Qualitätsprüfungen und Fehlerpfade härten
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
2026-10-15 10:05:48 +02:00
parent c0498b6935
commit 9dc37d1ece
12 changed files with 2383 additions and 517 deletions

View File

@@ -15,12 +15,21 @@ jobs:
sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev
- name: Rust installieren - name: Rust installieren
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Rust testen - name: Rust testen
run: | run: |
cargo fmt --check --manifest-path scanner/Cargo.toml
cargo fmt --check --manifest-path controller/Cargo.toml
cargo fmt --check --manifest-path gui/Cargo.toml
cargo clippy --locked --manifest-path scanner/Cargo.toml --all-targets -- -D warnings
cargo clippy --locked --manifest-path controller/Cargo.toml --all-targets -- -D warnings
cargo clippy --locked --manifest-path gui/Cargo.toml --all-targets -- -D warnings
cargo test --locked --manifest-path scanner/Cargo.toml cargo test --locked --manifest-path scanner/Cargo.toml
cargo test --locked --manifest-path controller/Cargo.toml cargo test --locked --manifest-path controller/Cargo.toml
cargo test --locked --manifest-path gui/Cargo.toml cargo test --locked --manifest-path gui/Cargo.toml
- name: Python und Rauchtests - name: Python und Rauchtests
run: | run: |
python3 -m unittest discover -s tests -p 'test_*.py' python3 -W error -m unittest discover -s tests -p 'test_*.py'
bash -n scripts/*.sh live/*.sh tests/*.sh
bash tests/smoke.sh bash tests/smoke.sh

View File

@@ -1,62 +1,115 @@
//! Optional technician workstations; no write access to internal devices. //! Optional technician workstations; no write access to internal devices.
use std::{fs, io::{self, Write}, path::Path, process::Command, time::{SystemTime, UNIX_EPOCH}}; use std::{
fs,
io::{self, Write},
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
fn ask(prompt: &str) -> String { fn ask(prompt: &str) -> String {
print!("{}", prompt); print!("{}", prompt);
let _ = io::stdout().flush(); let _ = io::stdout().flush();
let mut answer = String::new(); let mut answer = String::new();
if io::stdin().read_line(&mut answer).is_err() { return String::new(); } if io::stdin().read_line(&mut answer).is_err() {
return String::new();
}
answer.trim().to_string() answer.trim().to_string()
} }
fn read_trim(path: &str) -> Option<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()) 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> { fn output(exe: &str, args: &[&str]) -> Option<String> {
let r = Command::new(exe).args(args).output().ok()?; 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 } if r.status.success() {
Some(String::from_utf8_lossy(&r.stdout).trim().to_owned())
} else {
None
}
} }
fn escape_json(s: &str) -> String { fn escape_json(s: &str) -> String {
let mut result = String::from("\""); let mut result = String::from("\"");
for c in s.chars() { match c { for c in s.chars() {
'"' => result.push_str("\\\""), '\\' => result.push_str("\\\\"), match c {
'\n' => result.push_str("\\n"), '\r' => result.push_str("\\r"), '\t' => result.push_str("\\t"), '"' => 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 if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
c => result.push(c), c => result.push(c),
}} }
result.push('"'); result }
result.push('"');
result
} }
fn valid_asset(id: &str) -> bool { fn valid_asset(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') !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 { fn normalize_hostname(model: &str, asset: &str) -> String {
let source = format!("{}-{}", model, asset).to_ascii_lowercase(); let source = format!("{}-{}", model, asset).to_ascii_lowercase();
let mut result = String::new(); let mut result = String::new();
let mut last_dash = false; let mut last_dash = false;
for c in source.chars() { for c in source.chars() {
if c.is_ascii_alphanumeric() { result.push(c); last_dash = false; } if c.is_ascii_alphanumeric() {
else if !last_dash && !result.is_empty() { result.push('-'); last_dash = true; } 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() }
result
.trim_matches('-')
.chars()
.take(63)
.collect::<String>()
.trim_end_matches('-')
.to_owned()
} }
fn net_interfaces() -> Vec<(String, String, String, String)> { fn net_interfaces() -> Vec<(String, String, String, String)> {
let mut list = Vec::new(); let mut list = Vec::new();
if let Ok(entries) = fs::read_dir("/sys/class/net") { if let Ok(entries) = fs::read_dir("/sys/class/net") {
for entry in entries.flatten() { for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned(); let name = entry.file_name().to_string_lossy().into_owned();
if name == "lo" { continue; } if name == "lo" {
continue;
}
let base = format!("/sys/class/net/{name}"); let base = format!("/sys/class/net/{name}");
let kind = if Path::new(&format!("{base}/wireless")).exists() { "Wi-Fi" } else { "LAN/other" }; 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 carrier = read_trim(&format!("{base}/carrier")).unwrap_or_else(|| "unknown".into());
let mac = read_trim(&format!("{base}/address")).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.push((name, kind.to_string(), carrier, mac));
} }
} }
list.sort(); list list.sort();
list
}
fn ethernet_connected() -> bool {
net_interfaces()
.iter()
.any(|(_, kind, carrier, _)| kind == "LAN/other" && carrier == "1")
} }
fn ethernet_connected() -> bool { net_interfaces().iter().any(|(_,kind,carrier,_)| kind == "LAN/other" && carrier == "1") }
fn memory_dmi() -> (String, String) { fn memory_dmi() -> (String, String) {
let text = match output("dmidecode", &["-t", "memory"]) { let text = match output("dmidecode", &["-t", "memory"]) {
Some(s) => s, None => return ("UNKNOWN (dmidecode unavailable or permissions missing)".into(), "UNKNOWN".into()), Some(s) => s,
None => {
return (
"UNKNOWN (dmidecode unavailable or permissions missing)".into(),
"UNKNOWN".into(),
)
}
}; };
let mut slots = 0usize; let mut slots = 0usize;
let mut occupied = 0usize; let mut occupied = 0usize;
@@ -64,12 +117,35 @@ fn memory_dmi() -> (String, String) {
let mut in_device = false; let mut in_device = false;
for line in text.lines() { for line in text.lines() {
let t = line.trim(); let t = line.trim();
if t == "Memory Device" { slots += 1; in_device = true; continue; } if t == "Memory Device" {
if t == "Physical Memory Array" { in_device = false; continue; } slots += 1;
if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" { max_cap = t.trim_start_matches("Maximum Capacity:").trim().into(); } in_device = true;
if in_device && t.starts_with("Size:") && !t.contains("No Module Installed") && !t.ends_with("Unknown") { occupied += 1; in_device = false; } continue;
} }
let summary = if slots == 0 { "UNKNOWN".into() } else { format!("{occupied}/{slots} occupied; {} free", slots.saturating_sub(occupied)) }; 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) (summary, max_cap)
} }
fn display_info() -> String { fn display_info() -> String {
@@ -77,22 +153,35 @@ fn display_info() -> String {
if let Ok(entries) = fs::read_dir("/sys/class/drm") { if let Ok(entries) = fs::read_dir("/sys/class/drm") {
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if !path.join("status").is_file() { continue; } if !path.join("status").is_file() {
continue;
}
let status = fs::read_to_string(path.join("status")).unwrap_or_default(); let status = fs::read_to_string(path.join("status")).unwrap_or_default();
if status.trim() != "connected" { continue; } if status.trim() != "connected" {
continue;
}
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
let modes = fs::read_to_string(path.join("modes")).unwrap_or_default(); let modes = fs::read_to_string(path.join("modes")).unwrap_or_default();
let mode = modes.lines().next().unwrap_or("resolution unknown"); let mode = modes.lines().next().unwrap_or("resolution unknown");
let size = fs::read(path.join("edid")).ok().and_then(|e| { let size = fs::read(path.join("edid"))
.ok()
.and_then(|e| {
if e.len() >= 23 && e[21] > 0 && e[22] > 0 { 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; let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54;
Some(format!("{diag:.1} inch (EDID estimate)")) Some(format!("{diag:.1} inch (EDID estimate)"))
} else { None } } else {
}).unwrap_or_else(|| "size UNKNOWN".into()); None
}
})
.unwrap_or_else(|| "size UNKNOWN".into());
displays.push(format!("{name}: {mode}, {size}")); displays.push(format!("{name}: {mode}, {size}"));
} }
} }
if displays.is_empty() { "UNKNOWN (no active DRM connector)".into() } else { displays.join("; ") } 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> { fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(), String> {
let folder = data.join("reports").join(asset).join("supplemental"); let folder = data.join("reports").join(asset).join("supplemental");
@@ -104,60 +193,121 @@ fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(),String
} }
pub fn device_details(data: &Path) { pub fn device_details(data: &Path) {
let asset = ask("Asset ID / Geraetenummer: "); let asset = ask("Asset ID / Geraetenummer: ");
if !valid_asset(&asset) { println!("Invalid asset ID"); return; } if !valid_asset(&asset) {
let manufacturer = read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into()); 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 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 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 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 (slots, max_ram) = memory_dmi();
let display = display_info(); let display = display_info();
let host = normalize_hostname(&model, &asset); let host = normalize_hostname(&model, &asset);
let connections = net_interfaces(); 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}"); 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}"); } 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."); 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)); 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() { for (i, (name, kind, carrier, mac)) in connections.iter().enumerate() {
if i>0 { json.push(','); } if i > 0 {
json.push_str(&format!("{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}",escape_json(name),escape_json(kind),escape_json(carrier),escape_json(mac))); 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"); json.push_str("]}\n");
if let Err(e)=save(data,&asset,"hardware-details.json",&json){ eprintln!("Save failed: {e}"); } if let Err(e) = save(data, &asset, "hardware-details.json", &json) {
eprintln!("Save failed: {e}");
}
} }
fn check_prompt(label: &str) -> &'static str { fn check_prompt(label: &str) -> &'static str {
println!("{label}"); println!("{label}");
loop { match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase().as_str() { loop {
"p" => return "PASS", "f" => return "FAIL", "u" | "" => return "NOT_TESTED", "n" => return "NOT_APPLICABLE", _ => println!("Use p/f/u/n"), 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) { pub fn guided_tests(data: &Path) {
let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; } 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."); println!("\nNETWORK PRECHECK: Before Wi-Fi test, unplug Ethernet if one is connected.");
if ethernet_connected() { if ethernet_connected() {
println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test."); println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test.");
let _ = ask("After unplugging LAN press Enter to recheck..."); 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."); } if ethernet_connected() {
} else { println!("No active wired link detected. Wireless testing may proceed."); } println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected.");
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<_>>()); } 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."); 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?") 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."); 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") { 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(); let result = Command::new("speaker-test")
println!("speaker-test outcome: {:?} (not proof that sound was audible)",result); .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?"); let audio = check_prompt("Speakers: both channels audible, without distortion?");
println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity."); println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity.");
let mic = check_prompt("Microphone recorded and replayed clearly?"); let mic = check_prompt("Microphone recorded and replayed clearly?");
println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running."); println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running.");
if let Ok(devices) = fs::read_dir("/dev") { 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}");} } 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 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)); 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}");} if let Err(e) = save(data, &asset, "guided-tests.json", &json) {
eprintln!("Save failed: {e}");
}
json.clear(); json.clear();
} }
pub fn maintenance_guidance() { pub fn maintenance_guidance() {
@@ -170,25 +320,72 @@ pub fn maintenance_guidance() {
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."); 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..."); 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. /// Read-only CPU stress plus allocated userspace memory checks. Not a full RAM test.
pub fn stress_test(data: &Path) { pub fn stress_test(data: &Path) {
let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; } 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!("\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."); 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;} if !ask("Run stress tests now? Type YES: ").eq("YES") {
let cpu=Command::new("stress-ng").args(["--cpu","0","--timeout","120s","--metrics-brief"]).status(); println!("Cancelled");
println!("CPU stress result: {cpu:?}"); return;
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 cpu = Command::new("stress-ng")
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; }; .args(["--cpu", "0", "--timeout", "120s", "--metrics-brief"])
println!("RAM stress result: {ram:?}"); .status();
let status=|r: &io::Result<std::process::ExitStatus>|match r {Ok(s) if s.success()=>"PASS",Ok(_)=>"FAIL",Err(_)=>"UNAVAILABLE"}; println!("CPU stress result: {cpu:?}");
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); let mem_kib = read_trim("/proc/meminfo")
if let Err(e)=save(data,&asset,"stress-test.json",&report){eprintln!("Save failed: {e}");} .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}");
}
}
#[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\"");
}
} }

View File

@@ -1,20 +1,220 @@
mod extended; mod extended;
use std::{env, fs, io::{self, Write}, path::{Path, PathBuf}, process::Command, time::{SystemTime, UNIX_EPOCH}}; use std::{
env, fs,
io::{self, Write},
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
const DATA: &str = "/mnt/intake-data"; 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 prompt(q: &str) -> 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))}} print!("{}", q);
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())} let _ = io::stdout().flush();
fn sqlquote(s:&str)->String{format!("'{}'",s.replace('\'',"''"))} let mut s = String::new();
fn db_path(data:&Path)->PathBuf{data.join("database/intake.sqlite3")} let _ = io::stdin().read_line(&mut s);
fn sql(data:&Path,statement:&str)->Result<String,String>{capture("sqlite3",&[db_path(data).to_str().ok_or("Invalid DB path")?,statement])} s.trim().to_string()
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 run(program: &str, args: &[&str]) -> Result<(), String> {
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)} let st = Command::new(program)
fn get_time()->u64{SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()} .args(args)
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)}} .status()
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")]; .map_err(|e| format!("{}: {}", program, e))?;
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");}} if st.success() {
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());} 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) { fn recent(data: &Path) {
let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;"; let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;";
match sql(data, query) { match sql(data, query) {
@@ -23,6 +223,90 @@ fn recent(data: &Path) {
Err(err) => println!("Database error: {}", err), 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 help() {
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")}}} println!("PC Intake AIO v1.7.0 — offline-first technician controller");
#[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'");}} 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'");
}
}

View File

@@ -448,13 +448,12 @@ pub fn run(tool: &str, settings: &crate::workflow::Settings) -> Diagnostic {
|| (tool == "thermals" && text.contains("Messpunkte: 0")) || (tool == "thermals" && text.contains("Messpunkte: 0"))
{ {
Outcome::Unavailable Outcome::Unavailable
} else if peak_temperature(&text) } else if ok
|| peak_temperature(&text)
.map(|v| v >= settings.max_temperature_c) .map(|v| v >= settings.max_temperature_c)
.unwrap_or(false) .unwrap_or(false)
{ {
Outcome::NeedsReview Outcome::NeedsReview
} else if ok {
Outcome::NeedsReview
} else { } else {
Outcome::SetupProblem Outcome::SetupProblem
}, },

View File

@@ -740,8 +740,8 @@ impl App {
.min_size(egui::vec2(ui.available_width(), 48.0)), .min_size(egui::vec2(ui.available_width(), 48.0)),
) )
.clicked() .clicked()
&& self.persist()
{ {
if self.persist() {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
self.receiver = Some(rx); self.receiver = Some(rx);
thread::spawn(move || { thread::spawn(move || {
@@ -759,7 +759,6 @@ impl App {
let _ = tx.send(Work::Power(result)); let _ = tx.send(Work::Power(result));
}); });
} }
}
ui.add_space(16.0); ui.add_space(16.0);
if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending { if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending {
ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben auf diesem USB gespeichert.","Not fully synced yet. Results remain saved on this USB.")); ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben auf diesem USB gespeichert.","Not fully synced yet. Results remain saved on this USB."));

View File

@@ -713,9 +713,12 @@ mod workflow_tests {
use super::*; use super::*;
#[test] #[test]
fn renders_network_password_and_result_states() { fn renders_network_password_and_result_states() {
let mut wifi = Wifi::default(); let mut wifi = Wifi {
wifi.networks = networks: parse_networks(
parse_networks(":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0"); ":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0",
),
..Default::default()
};
let ctx = egui::Context::default(); let ctx = egui::Context::default();
for i in 0..4 { for i in 0..4 {
wifi.selected = wifi.networks.get(i % 2).cloned(); wifi.selected = wifi.networks.get(i % 2).cloned();
@@ -743,9 +746,11 @@ mod workflow_tests {
} }
#[test] #[test]
fn completion_and_worker_failure_release_busy_state() { fn completion_and_worker_failure_release_busy_state() {
let mut wifi = Wifi::default(); let mut wifi = Wifi {
wifi.password = "keep-for-retry".into(); password: "keep-for-retry".into(),
wifi.selected = Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0)); selected: Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0)),
..Default::default()
};
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
wifi.receiver = Some(rx); wifi.receiver = Some(rx);
tx.send(Event::Connected(Err("Passwort prüfen".into()))) tx.send(Event::Connected(Err("Passwort prüfen".into())))

View File

@@ -880,8 +880,10 @@ mod regression_tests {
} }
#[test] #[test]
fn invalid_settings_are_rejected() { fn invalid_settings_are_rejected() {
let mut s = Settings::default(); let mut s = Settings {
s.stress_seconds = 0; stress_seconds: 0,
..Default::default()
};
assert!(s.validate().is_err()); assert!(s.validate().is_err());
s.stress_seconds = 120; s.stress_seconds = 120;
s.max_temperature_c = f64::NAN; s.max_temperature_c = f64::NAN;

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export CSR_BUILD_ID export CSR_BUILD_ID
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id) CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
for component in scanner controller gui; do for component in scanner controller gui; do
cargo fmt --check --manifest-path "$ROOT/$component/Cargo.toml"
cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml" cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml" cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml"
done done

View File

@@ -1,14 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic. # DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic.
set -Eeuo pipefail set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
DEVICE=${1:-} DEVICE=${1:-}
ISO=${2:-} ISO=${2:-}
if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then 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 echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2
fi fi
[[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; } [[ $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 for cmd in lsblk sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs sync bsdtar blockdev partprobe udevadm; do
command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; } command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; }
done done
DEVICE=$(readlink -f "$DEVICE") DEVICE=$(readlink -f "$DEVICE")
@@ -24,6 +23,29 @@ size_bytes=$(blockdev --getsize64 "$DEVICE")
((size_bytes >= 12*1024*1024*1024)) || { echo 'USB must be at least 12 GiB.' >&2; exit 2; } ((size_bytes >= 12*1024*1024*1024)) || { echo 'USB must be at least 12 GiB.' >&2; exit 2; }
iso_bytes=$(stat -c %s "$ISO") iso_bytes=$(stat -c %s "$ISO")
((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; } ((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; }
ISO_LIST=$(mktemp)
TMP=
cleanup(){
if [[ -n $TMP ]]; then
umount "$TMP/boot" 2>/dev/null || true
umount "$TMP/data" 2>/dev/null || true
rmdir "$TMP/boot" "$TMP/data" "$TMP" 2>/dev/null || true
fi
rm -f "$ISO_LIST"
}
trap cleanup EXIT
bsdtar -tf "$ISO" > "$ISO_LIST" 2>/dev/null || {
echo 'ISO cannot be read as an archive.' >&2
exit 2
}
grep -qx 'arch/boot/x86_64/vmlinuz-linux' "$ISO_LIST" || {
echo 'ISO layout does not match GRUB config (kernel missing).' >&2
exit 2
}
grep -qx 'arch/boot/x86_64/initramfs-linux.img' "$ISO_LIST" || {
echo 'ISO layout does not match GRUB config (initramfs missing).' >&2
exit 2
}
model=$(lsblk -dn -o MODEL "$DEVICE" | xargs) model=$(lsblk -dn -o MODEL "$DEVICE" | xargs)
serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs) serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs)
echo '===== DESTRUCTIVE USB INSTALL =====' echo '===== DESTRUCTIVE USB INSTALL ====='
@@ -55,8 +77,6 @@ done
mkfs.vfat -F32 -n INTAKE_BOOT "$p2" mkfs.vfat -F32 -n INTAKE_BOOT "$p2"
mkfs.ext4 -F -L INTAKE_DATA "$p3" mkfs.ext4 -F -L INTAKE_DATA "$p3"
TMP=$(mktemp -d) 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" mkdir -p "$TMP/boot" "$TMP/data"
mount "$p2" "$TMP/boot"; mount "$p3" "$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" mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference"
@@ -69,7 +89,6 @@ if ! cp "$ISO" "$TMP/boot/pc-intake.iso" || ! sync -f "$TMP/boot"; then
fi fi
exit 1 exit 1
fi fi
# Copy video reference files separately under INTAKE_DATA/reference (optional).
cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG' cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG'
set timeout=5 set timeout=5
set default=0 set default=0
@@ -83,13 +102,6 @@ menuentry 'PC Intake Live USB (offline technician mode)' {
menuentry 'Reboot' { reboot } menuentry 'Reboot' { reboot }
menuentry 'Power off' { halt } menuentry 'Power off' { halt }
GRUBCFG 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=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" grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE"
sync sync

View File

@@ -3,6 +3,7 @@
Stdlib only; server assigns numbers and immutable reports stay unchanged. Stdlib only; server assigns numbers and immutable reports stay unchanged.
""" """
import argparse import argparse
import contextlib
import datetime as dt import datetime as dt
import hashlib import hashlib
import time import time
@@ -116,7 +117,8 @@ def credentials(config):
base = config.get('url', '') base = config.get('url', '')
if not base: raise ValueError('Inventory URL is not configured; reports remain offline') if not base: raise ValueError('Inventory URL is not configured; reports remain offline')
parsed = urllib.parse.urlparse(base) 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: if (parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password
or parsed.path not in ('', '/') or parsed.params or parsed.query or parsed.fragment):
raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)') raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)')
key = os.environ.get('CSR_API_KEY', '') key = os.environ.get('CSR_API_KEY', '')
if not key: if not key:
@@ -124,7 +126,7 @@ def credentials(config):
key = secret.read_text(encoding='utf-8').strip() key = secret.read_text(encoding='utf-8').strip()
if not key: if not key:
raise ValueError('No API key configured') raise ValueError('No API key configured')
return base, key return base.rstrip('/'), key
def connection_error(ex): def connection_error(ex):
# Never include server responses, request headers, or raw URLs in errors. # Never include server responses, request headers, or raw URLs in errors.
@@ -252,6 +254,8 @@ def process_queue(root, db, config, retry=False, selected=None, retry_seconds=60
failures += 1 failures += 1
# Error strings deliberately exclude URLs, API keys and raw server responses. # Error strings deliberately exclude URLs, API keys and raw server responses.
error = connection_error(ex) error = connection_error(ex)
if isinstance(ex, urllib.error.HTTPError):
ex.close()
delay = min(3600, retry_seconds * (2 ** min(attempts, 6))) delay = min(3600, retry_seconds * (2 ** min(attempts, 6)))
with db: with db:
db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha)) db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha))
@@ -347,7 +351,7 @@ def run_station(root,action,report_path):
'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)}) 'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)})
if action == 'intake' and report_path: if action == 'intake' and report_path:
raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest() raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest()
with sqlite3.connect(root/'database/intake.sqlite3') as db: with contextlib.closing(sqlite3.connect(root/'database/intake.sqlite3')) as db, db:
init_db(db);discover_queue(root,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("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,)) db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,))
@@ -388,10 +392,12 @@ def main():
return 2 return 2
if args.station: if args.station:
try: return run_station(root,args.station,args.report) try: return run_station(root,args.station,args.report)
except (OSError,ValueError,KeyError) as ex: except (OSError,ValueError,KeyError,urllib.error.URLError) as ex:
print(connection_error(ex),file=sys.stderr) print(connection_error(ex),file=sys.stderr)
if isinstance(ex, urllib.error.HTTPError):
ex.close()
return 2 return 2
with sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20) as db: with contextlib.closing(sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20)) as db, db:
init_db(db) init_db(db)
discover_queue(root, db) discover_queue(root, db)
if args.list: if args.list:

View File

@@ -1,4 +1,5 @@
import importlib.util import importlib.util
import contextlib
import json import json
from pathlib import Path from pathlib import Path
import sqlite3 import sqlite3
@@ -31,7 +32,7 @@ class Tests(unittest.TestCase):
for data in [{}, {'system_uuid':'00000000-0000-0000-0000-000000000000'}, {'serial_number':'To Be Filled By O.E.M.','manufacturer':'HP'}]: 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) with self.assertRaises(ValueError): bridge.hardware_id(data)
def test_missing_hwid_never_uses_manual_number(self): def test_missing_hwid_never_uses_manual_number(self):
with tempfile.TemporaryDirectory() as tmp, sqlite3.connect(':memory:') as db: with tempfile.TemporaryDirectory() as tmp, contextlib.closing(sqlite3.connect(':memory:')) as db:
bridge.init_db(db) bridge.init_db(db)
path=Path(tmp)/'report.json' path=Path(tmp)/'report.json'
path.write_text(json.dumps({'asset_id':'45','model':'Laptop'})) path.write_text(json.dumps({'asset_id':'45','model':'Laptop'}))
@@ -48,7 +49,7 @@ class Tests(unittest.TestCase):
if method=='POST': if method=='POST':
return {'number':501,'revision':3,'model':'HP EliteBook 745 G6','cpu':'old','ram_gb':8,'ssd_gb':128} return {'number':501,'revision':3,'model':'HP EliteBook 745 G6','cpu':'old','ram_gb':8,'ssd_gb':128}
return {'revision':4} return {'revision':4}
with sqlite3.connect(':memory:') as db: with contextlib.closing(sqlite3.connect(':memory:')) as db:
bridge.init_db(db) bridge.init_db(db)
with patch.object(bridge,'api_request',side_effect=fake), patch.dict('os.environ',{'CSR_API_KEY':'fake'}): 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) res=bridge.sync_one(path,{'url':'https://example.org'},db)
@@ -63,7 +64,7 @@ class Tests(unittest.TestCase):
def test_mismatch_not_patched(self): def test_mismatch_not_patched(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'report.json';path.write_text(json.dumps(self.report)) path=Path(tmp)/'report.json';path.write_text(json.dumps(self.report))
with sqlite3.connect(':memory:') as db: with contextlib.closing(sqlite3.connect(':memory:')) as db:
bridge.init_db(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 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'): with self.assertRaisesRegex(ValueError,'MODEL_MISMATCH'):
@@ -172,3 +173,19 @@ class ConnectionTests(unittest.TestCase):
self.assertIn('API key', bridge.connection_error(error)) self.assertIn('API key', bridge.connection_error(error))
self.assertNotIn('secret', bridge.connection_error(error)) self.assertNotIn('secret', bridge.connection_error(error))
error.close() error.close()
def test_inventory_url_must_be_an_https_origin(self):
with patch.dict('os.environ', {'CSR_API_KEY': 'test-key'}):
self.assertEqual(
bridge.credentials({'url': 'https://inventory.test/'}),
('https://inventory.test', 'test-key'),
)
for url in (
'http://inventory.test',
'https://user:secret@inventory.test',
'https://inventory.test/api',
'https://inventory.test?token=secret',
'https://inventory.test#fragment',
):
with self.subTest(url=url), self.assertRaisesRegex(ValueError, 'HTTPS origin'):
bridge.credentials({'url': url})