From 9dc37d1ecee6ec8229c884c09c93ebe4c7426630 Mon Sep 17 00:00:00 2001 From: Denis Stoecklmeier Date: Thu, 15 Oct 2026 10:05:48 +0200 Subject: [PATCH] =?UTF-8?q?Qualit=C3=A4tspr=C3=BCfungen=20und=20Fehlerpfad?= =?UTF-8?q?e=20h=C3=A4rten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/tests.yml | 11 +- controller/src/extended.rs | 349 ++++-- controller/src/main.rs | 320 +++++- gui/src/diagnostics.rs | 9 +- gui/src/main.rs | 3 +- gui/src/wifi.rs | 17 +- gui/src/workflow.rs | 6 +- scanner/src/main.rs | 2108 +++++++++++++++++++++++++++------- scripts/build-release.sh | 2 + scripts/install-usb.sh | 36 +- scripts/inventory-sync.py | 16 +- tests/test_inventory_sync.py | 23 +- 12 files changed, 2383 insertions(+), 517 deletions(-) diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 4deea20..28dc0c7 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -15,12 +15,21 @@ jobs: sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev - name: Rust installieren uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy - name: Rust testen 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 controller/Cargo.toml cargo test --locked --manifest-path gui/Cargo.toml - name: Python und Rauchtests run: | - python3 -m unittest discover -s tests -p 'test_*.py' + python3 -W error -m unittest discover -s tests -p 'test_*.py' + bash -n scripts/*.sh live/*.sh tests/*.sh bash tests/smoke.sh diff --git a/controller/src/extended.rs b/controller/src/extended.rs index 0b8e588..1a1e0b8 100644 --- a/controller/src/extended.rs +++ b/controller/src/extended.rs @@ -1,62 +1,115 @@ //! 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 { print!("{}", prompt); let _ = io::stdout().flush(); 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() } fn read_trim(path: &str) -> Option { - 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 { 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 { let mut result = String::from("\""); - for c in s.chars() { match c { - '"' => result.push_str("\\\""), '\\' => result.push_str("\\\\"), - '\n' => result.push_str("\\n"), '\r' => result.push_str("\\r"), '\t' => result.push_str("\\t"), - c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)), - c => result.push(c), - }} - result.push('"'); result + for c in s.chars() { + match c { + '"' => result.push_str("\\\""), + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)), + c => result.push(c), + } + } + result.push('"'); + result } fn valid_asset(id: &str) -> bool { - !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } fn normalize_hostname(model: &str, asset: &str) -> String { let source = format!("{}-{}", model, asset).to_ascii_lowercase(); let mut result = String::new(); let mut last_dash = false; for c in source.chars() { - if c.is_ascii_alphanumeric() { result.push(c); last_dash = false; } - else if !last_dash && !result.is_empty() { result.push('-'); last_dash = true; } + if c.is_ascii_alphanumeric() { + result.push(c); + last_dash = false; + } else if !last_dash && !result.is_empty() { + result.push('-'); + last_dash = true; + } } - result.trim_matches('-').chars().take(63).collect::().trim_end_matches('-').to_owned() + result + .trim_matches('-') + .chars() + .take(63) + .collect::() + .trim_end_matches('-') + .to_owned() } fn net_interfaces() -> Vec<(String, String, String, String)> { let mut list = Vec::new(); if let Ok(entries) = fs::read_dir("/sys/class/net") { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); - if name == "lo" { continue; } + if name == "lo" { + continue; + } let base = format!("/sys/class/net/{name}"); - let kind = if Path::new(&format!("{base}/wireless")).exists() { "Wi-Fi" } else { "LAN/other" }; + let kind = if Path::new(&format!("{base}/wireless")).exists() { + "Wi-Fi" + } else { + "LAN/other" + }; let carrier = read_trim(&format!("{base}/carrier")).unwrap_or_else(|| "unknown".into()); let mac = read_trim(&format!("{base}/address")).unwrap_or_else(|| "unknown".into()); list.push((name, kind.to_string(), carrier, mac)); } } - list.sort(); list + 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) { 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 occupied = 0usize; @@ -64,12 +117,35 @@ fn memory_dmi() -> (String, String) { let mut in_device = false; for line in text.lines() { let t = line.trim(); - if t == "Memory Device" { slots += 1; in_device = true; continue; } - if t == "Physical Memory Array" { in_device = false; continue; } - if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" { max_cap = t.trim_start_matches("Maximum Capacity:").trim().into(); } - if in_device && t.starts_with("Size:") && !t.contains("No Module Installed") && !t.ends_with("Unknown") { occupied += 1; in_device = false; } + if t == "Memory Device" { + slots += 1; + in_device = true; + continue; + } + if t == "Physical Memory Array" { + in_device = false; + continue; + } + if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" { + max_cap = t.trim_start_matches("Maximum Capacity:").trim().into(); + } + if in_device + && t.starts_with("Size:") + && !t.contains("No Module Installed") + && !t.ends_with("Unknown") + { + occupied += 1; + in_device = false; + } } - let summary = if slots == 0 { "UNKNOWN".into() } else { format!("{occupied}/{slots} occupied; {} free", slots.saturating_sub(occupied)) }; + let summary = if slots == 0 { + "UNKNOWN".into() + } else { + format!( + "{occupied}/{slots} occupied; {} free", + slots.saturating_sub(occupied) + ) + }; (summary, max_cap) } fn display_info() -> String { @@ -77,87 +153,161 @@ fn display_info() -> String { if let Ok(entries) = fs::read_dir("/sys/class/drm") { for entry in entries.flatten() { let path = entry.path(); - if !path.join("status").is_file() { continue; } + if !path.join("status").is_file() { + continue; + } 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 modes = fs::read_to_string(path.join("modes")).unwrap_or_default(); let mode = modes.lines().next().unwrap_or("resolution unknown"); - let size = fs::read(path.join("edid")).ok().and_then(|e| { - if e.len() >= 23 && e[21] > 0 && e[22] > 0 { - let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54; - Some(format!("{diag:.1} inch (EDID estimate)")) - } else { None } - }).unwrap_or_else(|| "size UNKNOWN".into()); + let size = fs::read(path.join("edid")) + .ok() + .and_then(|e| { + if e.len() >= 23 && e[21] > 0 && e[22] > 0 { + let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54; + Some(format!("{diag:.1} inch (EDID estimate)")) + } else { + None + } + }) + .unwrap_or_else(|| "size UNKNOWN".into()); displays.push(format!("{name}: {mode}, {size}")); } } - if displays.is_empty() { "UNKNOWN (no active DRM connector)".into() } else { displays.join("; ") } + 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"); fs::create_dir_all(&folder).map_err(|e| e.to_string())?; let path = folder.join(stem); - fs::write(&path, content).map_err(|e|e.to_string())?; + fs::write(&path, content).map_err(|e| e.to_string())?; println!("Saved to {}", path.display()); Ok(()) } pub fn device_details(data: &Path) { let asset = ask("Asset ID / Geraetenummer: "); - if !valid_asset(&asset) { println!("Invalid asset ID"); return; } - let manufacturer = read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into()); + if !valid_asset(&asset) { + println!("Invalid asset ID"); + return; + } + let manufacturer = + read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into()); let model = read_trim("/sys/class/dmi/id/product_name").unwrap_or_else(|| "UNKNOWN".into()); let uuid = read_trim("/sys/class/dmi/id/product_uuid").unwrap_or_else(|| "UNKNOWN".into()); - let cpu = read_trim("/proc/cpuinfo").and_then(|s|s.lines().find_map(|line|line.strip_prefix("model name").and_then(|v|v.split_once(':').map(|(_,v)|v.trim().to_owned())))).unwrap_or_else(|| "UNKNOWN".into()); + let cpu = read_trim("/proc/cpuinfo") + .and_then(|s| { + s.lines().find_map(|line| { + line.strip_prefix("model name") + .and_then(|v| v.split_once(':').map(|(_, v)| v.trim().to_owned())) + }) + }) + .unwrap_or_else(|| "UNKNOWN".into()); let (slots, max_ram) = memory_dmi(); let display = display_info(); let host = normalize_hostname(&model, &asset); let connections = net_interfaces(); println!("\nManufacturer: {manufacturer}\nModel: {model}\nCPU: {cpu}\nUUID: {uuid}\nRAM slots: {slots}\nMax RAM (BIOS estimate): {max_ram}\nDisplay: {display}\nProposed hostname: {host}"); - for (name,kind,carrier,mac) in &connections { println!("Interface {name}: {kind}, carrier={carrier}, MAC={mac}"); } + for (name, kind, carrier, mac) in &connections { + println!("Interface {name}: {kind}, carrier={carrier}, MAC={mac}"); + } println!("Storage sizes and device type are collected by the main scanner; don't include the boot USB."); let mut json = format!("{{\"asset_id\":{},\"manufacturer\":{},\"model\":{},\"cpu\":{},\"uuid\":{},\"ram_slots\":{},\"ram_max\":{},\"display\":{},\"proposed_hostname\":{},\"network\":[",escape_json(&asset),escape_json(&manufacturer),escape_json(&model),escape_json(&cpu),escape_json(&uuid),escape_json(&slots),escape_json(&max_ram),escape_json(&display),escape_json(&host)); - for (i,(name,kind,carrier,mac)) in connections.iter().enumerate() { - if i>0 { json.push(','); } - json.push_str(&format!("{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}",escape_json(name),escape_json(kind),escape_json(carrier),escape_json(mac))); + for (i, (name, kind, carrier, mac)) in connections.iter().enumerate() { + if i > 0 { + json.push(','); + } + json.push_str(&format!( + "{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}", + escape_json(name), + escape_json(kind), + escape_json(carrier), + escape_json(mac) + )); } json.push_str("]}\n"); - if let Err(e)=save(data,&asset,"hardware-details.json",&json){ eprintln!("Save failed: {e}"); } + if let Err(e) = save(data, &asset, "hardware-details.json", &json) { + eprintln!("Save failed: {e}"); + } } fn check_prompt(label: &str) -> &'static str { println!("{label}"); - loop { match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase().as_str() { - "p" => return "PASS", "f" => return "FAIL", "u" | "" => return "NOT_TESTED", "n" => return "NOT_APPLICABLE", _ => println!("Use p/f/u/n"), - }} + loop { + match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ") + .to_lowercase() + .as_str() + { + "p" => return "PASS", + "f" => return "FAIL", + "u" | "" => return "NOT_TESTED", + "n" => return "NOT_APPLICABLE", + _ => println!("Use p/f/u/n"), + } + } } pub fn guided_tests(data: &Path) { - let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; } + let asset = ask("Asset ID: "); + if !valid_asset(&asset) { + println!("Invalid asset ID"); + return; + } println!("\nNETWORK PRECHECK: Before Wi-Fi test, unplug Ethernet if one is connected."); if ethernet_connected() { println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test."); - let _=ask("After unplugging LAN press Enter to recheck..."); - if ethernet_connected() { println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected."); } - } else { println!("No active wired link detected. Wireless testing may proceed."); } - let wifi = if ethernet_connected() { "NOT_TESTED" } else { - println!("Wi-Fi interfaces: {:?}",net_interfaces().iter().filter(|x|x.1=="Wi-Fi").map(|x|&x.0).collect::>()); + let _ = ask("After unplugging LAN press Enter to recheck..."); + if ethernet_connected() { + println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected."); + } + } else { + println!("No active wired link detected. Wireless testing may proceed."); + } + let wifi = if ethernet_connected() { + "NOT_TESTED" + } else { + println!( + "Wi-Fi interfaces: {:?}", + net_interfaces() + .iter() + .filter(|x| x.1 == "Wi-Fi") + .map(|x| &x.0) + .collect::>() + ); println!("Connect using your network manager and verify actual traffic over Wi-Fi. Interface detection alone is not enough."); check_prompt("Wi-Fi connection and data transfer verified?") }; println!("\nAUDIO: We'll attempt a stereo test sound if speaker-test exists. Confirm left/right audibly."); if ask("Run short speaker test? [y/N] ").eq_ignore_ascii_case("y") { - let result=Command::new("speaker-test").args(["-c","2","-t","wav","-l","1"]).status(); - println!("speaker-test outcome: {:?} (not proof that sound was audible)",result); + let result = Command::new("speaker-test") + .args(["-c", "2", "-t", "wav", "-l", "1"]) + .status(); + println!( + "speaker-test outcome: {:?} (not proof that sound was audible)", + result + ); } - let audio=check_prompt("Speakers: both channels audible, without distortion?"); + let audio = check_prompt("Speakers: both channels audible, without distortion?"); println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity."); - let mic=check_prompt("Microphone recorded and replayed clearly?"); + let mic = check_prompt("Microphone recorded and replayed clearly?"); println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running."); - if let Ok(devices)=fs::read_dir("/dev") { - for dev in devices.flatten() { let n=dev.file_name().to_string_lossy().to_string(); if n.starts_with("video") {println!("Found /dev/{n}");} } + if let Ok(devices) = fs::read_dir("/dev") { + for dev in devices.flatten() { + let n = dev.file_name().to_string_lossy().to_string(); + if n.starts_with("video") { + println!("Found /dev/{n}"); + } + } } - let webcam=check_prompt("Webcam picture inspected and working?"); + let webcam = check_prompt("Webcam picture inspected and working?"); let mut json=format!("{{\"asset_id\":{},\"timestamp\":{},\"wifi\":{},\"audio\":{},\"microphone\":{},\"webcam\":{}}}\n",escape_json(&asset),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),escape_json(wifi),escape_json(audio),escape_json(mic),escape_json(webcam)); - if let Err(e)=save(data,&asset,"guided-tests.json",&json){eprintln!("Save failed: {e}");} + if let Err(e) = save(data, &asset, "guided-tests.json", &json) { + eprintln!("Save failed: {e}"); + } json.clear(); } pub fn maintenance_guidance() { @@ -168,27 +318,74 @@ pub fn maintenance_guidance() { println!("For Arch Linux installed systems:"); println!(" sudo pacman -Syu"); println!("Do not chroot or rename another machine's OS without selecting and validating its target disk. Proposed hostname is stored in hardware-details.json for controlled provisioning."); - let _=ask("Press Enter to return to menu..."); -} -#[cfg(test)] mod tests { - use super::*; - #[test] fn valid_asset_ids(){assert!(valid_asset("PC-0042")); assert!(!valid_asset("../evil"));} - #[test] fn hostnames(){assert_eq!(normalize_hostname("EliteBook 745 G6","PC-0001"),"elitebook-745-g6-pc-0001");} - #[test] fn escape(){assert_eq!(escape_json("a\"b"),"\"a\\\"b\"");} + let _ = ask("Press Enter to return to menu..."); } /// Read-only CPU stress plus allocated userspace memory checks. Not a full RAM test. pub fn stress_test(data: &Path) { - let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; } + let asset = ask("Asset ID: "); + if !valid_asset(&asset) { + println!("Invalid asset ID"); + return; + } println!("\nThis uses stress-ng (120 seconds) and memtester on up to 50% of MemAvailable, capped at 8 GiB."); println!("Do not use on a machine with unstable cooling. This cannot test all physical RAM or prove absence of faults."); - if !ask("Run stress tests now? Type YES: ").eq("YES") {println!("Cancelled");return;} - let cpu=Command::new("stress-ng").args(["--cpu","0","--timeout","120s","--metrics-brief"]).status(); + if !ask("Run stress tests now? Type YES: ").eq("YES") { + println!("Cancelled"); + return; + } + let cpu = Command::new("stress-ng") + .args(["--cpu", "0", "--timeout", "120s", "--metrics-brief"]) + .status(); println!("CPU stress result: {cpu:?}"); - let mem_kib=read_trim("/proc/meminfo").and_then(|t|t.lines().find(|l|l.starts_with("MemAvailable:")).and_then(|line| line.split_whitespace().nth(1)).and_then(|n|n.parse::().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; }; + 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::().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|match r {Ok(s) if s.success()=>"PASS",Ok(_)=>"FAIL",Err(_)=>"UNAVAILABLE"}; + let status = |r: &io::Result| 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}");} + 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\""); + } } diff --git a/controller/src/main.rs b/controller/src/main.rs index 16f08c3..0e59ddb 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -1,20 +1,220 @@ 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"; -fn prompt(q: &str) -> String { print!("{}",q); let _=io::stdout().flush(); let mut s=String::new(); let _=io::stdin().read_line(&mut s); s.trim().to_string() } -fn run(program:&str,args:&[&str])->Result<(),String>{let st=Command::new(program).args(args).status().map_err(|e|format!("{}: {}",program,e))?;if st.success(){Ok(())}else{Err(format!("{} exited with {}",program,st))}} -fn capture(program:&str,args:&[&str])->Result{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{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{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 prompt(q: &str) -> String { + print!("{}", q); + let _ = io::stdout().flush(); + let mut s = String::new(); + let _ = io::stdin().read_line(&mut s); + s.trim().to_string() +} +fn run(program: &str, args: &[&str]) -> Result<(), String> { + let st = Command::new(program) + .args(args) + .status() + .map_err(|e| format!("{}: {}", program, e))?; + if st.success() { + Ok(()) + } else { + Err(format!("{} exited with {}", program, st)) + } +} +fn capture(program: &str, args: &[&str]) -> Result { + 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 { + 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 { + let root = data.join("reports").join(id); + let mut matches = Vec::new(); + for d in fs::read_dir(root).ok()?.flatten() { + let p = d.path().join("report.json"); + if p.is_file() { + let mt = p.metadata().ok()?.modified().ok()?; + matches.push((mt, p)); + } + } + matches.sort_by_key(|(m, _)| *m); + matches.pop().map(|(_, p)| p) +} +fn get_time() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn scan(data: &Path, quick: bool) { + let id = prompt("Asset ID: "); + if !valid_id(&id) { + println!("Invalid ID (letters, digits, underscore and hyphen only)"); + return; + } + let out = data.join("reports"); + let outstr = out.to_string_lossy().to_string(); + let mut cmd = Command::new("/usr/local/bin/pc-intake"); + cmd.args(["--asset-id", &id, "--output", &outstr]); + if quick { + cmd.arg("--quick"); + } else { + cmd.arg("--compile"); + } + let checklist = data.join("checklists").join(format!("{}.txt", id)); + if checklist.exists() { + cmd.args(["--checklist", &checklist.to_string_lossy()]); + } + println!("Running {} scan...", if quick { "quick" } else { "full" }); + match cmd.status() { + Ok(status) if status.success() => { + if let Some(report) = latest(data, &id) { + let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy()))); + match result { + Ok(_) => { + println!("Saved: {}", report.display()); + println!("Inventory sync: PENDING (not uploaded)"); + } + Err(e) => println!("Database write failed: {}. JSON report remains saved.", e), + } + } else { + println!("Scanner exited successfully, but no report found"); + } + } + Ok(status) => println!("Scanner failed: {}", status), + Err(e) => println!("Could not launch scanner: {}", e), + } +} +const CHECKS: &[(&str, &str)] = &[ + ( + "display", + "Show colors/brightness and inspect for dead pixels", + ), + ( + "keyboard", + "Check every key, including modifiers and function keys", + ), + ( + "touchpad", + "Verify movement, taps, buttons and scrolling; N/A for desktop", + ), + ( + "wifi", + "Connect to a network and open a local or remote test service", + ), + ("audio", "Play left/right sound and check for distortion"), + ("microphone", "Record voice and play it back"), + ( + "webcam", + "Preview image and verify focus and quality; N/A if absent", + ), + ( + "usb_ports", + "Insert a known-good test device in each physical port", + ), + ( + "charging", + "Verify AC adapter, charging status, battery runtime; N/A for desktop", + ), + ("thermals", "Verify fans, heat and sustained load"), + ( + "stability", + "Check for crashes, unexpected resets or kernel errors", + ), +]; +fn technician(data: &Path) { + let id = prompt("Asset ID for inspection: "); + if !valid_id(&id) { + println!("Invalid ID"); + return; + } + let mut rows = Vec::new(); + for (key, desc) in CHECKS { + println!("\n{}: {}", key, desc); + if *key == "audio" { + println!("Optional speaker test (if installed): speaker-test -c 2 -t wav"); + } + if *key == "webcam" { + println!( + "Optional camera detection: ls /dev/video*; preview requires graphical session" + ); + } + loop { + let ans = prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase(); + if let Some(st) = match ans.as_str() { + "p" => Some("PASS"), + "f" => Some("FAIL"), + "u" | "" => Some("NOT_TESTED"), + "n" => Some("NOT_APPLICABLE"), + _ => None, + } { + rows.push((key.to_string(), st.to_string())); + break; + } + println!("Enter p, f, u or n"); + } + } + let target = data.join("checklists").join(format!("{}.txt", id)); + let mut raw = String::new(); + for (k, v) in &rows { + raw.push_str(&format!("{}={}\n", k, v)); + } + if let Err(e) = fs::write(&target, raw) { + println!("Could not save checklist: {}", e); + return; + } + let fail = rows.iter().any(|(_, s)| s == "FAIL"); + let unknown = rows.iter().any(|(_, s)| s == "NOT_TESTED"); + let verdict = if fail { + "FAIL" + } else if unknown { + "INCOMPLETE" + } else { + "COMPLETED" + }; + let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id))); + println!( + "Inspection {}. Saved {}; re-scan to embed checklist in hardware report.", + verdict, + target.display() + ); +} fn recent(data: &Path) { let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;"; match sql(data, query) { @@ -23,6 +223,90 @@ fn recent(data: &Path) { Err(err) => println!("Database error: {}", err), } } -fn help(){println!("PC Intake AIO v1.7.0 — offline-first technician controller");println!("Usage: intake-controller [--data PATH] [--self-check]");} -fn main(){let args:Vec=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'");}} +fn help() { + println!("PC Intake AIO v1.7.0 — offline-first technician controller"); + println!("Usage: intake-controller [--data PATH] [--self-check]"); +} +fn main() { + let args: Vec = 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'"); + } +} diff --git a/gui/src/diagnostics.rs b/gui/src/diagnostics.rs index a40d2a2..f61fe3e 100644 --- a/gui/src/diagnostics.rs +++ b/gui/src/diagnostics.rs @@ -448,13 +448,12 @@ pub fn run(tool: &str, settings: &crate::workflow::Settings) -> Diagnostic { || (tool == "thermals" && text.contains("Messpunkte: 0")) { Outcome::Unavailable - } else if peak_temperature(&text) - .map(|v| v >= settings.max_temperature_c) - .unwrap_or(false) + } else if ok + || peak_temperature(&text) + .map(|v| v >= settings.max_temperature_c) + .unwrap_or(false) { Outcome::NeedsReview - } else if ok { - Outcome::NeedsReview } else { Outcome::SetupProblem }, diff --git a/gui/src/main.rs b/gui/src/main.rs index ae5ef4e..f732026 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -740,8 +740,8 @@ impl App { .min_size(egui::vec2(ui.available_width(), 48.0)), ) .clicked() + && self.persist() { - if self.persist() { let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); thread::spawn(move || { @@ -758,7 +758,6 @@ impl App { }); let _ = tx.send(Work::Power(result)); }); - } } ui.add_space(16.0); if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending { diff --git a/gui/src/wifi.rs b/gui/src/wifi.rs index 306f1e3..30d794b 100644 --- a/gui/src/wifi.rs +++ b/gui/src/wifi.rs @@ -713,9 +713,12 @@ 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 mut wifi = Wifi { + networks: parse_networks( + ":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0", + ), + ..Default::default() + }; let ctx = egui::Context::default(); for i in 0..4 { wifi.selected = wifi.networks.get(i % 2).cloned(); @@ -743,9 +746,11 @@ mod workflow_tests { } #[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 mut wifi = Wifi { + password: "keep-for-retry".into(), + selected: Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0)), + ..Default::default() + }; let (tx, rx) = mpsc::channel(); wifi.receiver = Some(rx); tx.send(Event::Connected(Err("Passwort prüfen".into()))) diff --git a/gui/src/workflow.rs b/gui/src/workflow.rs index 7211b37..222e003 100644 --- a/gui/src/workflow.rs +++ b/gui/src/workflow.rs @@ -880,8 +880,10 @@ mod regression_tests { } #[test] fn invalid_settings_are_rejected() { - let mut s = Settings::default(); - s.stress_seconds = 0; + let mut s = Settings { + stress_seconds: 0, + ..Default::default() + }; assert!(s.validate().is_err()); s.stress_seconds = 120; s.max_temperature_c = f64::NAN; diff --git a/scanner/src/main.rs b/scanner/src/main.rs index 60ccd09..db32914 100644 --- a/scanner/src/main.rs +++ b/scanner/src/main.rs @@ -1,210 +1,597 @@ //! Offline Linux intake. No crates, shell commands, network, or write benchmarks. -use std::{env, fs, hint::black_box, path::{Path, PathBuf}, process::{self, Command}, thread, time::{Instant, SystemTime, UNIX_EPOCH}}; +use std::{ + env, fs, + hint::black_box, + path::{Path, PathBuf}, + process::{self, Command}, + thread, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; const VERSION: &str = env!("CARGO_PKG_VERSION"); const WORK: u64 = 4_000_000; #[derive(Debug)] -struct Disk { name: String, gib: u64, rotational: Option, removable: bool, model: String, health: String, smart_details: String } +struct Disk { + name: String, + gib: u64, + rotational: Option, + removable: bool, + model: String, + health: String, + smart_details: String, +} #[derive(Debug)] -struct Battery { name: String, capacity_pct: Option, health_pct: Option, status: String } +struct Battery { + name: String, + capacity_pct: Option, + health_pct: Option, + status: String, +} #[derive(Debug)] -struct Bench { single_ms: f64, multi_ms: f64, threads: usize, single_runs_ms: Vec, parallel_runs_ms: Vec } +struct Bench { + single_ms: f64, + multi_ms: f64, + threads: usize, + single_runs_ms: Vec, + parallel_runs_ms: Vec, +} #[derive(Debug)] -struct CompileBench { compiler: String, median_ms: f64, runs_ms: Vec, source_functions: usize, status: String } +struct CompileBench { + compiler: String, + median_ms: f64, + runs_ms: Vec, + source_functions: usize, + status: String, +} #[derive(Debug)] -struct DevBench { median_ms: f64, runs_ms: Vec, records: usize, checksum: u64 } +struct DevBench { + median_ms: f64, + runs_ms: Vec, + records: usize, + checksum: u64, +} #[derive(Debug)] -struct MemoryBench { median_mib_s: f64, runs_mib_s: Vec, buffer_mib: usize, checksum: u64 } +struct MemoryBench { + median_mib_s: f64, + runs_mib_s: Vec, + buffer_mib: usize, + checksum: u64, +} #[derive(Debug)] -struct VideoBench { status: String, source: String, codec: String, width: u32, height: u32, frames: u64, seconds: f64, fps: f64, realtime_factor: f64, note: String } +struct VideoBench { + status: String, + source: String, + codec: String, + width: u32, + height: u32, + frames: u64, + seconds: f64, + fps: f64, + realtime_factor: f64, + note: String, +} #[derive(Debug)] -struct GraphicsBench { status: String, score: Option, note: String } +struct GraphicsBench { + status: String, + score: Option, + note: String, +} #[derive(Debug)] -struct PlaybackBench { status: String, elapsed_seconds: f64, note: String } +struct PlaybackBench { + status: String, + elapsed_seconds: f64, + note: String, +} #[derive(Debug)] -struct Role { name: &'static str, verdict: &'static str, score: Option, reason: String } +struct Role { + name: &'static str, + verdict: &'static str, + score: Option, + reason: String, +} #[derive(Debug)] -struct Report { playback: Option, video: Option, graphics: Option, compilation: Option, memory_bench: Option, developer: Option, sensors: Vec<(String, f64)>, network: Vec<(String,String)>, installed_ram_gib: Option, checklist: Vec<(String,String)>, asset: String, timestamp: u64, distro: String, kernel: String, vendor: String, model: String, cpu: String, threads: usize, mem_gib: f64, gpus: Vec, disks: Vec, batteries: Vec, bench: Option, roles: Vec, concerns: Vec, health_gate: &'static str } +struct Report { + playback: Option, + video: Option, + graphics: Option, + compilation: Option, + memory_bench: Option, + developer: Option, + sensors: Vec<(String, f64)>, + network: Vec<(String, String)>, + installed_ram_gib: Option, + checklist: Vec<(String, String)>, + asset: String, + timestamp: u64, + distro: String, + kernel: String, + vendor: String, + model: String, + cpu: String, + threads: usize, + mem_gib: f64, + gpus: Vec, + disks: Vec, + batteries: Vec, + bench: Option, + roles: Vec, + concerns: Vec, + health_gate: &'static str, +} -fn read(path: impl AsRef) -> String { fs::read_to_string(path).unwrap_or_default().trim().to_string() } -fn dmi_identity(key: &str) -> String { let value = read(format!("/sys/class/dmi/id/{key}")); if value.is_empty() { read(format!("/run/csr-intake/{key}")) } else { value } } -fn number(path: impl AsRef) -> Option { read(path).parse().ok() } -fn clean(s: &str) -> String { s.replace(|c: char| matches!(c, '\r' | '\n' | '\t'), " ").trim().to_string() } -fn esc(s: &str) -> String { let mut out = String::new(); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c) } } out } -fn json(s: &str) -> String { let mut out=String::from("\""); for c in s.chars() { match c { '"'=>out.push_str("\\\""), '\\'=>out.push_str("\\\\"), '\n'=>out.push_str("\\n"), '\r'=>out.push_str("\\r"), '\t'=>out.push_str("\\t"), c if c < ' '=>out.push_str(&format!("\\u{:04x}",c as u32)), _=>out.push(c) } } out.push('"'); out } -fn csv(s: &str) -> String { format!("\"{}\"", s.replace('"', "\"\"")) } -fn distro() -> String { for line in read("/etc/os-release").lines() { if let Some(x)=line.strip_prefix("PRETTY_NAME=") { return x.trim_matches('"').to_owned(); } } "Unknown".into() } -fn cpu() -> String { for line in read("/proc/cpuinfo").lines() { if let Some((k,v))=line.split_once(':') { if k.trim()=="model name" || k.trim()=="Processor" { return clean(v); } } } "Unknown".into() } -fn mem_gib() -> f64 { for line in read("/proc/meminfo").lines() { if let Some(x)=line.strip_prefix("MemTotal:") { return x.split_whitespace().next().and_then(|v|v.parse::().ok()).unwrap_or(0.0)/1048576.0; } } 0.0 } +fn read(path: impl AsRef) -> String { + fs::read_to_string(path) + .unwrap_or_default() + .trim() + .to_string() +} +fn dmi_identity(key: &str) -> String { + let value = read(format!("/sys/class/dmi/id/{key}")); + if value.is_empty() { + read(format!("/run/csr-intake/{key}")) + } else { + value + } +} +fn number(path: impl AsRef) -> Option { + read(path).parse().ok() +} +fn clean(s: &str) -> String { + s.replace(['\r', '\n', '\t'], " ").trim().to_string() +} +fn esc(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} +fn json(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c < ' ' => out.push_str(&format!("\\u{:04x}", c as u32)), + _ => out.push(c), + } + } + out.push('"'); + out +} +fn csv(s: &str) -> String { + format!("\"{}\"", s.replace('"', "\"\"")) +} +fn distro() -> String { + for line in read("/etc/os-release").lines() { + if let Some(x) = line.strip_prefix("PRETTY_NAME=") { + return x.trim_matches('"').to_owned(); + } + } + "Unknown".into() +} +fn cpu() -> String { + for line in read("/proc/cpuinfo").lines() { + if let Some((k, v)) = line.split_once(':') { + if k.trim() == "model name" || k.trim() == "Processor" { + return clean(v); + } + } + } + "Unknown".into() +} +fn mem_gib() -> f64 { + for line in read("/proc/meminfo").lines() { + if let Some(x) = line.strip_prefix("MemTotal:") { + return x + .split_whitespace() + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0) + / 1048576.0; + } + } + 0.0 +} // DMI type 17 is optional and often root-only; do not mistake MemTotal for installed RAM. fn installed_ram_gib() -> Option { - let bytes=fs::read("/sys/firmware/dmi/tables/DMI").or_else(|_| fs::read("/run/csr-intake/DMI")).ok()?; - let mut at=0usize; let mut total_mib=0u64; let mut count=0u64; - while at+4<=bytes.len() { - let kind=bytes[at];let len=bytes[at+1] as usize; - if len<4 || at+len>bytes.len(){break;} - if kind==17 && len>=14 { - let size=u16::from_le_bytes([bytes[at+12],bytes[at+13]]); - let mib=match size { + let bytes = fs::read("/sys/firmware/dmi/tables/DMI") + .or_else(|_| fs::read("/run/csr-intake/DMI")) + .ok()?; + let mut at = 0usize; + let mut total_mib = 0u64; + let mut count = 0u64; + while at + 4 <= bytes.len() { + let kind = bytes[at]; + let len = bytes[at + 1] as usize; + if len < 4 || at + len > bytes.len() { + break; + } + if kind == 17 && len >= 14 { + let size = u16::from_le_bytes([bytes[at + 12], bytes[at + 13]]); + let mib = match size { 0 | 0xffff => 0, - 0x7fff if len>=32 => u32::from_le_bytes([bytes[at+28],bytes[at+29],bytes[at+30],bytes[at+31]]) as u64, + 0x7fff if len >= 32 => u32::from_le_bytes([ + bytes[at + 28], + bytes[at + 29], + bytes[at + 30], + bytes[at + 31], + ]) as u64, 0x7fff => 0, - n if n & 0x8000 != 0 => ((n & 0x7fff) as u64)/1024, + n if n & 0x8000 != 0 => ((n & 0x7fff) as u64) / 1024, n => n as u64, }; - if mib>0 {total_mib+=mib;count+=1;} + if mib > 0 { + total_mib += mib; + count += 1; + } + } + let mut next = at + len; + while next + 1 < bytes.len() && !(bytes[next] == 0 && bytes[next + 1] == 0) { + next += 1; + } + if next + 1 >= bytes.len() { + break; + } + at = next + 2; + if kind == 127 { + break; } - let mut next=at+len; - while next+1=bytes.len(){break;} - at=next+2; - if kind==127 {break;} } - if count>0 {Some(total_mib as f64/1024.0)} else {None} + if count > 0 { + Some(total_mib as f64 / 1024.0) + } else { + None + } } -const CHECK_NAMES: [&str; 11] = ["display","keyboard","touchpad","wifi","audio","microphone","webcam","usb_ports","charging","thermals","stability"]; -fn checklist(path:Option<&Path>) -> Result,String> { - let mut result:Vec<(String,String)>=CHECK_NAMES.iter().map(|n|(n.to_string(),"UNKNOWN".into())).collect(); - if let Some(path)=path { - for (i,line) in fs::read_to_string(path).map_err(|e|format!("Checklist: {e}"))?.lines().enumerate() { - let line=line.trim(); if line.is_empty() || line.starts_with('#'){continue;} - let (name,value)=line.split_once('=').ok_or_else(||format!("Checklist line {}: expected name=PASS/FAIL/UNKNOWN",i+1))?; - let name=name.trim();let value=value.trim().to_uppercase(); - if !["PASS","FAIL","UNKNOWN","NOT_TESTED","NOT_APPLICABLE","UNAVAILABLE","SETUP_PROBLEM"].contains(&value.as_str()){return Err(format!("Checklist line {}: invalid value",i+1));} - let slot=result.iter_mut().find(|(n,_)|n==name).ok_or_else(||format!("Checklist line {}: unknown check {}",i+1,name))?; - slot.1=if ["NOT_TESTED","UNAVAILABLE","SETUP_PROBLEM"].contains(&value.as_str()) {"UNKNOWN".into()} else {value}; +const CHECK_NAMES: [&str; 11] = [ + "display", + "keyboard", + "touchpad", + "wifi", + "audio", + "microphone", + "webcam", + "usb_ports", + "charging", + "thermals", + "stability", +]; +fn checklist(path: Option<&Path>) -> Result, String> { + let mut result: Vec<(String, String)> = CHECK_NAMES + .iter() + .map(|n| (n.to_string(), "UNKNOWN".into())) + .collect(); + if let Some(path) = path { + for (i, line) in fs::read_to_string(path) + .map_err(|e| format!("Checklist: {e}"))? + .lines() + .enumerate() + { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (name, value) = line.split_once('=').ok_or_else(|| { + format!("Checklist line {}: expected name=PASS/FAIL/UNKNOWN", i + 1) + })?; + let name = name.trim(); + let value = value.trim().to_uppercase(); + if ![ + "PASS", + "FAIL", + "UNKNOWN", + "NOT_TESTED", + "NOT_APPLICABLE", + "UNAVAILABLE", + "SETUP_PROBLEM", + ] + .contains(&value.as_str()) + { + return Err(format!("Checklist line {}: invalid value", i + 1)); + } + let slot = result + .iter_mut() + .find(|(n, _)| n == name) + .ok_or_else(|| format!("Checklist line {}: unknown check {}", i + 1, name))?; + slot.1 = if ["NOT_TESTED", "UNAVAILABLE", "SETUP_PROBLEM"].contains(&value.as_str()) { + "UNKNOWN".into() + } else { + value + }; } } Ok(result) } fn gpu() -> Vec { - let mut v=vec![]; - if let Ok(entries)=fs::read_dir("/sys/bus/pci/devices") { - for e in entries.flatten() { let p=e.path(); let class=read(p.join("class")); - if class.starts_with("0x03") { let vendor=read(p.join("vendor")); let device=read(p.join("device")); let driver=fs::read_link(p.join("driver")).ok().and_then(|x|x.file_name().map(|s|s.to_string_lossy().into_owned())).unwrap_or_else(||"unbound".into()); - v.push(format!("PCI {}:{} driver={}",vendor,device,driver)); + let mut v = vec![]; + if let Ok(entries) = fs::read_dir("/sys/bus/pci/devices") { + for e in entries.flatten() { + let p = e.path(); + let class = read(p.join("class")); + if class.starts_with("0x03") { + let vendor = read(p.join("vendor")); + let device = read(p.join("device")); + let driver = fs::read_link(p.join("driver")) + .ok() + .and_then(|x| x.file_name().map(|s| s.to_string_lossy().into_owned())) + .unwrap_or_else(|| "unbound".into()); + v.push(format!("PCI {}:{} driver={}", vendor, device, driver)); } } } - v.sort(); v + v.sort(); + v } fn disks() -> Vec { - let mut found=vec![]; - if let Ok(entries)=fs::read_dir("/sys/block") { for e in entries.flatten() { - let name=e.file_name().to_string_lossy().into_owned(); - if !(name.starts_with("sd") || name.starts_with("nvme") || name.starts_with("vd") || name.starts_with("mmcblk")) {continue;} - let p=e.path(); let sectors=number(p.join("size")).unwrap_or(0); - if sectors==0 {continue;} - let rot=number(p.join("queue/rotational")).map(|x|x!=0); - let model=read(p.join("device/model")); - found.push(Disk {name, gib:sectors.saturating_mul(512)/1073741824, rotational:rot, removable:number(p.join("removable")).unwrap_or(0)!=0, model, health:"UNKNOWN".into(), smart_details:"Not assessed".into()}); - }} - found.sort_by(|a,b|a.name.cmp(&b.name)); found + let mut found = vec![]; + if let Ok(entries) = fs::read_dir("/sys/block") { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if !(name.starts_with("sd") + || name.starts_with("nvme") + || name.starts_with("vd") + || name.starts_with("mmcblk")) + { + continue; + } + let p = e.path(); + let sectors = number(p.join("size")).unwrap_or(0); + if sectors == 0 { + continue; + } + let rot = number(p.join("queue/rotational")).map(|x| x != 0); + let model = read(p.join("device/model")); + found.push(Disk { + name, + gib: sectors.saturating_mul(512) / 1073741824, + rotational: rot, + removable: number(p.join("removable")).unwrap_or(0) != 0, + model, + health: "UNKNOWN".into(), + smart_details: "Not assessed".into(), + }); + } + } + found.sort_by(|a, b| a.name.cmp(&b.name)); + found } // Linux NVMe Get Log Page, SMART/Health Information (log identifier 0x02). // No writes to the SSD. ioctl may require root/CAP_SYS_ADMIN; failures stay UNKNOWN. // SATA, USB, and virtual disks are not falsely marked healthy. -#[cfg(target_os="linux")] +#[cfg(target_os = "linux")] mod native_nvme { - use std::{fs::OpenOptions, os::{fd::AsRawFd, raw::{c_int,c_ulong}}, path::Path}; + use std::{ + fs::OpenOptions, + os::{ + fd::AsRawFd, + raw::{c_int, c_ulong}, + }, + path::Path, + }; #[repr(C)] #[derive(Default)] struct NvmeAdminCmd { - opcode:u8, flags:u8, rsvd1:u16, nsid:u32, cdw2:u32, cdw3:u32, - metadata:u64, addr:u64, metadata_len:u32, data_len:u32, - cdw10:u32, cdw11:u32, cdw12:u32, cdw13:u32, cdw14:u32, cdw15:u32, - timeout_ms:u32, result:u32, + opcode: u8, + flags: u8, + rsvd1: u16, + nsid: u32, + cdw2: u32, + cdw3: u32, + metadata: u64, + addr: u64, + metadata_len: u32, + data_len: u32, + cdw10: u32, + cdw11: u32, + cdw12: u32, + cdw13: u32, + cdw14: u32, + cdw15: u32, + timeout_ms: u32, + result: u32, } - unsafe extern "C" {fn ioctl(fd:c_int, request:c_ulong, ...) -> c_int;} - const NVME_IOCTL_ADMIN_CMD:c_ulong=0xC0484E41; - fn le128(data:&[u8])->u128 {let mut bytes=[0u8;16];bytes.copy_from_slice(data);u128::from_le_bytes(bytes)} - pub fn read_health(name:&str)->Result<(String,String),String> { - let controller=name.get(..name[4..].find('n').map(|i|i+4).unwrap_or(name.len())).unwrap_or(name); // nvme0n1 -> nvme0 - if !controller.starts_with("nvme") || !controller[4..].chars().all(|c|c.is_ascii_digit()) { + unsafe extern "C" { + fn ioctl(fd: c_int, request: c_ulong, ...) -> c_int; + } + const NVME_IOCTL_ADMIN_CMD: c_ulong = 0xC0484E41; + fn le128(data: &[u8]) -> u128 { + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(data); + u128::from_le_bytes(bytes) + } + pub fn read_health(name: &str) -> Result<(String, String), String> { + let controller = name + .get(..name[4..].find('n').map(|i| i + 4).unwrap_or(name.len())) + .unwrap_or(name); // nvme0n1 -> nvme0 + if !controller.starts_with("nvme") || !controller[4..].chars().all(|c| c.is_ascii_digit()) { return Err("Not an NVMe namespace".into()); } - let device=format!("/dev/{controller}"); - if !Path::new(&device).exists(){return Err(format!("{device} unavailable"));} - let file=OpenOptions::new().read(true).open(&device).map_err(|e|format!("open {device}: {e}"))?; - let mut buffer=[0u8;512]; - let mut command=NvmeAdminCmd {opcode:0x02,nsid:0xffffffff,addr:buffer.as_mut_ptr() as u64, - data_len:512,cdw10:((128-1)<<16)|0x02,timeout_ms:5000,..Default::default()}; - let result=unsafe {ioctl(file.as_raw_fd(),NVME_IOCTL_ADMIN_CMD,&mut command)}; - if result!=0 {return Err(format!("NVMe admin ioctl returned {result} (may need root/permissions or unsupported device)"));} - let warnings=buffer[0]; - let temp_k=u16::from_le_bytes([buffer[1],buffer[2]]); - let spare=buffer[3];let threshold=buffer[4];let used=buffer[5]; - let media_errors=le128(&buffer[160..176]); - let status=if warnings!=0 || spare0 {"WARN"} else if used>=100 {"WARN"} else {"PASS"}; - let temp=if temp_k>0 {format!("{} C",i32::from(temp_k)-273)} else {"UNKNOWN".into()}; + let device = format!("/dev/{controller}"); + if !Path::new(&device).exists() { + return Err(format!("{device} unavailable")); + } + let file = OpenOptions::new() + .read(true) + .open(&device) + .map_err(|e| format!("open {device}: {e}"))?; + let mut buffer = [0u8; 512]; + let mut command = NvmeAdminCmd { + opcode: 0x02, + nsid: 0xffffffff, + addr: buffer.as_mut_ptr() as u64, + data_len: 512, + cdw10: ((128 - 1) << 16) | 0x02, + timeout_ms: 5000, + ..Default::default() + }; + let result = unsafe { ioctl(file.as_raw_fd(), NVME_IOCTL_ADMIN_CMD, &mut command) }; + if result != 0 { + return Err(format!("NVMe admin ioctl returned {result} (may need root/permissions or unsupported device)")); + } + let warnings = buffer[0]; + let temp_k = u16::from_le_bytes([buffer[1], buffer[2]]); + let spare = buffer[3]; + let threshold = buffer[4]; + let used = buffer[5]; + let media_errors = le128(&buffer[160..176]); + let status = if warnings != 0 || spare < threshold || media_errors > 0 || used >= 100 { + "WARN" + } else { + "PASS" + }; + let temp = if temp_k > 0 { + format!("{} C", i32::from(temp_k) - 273) + } else { + "UNKNOWN".into() + }; let details=format!("NVMe SMART: critical_warning=0x{warnings:02x}; temperature={temp}; available_spare={spare}%; spare_threshold={threshold}%; percentage_used={used}%; media_errors={media_errors}. WARN requires review, not automatic failure."); - Ok((status.into(),details)) + Ok((status.into(), details)) + } +} +#[cfg(not(target_os = "linux"))] +mod native_nvme { + pub fn read_health(_: &str) -> Result<(String, String), String> { + Err("Linux only".into()) } } -#[cfg(not(target_os="linux"))] -mod native_nvme {pub fn read_health(_: &str)->Result<(String,String),String>{Err("Linux only".into())}} // Optional read-only smartctl transport for SATA/USB/NVMe. Never infer PASS from an exit code // alone; smartctl exits nonzero for some warning conditions, so parse both stdout and stderr. -fn smartctl_health(name: &str) -> Result<(String,String),String> { - let path=format!("/dev/{name}"); - let output=Command::new("smartctl").args(["-H","-A",&path]).output() - .map_err(|e|format!("smartctl unavailable ({e})"))?; - let content=String::from_utf8_lossy(&output.stdout).to_string(); - let stderr=String::from_utf8_lossy(&output.stderr); - if content.trim().is_empty() {return Err(format!("smartctl gave no output: {}",stderr.chars().take(160).collect::()));} - let upper=content.to_ascii_uppercase(); +fn smartctl_health(name: &str) -> Result<(String, String), String> { + let path = format!("/dev/{name}"); + let output = Command::new("smartctl") + .args(["-H", "-A", &path]) + .output() + .map_err(|e| format!("smartctl unavailable ({e})"))?; + let content = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + if content.trim().is_empty() { + return Err(format!( + "smartctl gave no output: {}", + stderr.chars().take(160).collect::() + )); + } + let upper = content.to_ascii_uppercase(); // smartctl health strings differ for ATA and SCSI; do not guess on unsupported bridges. - let passed=upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: PASSED") + let passed = upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: PASSED") || upper.contains("SMART HEALTH STATUS: OK") || upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: OK"); - let failed=upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: FAILED") + let failed = upper.contains("SMART OVERALL-HEALTH SELF-ASSESSMENT TEST RESULT: FAILED") || upper.contains("SMART HEALTH STATUS: BAD") || upper.contains("SMART HEALTH STATUS: FAILED"); - let interesting:Vec=content.lines().filter(|l| { - let x=l.to_ascii_lowercase(); - ["overall-health","smart health status","reallocated_sector_ct","current_pending_sector","offline_uncorrectable","reported_uncorrect","percentage used","media and data integrity errors","critical warning"].iter().any(|w|x.contains(w)) - }).take(14).map(|l|clean(l)).collect(); - let details=format!("smartctl (-H -A) exit={}; {}",output.status,interesting.join(" | ")); + let interesting: Vec = content + .lines() + .filter(|l| { + let x = l.to_ascii_lowercase(); + [ + "overall-health", + "smart health status", + "reallocated_sector_ct", + "current_pending_sector", + "offline_uncorrectable", + "reported_uncorrect", + "percentage used", + "media and data integrity errors", + "critical warning", + ] + .iter() + .any(|w| x.contains(w)) + }) + .take(14) + .map(clean) + .collect(); + let details = format!( + "smartctl (-H -A) exit={}; {}", + output.status, + interesting.join(" | ") + ); // An overall PASSED flag is not full disk certification. Inspect problem attributes. - let bad_attr=content.lines().any(|line| { - let x=line.to_ascii_lowercase(); - if ["reallocated_sector_ct","current_pending_sector","offline_uncorrectable","reported_uncorrect"].iter().any(|t|x.contains(t)) { - line.split_whitespace().last().and_then(|v|v.parse::().ok()).is_some_and(|n|n>0) - } else {false} + let bad_attr = content.lines().any(|line| { + let x = line.to_ascii_lowercase(); + if [ + "reallocated_sector_ct", + "current_pending_sector", + "offline_uncorrectable", + "reported_uncorrect", + ] + .iter() + .any(|t| x.contains(t)) + { + line.split_whitespace() + .last() + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n > 0) + } else { + false + } }); - if failed || bad_attr {Ok(("WARN".into(),details))} - else if passed {Ok(("PASS".into(),details))} - else {Ok(("UNKNOWN".into(),details))} + if failed || bad_attr { + Ok(("WARN".into(), details)) + } else if passed { + Ok(("PASS".into(), details)) + } else { + Ok(("UNKNOWN".into(), details)) + } } -fn assess_disks(disks:&mut [Disk]) { +fn assess_disks(disks: &mut [Disk]) { for disk in disks { if disk.name.starts_with("nvme") { match native_nvme::read_health(&disk.name) { - Ok((state,details))=>{disk.health=state;disk.smart_details=details;}, - Err(e)=>{disk.smart_details=format!("NVMe SMART unavailable: {e}");} + Ok((state, details)) => { + disk.health = state; + disk.smart_details = details; + } + Err(e) => { + disk.smart_details = format!("NVMe SMART unavailable: {e}"); + } } } else { match smartctl_health(&disk.name) { - Ok((state,details))=>{disk.health=state;disk.smart_details=details;}, - Err(e)=>{disk.smart_details=format!("SATA/USB SMART unavailable: {e}");} + Ok((state, details)) => { + disk.health = state; + disk.smart_details = details; + } + Err(e) => { + disk.smart_details = format!("SATA/USB SMART unavailable: {e}"); + } } } } } // Actual FFmpeg file decode (not a display/playback or hardware acceleration test). // Video test uses a user-provided real file; requires ffmpeg on the target machine. -fn ffmpeg_video(path:&Path) -> VideoBench { - let source=path.display().to_string(); - let unknown=|status:String|VideoBench{status,source:source.clone(),codec:"UNKNOWN".into(),width:0,height:0,frames:0,seconds:0.0,fps:0.0,realtime_factor:0.0,note:"Real input decoded to null output; measures decode throughput, not dropped display frames".into()}; - if !path.is_file() {return unknown("UNAVAILABLE (video file does not exist)".into());} - let start=Instant::now(); - let output=Command::new("ffmpeg").args(["-hide_banner","-nostdin","-v","info","-i"]).arg(path) - .args(["-map","0:v:0","-an","-sn","-f","null","-"]).output(); - let output=match output {Ok(o)=>o,Err(e)=>return unknown(format!("UNAVAILABLE (ffmpeg: {e})"))}; - let log=String::from_utf8_lossy(&output.stderr); +fn ffmpeg_video(path: &Path) -> VideoBench { + let source = path.display().to_string(); + let unknown = |status: String| { + VideoBench{status,source:source.clone(),codec:"UNKNOWN".into(),width:0,height:0,frames:0,seconds:0.0,fps:0.0,realtime_factor:0.0,note:"Real input decoded to null output; measures decode throughput, not dropped display frames".into()} + }; + if !path.is_file() { + return unknown("UNAVAILABLE (video file does not exist)".into()); + } + let start = Instant::now(); + let output = Command::new("ffmpeg") + .args(["-hide_banner", "-nostdin", "-v", "info", "-i"]) + .arg(path) + .args(["-map", "0:v:0", "-an", "-sn", "-f", "null", "-"]) + .output(); + let output = match output { + Ok(o) => o, + Err(e) => return unknown(format!("UNAVAILABLE (ffmpeg: {e})")), + }; + let log = String::from_utf8_lossy(&output.stderr); if !output.status.success() { let detail = log.lines().last().unwrap_or("unknown error"); return unknown(format!( @@ -214,103 +601,250 @@ fn ffmpeg_video(path:&Path) -> VideoBench { )); } // Extract frame count from the final ffmpeg progress line (informational only). - let progress=log.lines().rev().find(|l|l.contains("frame=")).unwrap_or(""); - let frames=progress.split("frame=").nth(1).and_then(|x|x.trim_start().split_whitespace().next()).and_then(|x|x.parse::().ok()).unwrap_or(0); - let video_line=log.lines().find(|l|l.contains("Video:")).unwrap_or(""); - let codec=video_line.split("Video:").nth(1).and_then(|x|x.split(',').next()).unwrap_or("UNKNOWN").trim().to_string(); - let dimensions=video_line.split(|c:char| !c.is_ascii_alphanumeric() && c!='x').find(|x| {let mut it=x.split('x');it.next().and_then(|n|n.parse::().ok()).is_some() && it.next().and_then(|n|n.parse::().ok()).is_some()}).unwrap_or(""); - let mut pair=dimensions.split('x');let width=pair.next().and_then(|x|x.parse().ok()).unwrap_or(0);let height=pair.next().and_then(|x|x.parse().ok()).unwrap_or(0); - let seconds=start.elapsed().as_secs_f64(); - let fps=if seconds>0.0 {frames as f64/seconds} else {0.0}; + let progress = log + .lines() + .rev() + .find(|l| l.contains("frame=")) + .unwrap_or(""); + let frames = progress + .split("frame=") + .nth(1) + .and_then(|x| x.split_whitespace().next()) + .and_then(|x| x.parse::().ok()) + .unwrap_or(0); + let video_line = log.lines().find(|l| l.contains("Video:")).unwrap_or(""); + let codec = video_line + .split("Video:") + .nth(1) + .and_then(|x| x.split(',').next()) + .unwrap_or("UNKNOWN") + .trim() + .to_string(); + let dimensions = video_line + .split(|c: char| !c.is_ascii_alphanumeric() && c != 'x') + .find(|x| { + let mut it = x.split('x'); + it.next().and_then(|n| n.parse::().ok()).is_some() + && it.next().and_then(|n| n.parse::().ok()).is_some() + }) + .unwrap_or(""); + let mut pair = dimensions.split('x'); + let width = pair.next().and_then(|x| x.parse().ok()).unwrap_or(0); + let height = pair.next().and_then(|x| x.parse().ok()).unwrap_or(0); + let seconds = start.elapsed().as_secs_f64(); + let fps = if seconds > 0.0 { + frames as f64 / seconds + } else { + 0.0 + }; // Estimated real-time factor based on reported input fps if known; not displayed playback. - let nominal=video_line.split(',').find_map(|x|{let x=x.trim();x.strip_suffix(" fps").and_then(|n|n.trim().parse::().ok())}).unwrap_or(0.0); + let nominal = video_line + .split(',') + .find_map(|x| { + let x = x.trim(); + x.strip_suffix(" fps") + .and_then(|n| n.trim().parse::().ok()) + }) + .unwrap_or(0.0); VideoBench{status:if frames>0 {"PASS".into()} else {"UNKNOWN (ffmpeg did not report frame count)".into()},source,codec,width,height,frames,seconds,fps,realtime_factor:if nominal>0.0 {fps/nominal} else {0.0},note:"Real media decoded by FFmpeg to null output; excludes display scheduling, sound, hardware acceleration validation and dropped frames".into()} } // On-screen playback check via mpv, when a graphical session and mpv are installed. // A successful process exit means playback completed, not that there were no dropped frames. -fn mpv_playback(path:&Path)->PlaybackBench { - if !path.is_file() {return PlaybackBench{status:"UNAVAILABLE".into(),elapsed_seconds:0.0,note:"File missing".into()};} - let start=Instant::now(); - let output=Command::new("mpv").args(["--no-config","--no-audio","--no-osc","--frames=300","--hwdec=auto","--vo=gpu","--really-quiet","--"]).arg(path).output(); +fn mpv_playback(path: &Path) -> PlaybackBench { + if !path.is_file() { + return PlaybackBench { + status: "UNAVAILABLE".into(), + elapsed_seconds: 0.0, + note: "File missing".into(), + }; + } + let start = Instant::now(); + let output = Command::new("mpv") + .args([ + "--no-config", + "--no-audio", + "--no-osc", + "--frames=300", + "--hwdec=auto", + "--vo=gpu", + "--really-quiet", + "--", + ]) + .arg(path) + .output(); match output { Err(e)=>PlaybackBench{status:"UNAVAILABLE".into(),elapsed_seconds:0.0,note:format!("mpv unavailable: {e}")}, Ok(o)=>PlaybackBench{status:if o.status.success(){"COMPLETED"}else{"FAILED"}.into(),elapsed_seconds:start.elapsed().as_secs_f64(),note:format!("300-frame on-screen mpv run; exit={}. Dropped frames and display smoothness still require observation",o.status)} } } // Optional benchmark, not a gaming FPS measurement. glmark2 requires usable display session. -fn graphics_benchmark()->GraphicsBench { - let output=Command::new("glmark2").arg("--off-screen").output(); +fn graphics_benchmark() -> GraphicsBench { + let output = Command::new("glmark2").arg("--off-screen").output(); match output { - Err(e)=>GraphicsBench{status:"UNAVAILABLE".into(),score:None,note:format!("glmark2 not installed or could not launch: {e}")}, - Ok(o)=>{ - let log=String::from_utf8_lossy(&o.stdout); - let score=log.lines().find_map(|line|line.trim().strip_prefix("glmark2 Score:").and_then(|v|v.trim().parse::().ok())); - GraphicsBench{status:if o.status.success()&&score.is_some(){"PASS"}else{"UNKNOWN"}.into(),score,note:format!("Off-screen OpenGL benchmark (not game FPS), process status {}",o.status)} + Err(e) => GraphicsBench { + status: "UNAVAILABLE".into(), + score: None, + note: format!("glmark2 not installed or could not launch: {e}"), + }, + Ok(o) => { + let log = String::from_utf8_lossy(&o.stdout); + let score = log.lines().find_map(|line| { + line.trim() + .strip_prefix("glmark2 Score:") + .and_then(|v| v.trim().parse::().ok()) + }); + GraphicsBench { + status: if o.status.success() && score.is_some() { + "PASS" + } else { + "UNKNOWN" + } + .into(), + score, + note: format!( + "Off-screen OpenGL benchmark (not game FPS), process status {}", + o.status + ), + } } } } -fn median(values:&mut [f64])->f64 { - values.sort_by(|a,b|a.total_cmp(b)); - let m=values.len()/2; - if values.len()%2==1 {values[m]} else {(values[m-1]+values[m])/2.0} +fn median(values: &mut [f64]) -> f64 { + values.sort_by(|a, b| a.total_cmp(b)); + let m = values.len() / 2; + if values.len() % 2 == 1 { + values[m] + } else { + (values[m - 1] + values[m]) / 2.0 + } } // Read thermal sensors from hwmon. Labels are untrusted hardware strings and escaped in HTML. -fn temperatures() -> Vec<(String,f64)> { - let mut result=Vec::new(); - if let Ok(chips)=fs::read_dir("/sys/class/hwmon") { for chip in chips.flatten() { - let p=chip.path(); let chipname=read(p.join("name")); - if let Ok(files)=fs::read_dir(&p) {for file in files.flatten() { - let filename=file.file_name().to_string_lossy().into_owned(); - if !filename.starts_with("temp") || !filename.ends_with("_input") {continue;} - let stem=filename.trim_end_matches("_input"); - if let Some(millidegrees)=number(file.path()) { - if millidegrees>180_000 {continue;} - let label=read(p.join(format!("{stem}_label"))); - let name=if label.is_empty(){format!("{chipname}:{stem}")} else {format!("{chipname}:{label}")}; - result.push((name,millidegrees as f64/1000.0)); +fn temperatures() -> Vec<(String, f64)> { + let mut result = Vec::new(); + if let Ok(chips) = fs::read_dir("/sys/class/hwmon") { + for chip in chips.flatten() { + let p = chip.path(); + let chipname = read(p.join("name")); + if let Ok(files) = fs::read_dir(&p) { + for file in files.flatten() { + let filename = file.file_name().to_string_lossy().into_owned(); + if !filename.starts_with("temp") || !filename.ends_with("_input") { + continue; + } + let stem = filename.trim_end_matches("_input"); + if let Some(millidegrees) = number(file.path()) { + if millidegrees > 180_000 { + continue; + } + let label = read(p.join(format!("{stem}_label"))); + let name = if label.is_empty() { + format!("{chipname}:{stem}") + } else { + format!("{chipname}:{label}") + }; + result.push((name, millidegrees as f64 / 1000.0)); + } + } } - }} - }} - result.sort_by(|a,b|a.0.cmp(&b.0));result + } + } + result.sort_by(|a, b| a.0.cmp(&b.0)); + result } // Detect network devices and their Linux link state (does not imply internet connectivity). -fn network_devices() -> Vec<(String,String)> { - let mut result=Vec::new(); - if let Ok(entries)=fs::read_dir("/sys/class/net") {for e in entries.flatten() { - let name=e.file_name().to_string_lossy().into_owned(); - if name=="lo" {continue;} - let p=e.path();let link=read(p.join("operstate")); - let kind=if p.join("wireless").exists() || p.join("phy80211").exists() {"Wi-Fi"} else {"Network"}; - result.push((name,format!("{kind}, link={link}; connectivity not tested"))); - }}result.sort_by(|a,b|a.0.cmp(&b.0));result +fn network_devices() -> Vec<(String, String)> { + let mut result = Vec::new(); + if let Ok(entries) = fs::read_dir("/sys/class/net") { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name == "lo" { + continue; + } + let p = e.path(); + let link = read(p.join("operstate")); + let kind = if p.join("wireless").exists() || p.join("phy80211").exists() { + "Wi-Fi" + } else { + "Network" + }; + result.push(( + name, + format!("{kind}, link={link}; connectivity not tested"), + )); + } + } + result.sort_by(|a, b| a.0.cmp(&b.0)); + result } fn batteries() -> Vec { - let mut found=vec![]; - if let Ok(entries)=fs::read_dir("/sys/class/power_supply") { for e in entries.flatten() { - let p=e.path(); if read(p.join("type"))!="Battery" {continue;} - let name=e.file_name().to_string_lossy().into_owned(); - let full=number(p.join("energy_full")).or_else(||number(p.join("charge_full"))); - let design=number(p.join("energy_full_design")).or_else(||number(p.join("charge_full_design"))); - let health=full.zip(design).and_then(|(f,d)|if d>0 {Some((f.saturating_mul(100)/d).min(150))} else {None}); - found.push(Battery{name,capacity_pct:number(p.join("capacity")),health_pct:health,status:read(p.join("status"))}); - }} found + let mut found = vec![]; + if let Ok(entries) = fs::read_dir("/sys/class/power_supply") { + for e in entries.flatten() { + let p = e.path(); + if read(p.join("type")) != "Battery" { + continue; + } + let name = e.file_name().to_string_lossy().into_owned(); + let full = number(p.join("energy_full")).or_else(|| number(p.join("charge_full"))); + let design = number(p.join("energy_full_design")) + .or_else(|| number(p.join("charge_full_design"))); + let health = full + .zip(design) + .and_then(|(f, d)| f.saturating_mul(100).checked_div(d)) + .map(|value| value.min(150)); + found.push(Battery { + name, + capacity_pct: number(p.join("capacity")), + health_pct: health, + status: read(p.join("status")), + }); + } + } + found } // Fixed-workload integer mixing (throughput proxy, NOT a standardized CPU benchmark). -fn cpu_work(iterations:u64) -> u64 { let mut x=0x243f6a8885a308d3u64; for i in 0..iterations {x=x.wrapping_add(i ^ 0x9e3779b97f4a7c15); x^=x>>27; x=x.wrapping_mul(0x3c79ac492ba7b653); x^=x>>33;} black_box(x) } +fn cpu_work(iterations: u64) -> u64 { + let mut x = 0x243f6a8885a308d3u64; + for i in 0..iterations { + x = x.wrapping_add(i ^ 0x9e3779b97f4a7c15); + x ^= x >> 27; + x = x.wrapping_mul(0x3c79ac492ba7b653); + x ^= x >> 33; + } + black_box(x) +} fn benchmark() -> Bench { - let threads=thread::available_parallelism().map(|n|n.get()).unwrap_or(1).min(16); + let threads = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + .min(16); // Warm up the workload once to reduce first-run noise. black_box(cpu_work(WORK / 4)); - let mut singles=Vec::new();let mut parallels=Vec::new(); + let mut singles = Vec::new(); + let mut parallels = Vec::new(); for _ in 0..5 { - let t=Instant::now();black_box(cpu_work(WORK));singles.push(t.elapsed().as_secs_f64()*1000.0); - let t=Instant::now();let hs:Vec<_>=(0..threads).map(|_|thread::spawn(||cpu_work(WORK))).collect(); - for h in hs {let _=black_box(h.join());} - parallels.push(t.elapsed().as_secs_f64()*1000.0); + let t = Instant::now(); + black_box(cpu_work(WORK)); + singles.push(t.elapsed().as_secs_f64() * 1000.0); + let t = Instant::now(); + let hs: Vec<_> = (0..threads) + .map(|_| thread::spawn(|| cpu_work(WORK))) + .collect(); + for h in hs { + let _ = black_box(h.join()); + } + parallels.push(t.elapsed().as_secs_f64() * 1000.0); + } + let mut s = singles.clone(); + let mut p = parallels.clone(); + Bench { + single_ms: median(&mut s), + multi_ms: median(&mut p), + threads, + single_runs_ms: singles, + parallel_runs_ms: parallels, } - let mut s=singles.clone();let mut p=parallels.clone(); - Bench{single_ms:median(&mut s),multi_ms:median(&mut p),threads,single_runs_ms:singles,parallel_runs_ms:parallels} } // Deterministic source-text scanning/indexing proxy; this is NOT a compilation benchmark. // No compiler or external executable is needed on refurbished devices. @@ -320,10 +854,14 @@ fn source_index_work(records: usize) -> u64 { for n in 0..records { let line = format!("pub fn function_{n}(value: u64) -> u64 {{ let output = value.wrapping_mul({}); output }}", n % 7919 + 1); for token in line.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') { - if token.is_empty() { continue; } + if token.is_empty() { + continue; + } let is_keyword = keywords.contains(&token); let mut hash = 0xcbf29ce484222325u64; - for b in token.bytes() { hash = (hash ^ b as u64).wrapping_mul(0x100000001b3); } + for b in token.bytes() { + hash = (hash ^ b as u64).wrapping_mul(0x100000001b3); + } checksum = checksum.rotate_left(7) ^ hash ^ u64::from(is_keyword); } } @@ -340,7 +878,12 @@ fn developer_benchmark() -> DevBench { runs.push(t.elapsed().as_secs_f64() * 1000.0); } let mut sorted = runs.clone(); - DevBench { median_ms: median(&mut sorted), runs_ms: runs, records: RECORDS, checksum } + DevBench { + median_ms: median(&mut sorted), + runs_ms: runs, + records: RECORDS, + checksum, + } } // Allocates 64 MiB; read-only timed passes over filled memory. No disk writes. // This is a single-thread sequential READ proxy, not a standardized DRAM benchmark. @@ -348,34 +891,53 @@ fn memory_benchmark() -> MemoryBench { const MIB: usize = 1024 * 1024; const BYTES: usize = 64 * MIB; let elements = BYTES / std::mem::size_of::(); - let data: Vec = (0..elements).map(|i| (i as u64).wrapping_mul(0x9e3779b97f4a7c15)).collect(); + let data: Vec = (0..elements) + .map(|i| (i as u64).wrapping_mul(0x9e3779b97f4a7c15)) + .collect(); let mut speeds = Vec::new(); let mut checksum = 0u64; for _ in 0..5 { let start = Instant::now(); - let sum = data.iter().fold(0u64, |sum, item| sum.wrapping_add(black_box(*item))); + let sum = data + .iter() + .fold(0u64, |sum, item| sum.wrapping_add(black_box(*item))); black_box(sum); checksum = sum; let secs = start.elapsed().as_secs_f64().max(0.000_001); speeds.push(64.0 / secs); } let mut sorted = speeds.clone(); - MemoryBench { median_mib_s: median(&mut sorted), runs_mib_s: speeds, buffer_mib: 64, checksum } + MemoryBench { + median_mib_s: median(&mut sorted), + runs_mib_s: speeds, + buffer_mib: 64, + checksum, + } } fn cpu_variation_pct(b: &Bench) -> f64 { - if b.single_ms <= 0.0 { return 0.0; } - let lo = b.single_runs_ms.iter().copied().fold(f64::INFINITY, f64::min); + if b.single_ms <= 0.0 { + return 0.0; + } + let lo = b + .single_runs_ms + .iter() + .copied() + .fold(f64::INFINITY, f64::min); let hi = b.single_runs_ms.iter().copied().fold(0.0_f64, f64::max); (hi - lo) / b.single_ms * 100.0 } // Provisional fixed anchor points for the existing v1 synthetic workloads. // Scores are NOT percentiles, validated application benchmarks, or donation approvals. fn higher_is_better(value: f64, low: f64, high: f64) -> u8 { - if !value.is_finite() || value <= 0.0 { return 0; } + if !value.is_finite() || value <= 0.0 { + return 0; + } ((100.0 * (value - low) / (high - low)).clamp(0.0, 100.0)).round() as u8 } fn lower_is_better(value: f64, best: f64, worst: f64) -> u8 { - if !value.is_finite() || value <= 0.0 { return 0; } + if !value.is_finite() || value <= 0.0 { + return 0; + } ((100.0 * (worst - value) / (worst - best)).clamp(0.0, 100.0)).round() as u8 } // Optional real C compilation benchmark. The tool itself has no external runtime dependencies. @@ -383,246 +945,1018 @@ fn lower_is_better(value: f64, best: f64, worst: f64) -> u8 { // temporary directory and removed even if the compiler fails. No generated code is executed. fn compilation_benchmark() -> CompileBench { const FUNCTIONS: usize = 320; - let compiler = ["cc", "gcc", "clang"].iter().find(|name| - Command::new(name).arg("--version").output().is_ok() - ).map(|x|x.to_string()); - let Some(compiler) = compiler else { return CompileBench { compiler:"none".into(), median_ms:0.0, runs_ms:vec![], source_functions:FUNCTIONS, status:"UNAVAILABLE (C compiler not installed)".into() }; }; - let folder = env::temp_dir().join(format!("pc-intake-compile-{}-{}",process::id(),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos())); - if let Err(e) = fs::create_dir(&folder) {return CompileBench {compiler,median_ms:0.0,runs_ms:vec![],source_functions:FUNCTIONS,status:format!("UNAVAILABLE (temporary directory: {e})")};} - let result = (|| -> Result,String> { - let source=folder.join("workload.c"); - let mut contents=String::from("#include \n"); - for n in 0..FUNCTIONS { contents.push_str(&format!("uint64_t fn{n}(uint64_t x) {{ for (uint64_t j=0;j<96;j++) x=(x*6364136223846793005ULL+1442695040888963407ULL)^(x>>13); return x+{n}ULL; }}\n")); } - fs::write(&source,contents).map_err(|e|e.to_string())?; - let mut times=vec![]; + let compiler = ["cc", "gcc", "clang"] + .iter() + .find(|name| Command::new(name).arg("--version").output().is_ok()) + .map(|x| x.to_string()); + let Some(compiler) = compiler else { + return CompileBench { + compiler: "none".into(), + median_ms: 0.0, + runs_ms: vec![], + source_functions: FUNCTIONS, + status: "UNAVAILABLE (C compiler not installed)".into(), + }; + }; + let folder = env::temp_dir().join(format!( + "pc-intake-compile-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + if let Err(e) = fs::create_dir(&folder) { + return CompileBench { + compiler, + median_ms: 0.0, + runs_ms: vec![], + source_functions: FUNCTIONS, + status: format!("UNAVAILABLE (temporary directory: {e})"), + }; + } + let result = (|| -> Result, String> { + let source = folder.join("workload.c"); + let mut contents = String::from("#include \n"); + for n in 0..FUNCTIONS { + contents.push_str(&format!("uint64_t fn{n}(uint64_t x) {{ for (uint64_t j=0;j<96;j++) x=(x*6364136223846793005ULL+1442695040888963407ULL)^(x>>13); return x+{n}ULL; }}\n")); + } + fs::write(&source, contents).map_err(|e| e.to_string())?; + let mut times = vec![]; for iteration in 0..3 { - let object=folder.join(format!("workload-{iteration}.o")); - let start=Instant::now(); - let output=Command::new(&compiler).arg("-std=c11").arg("-O2").arg("-c").arg(&source).arg("-o").arg(&object).output().map_err(|e|e.to_string())?; - if !output.status.success() {return Err(format!("compiler exited {}: {}",output.status, String::from_utf8_lossy(&output.stderr).chars().take(240).collect::()));} - let metadata=fs::metadata(&object).map_err(|e|e.to_string())?; - if metadata.len()==0 {return Err("empty compiler output".into());} - times.push(start.elapsed().as_secs_f64()*1000.0); + let object = folder.join(format!("workload-{iteration}.o")); + let start = Instant::now(); + let output = Command::new(&compiler) + .arg("-std=c11") + .arg("-O2") + .arg("-c") + .arg(&source) + .arg("-o") + .arg(&object) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "compiler exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + .chars() + .take(240) + .collect::() + )); + } + let metadata = fs::metadata(&object).map_err(|e| e.to_string())?; + if metadata.len() == 0 { + return Err("empty compiler output".into()); + } + times.push(start.elapsed().as_secs_f64() * 1000.0); } Ok(times) })(); - let _=fs::remove_dir_all(&folder); + let _ = fs::remove_dir_all(&folder); match result { - Ok(mut runs) => {let med=median(&mut runs);CompileBench{compiler,median_ms:med,runs_ms:runs,source_functions:FUNCTIONS,status:"PASS".into()}}, - Err(e) => CompileBench{compiler,median_ms:0.0,runs_ms:vec![],source_functions:FUNCTIONS,status:format!("UNAVAILABLE ({e})")}, + Ok(mut runs) => { + let med = median(&mut runs); + CompileBench { + compiler, + median_ms: med, + runs_ms: runs, + source_functions: FUNCTIONS, + status: "PASS".into(), + } + } + Err(e) => CompileBench { + compiler, + median_ms: 0.0, + runs_ms: vec![], + source_functions: FUNCTIONS, + status: format!("UNAVAILABLE ({e})"), + }, } } -fn grade_with_evidence(cpu: Option<&Bench>, dev: Option<&DevBench>, ram: Option<&MemoryBench>) -> Vec { - let Some(b) = cpu else {return ["Office","Student","Developer","Multimedia playback","Video creation","Gaming"].iter().map(|name|Role{name:*name,verdict:"NOT_ASSESSED",score:None,reason:"Benchmarks skipped (--quick) or unavailable".into()}).collect()}; - let s=lower_is_better(b.single_ms, 12.0, 85.0); - let m=lower_is_better(b.multi_ms, 18.0, 450.0); - let office=(s as u16 * 7 + m as u16 * 3)/10; - let mut r=vec![Role{name:"Office",verdict:if office>=50 {"PROVISIONAL_CANDIDATE"} else if office>=25 {"LIMITED"} else {"NOT_RECOMMENDED"},score:Some(office as u8),reason:"Fixed-anchor CPU proxy score; verify real browser/office responsiveness".into()}]; - r.push(Role{name:"Student",verdict:"NEEDS_VIDEO_CALL_TEST",score:None,reason:"CPU proxy available, but video calls/playback and peripherals untested".into()}); - if let (Some(d),Some(mem))=(dev,ram) { - let ds=lower_is_better(d.median_ms, 15.0, 250.0); - let rs=higher_is_better(mem.median_mib_s, 1500.0, 20000.0); - let composite=(s as u32*30 + m as u32*30 + ds as u32*30 + rs as u32*10)/100; +fn grade_with_evidence( + cpu: Option<&Bench>, + dev: Option<&DevBench>, + ram: Option<&MemoryBench>, +) -> Vec { + let Some(b) = cpu else { + return [ + "Office", + "Student", + "Developer", + "Multimedia playback", + "Video creation", + "Gaming", + ] + .into_iter() + .map(|name| Role { + name, + verdict: "NOT_ASSESSED", + score: None, + reason: "Benchmarks skipped (--quick) or unavailable".into(), + }) + .collect(); + }; + let s = lower_is_better(b.single_ms, 12.0, 85.0); + let m = lower_is_better(b.multi_ms, 18.0, 450.0); + let office = (s as u16 * 7 + m as u16 * 3) / 10; + let mut r = vec![Role { + name: "Office", + verdict: if office >= 50 { + "PROVISIONAL_CANDIDATE" + } else if office >= 25 { + "LIMITED" + } else { + "NOT_RECOMMENDED" + }, + score: Some(office as u8), + reason: "Fixed-anchor CPU proxy score; verify real browser/office responsiveness".into(), + }]; + r.push(Role { + name: "Student", + verdict: "NEEDS_VIDEO_CALL_TEST", + score: None, + reason: "CPU proxy available, but video calls/playback and peripherals untested".into(), + }); + if let (Some(d), Some(mem)) = (dev, ram) { + let ds = lower_is_better(d.median_ms, 15.0, 250.0); + let rs = higher_is_better(mem.median_mib_s, 1500.0, 20000.0); + let composite = (s as u32 * 30 + m as u32 * 30 + ds as u32 * 30 + rs as u32 * 10) / 100; r.push(Role{name:"Developer",verdict:if composite>=65 {"PROVISIONAL_STRONG_CANDIDATE"} else if composite>=40 {"PROVISIONAL_BASIC_CANDIDATE"} else {"LIMITED"},score:Some(composite as u8),reason:format!("CPU single {s}/100, parallel {m}/100, source-index proxy {ds}/100, memory-read proxy {rs}/100. Not real compilation; thresholds need fleet calibration")}); } else { - r.push(Role{name:"Developer",verdict:"NOT_ASSESSED",score:None,reason:"Source-index and memory measurements are required".into()}); + r.push(Role { + name: "Developer", + verdict: "NOT_ASSESSED", + score: None, + reason: "Source-index and memory measurements are required".into(), + }); + } + for (name, reason) in [ + ( + "Multimedia playback", + "No decoded frames, dropped-frame or codec playback measurements", + ), + ( + "Video creation", + "No verified software/hardware video encoding workload", + ), + ("Gaming", "No actual GPU frame-rate or game benchmark"), + ] { + r.push(Role { + name, + verdict: "NOT_ASSESSED", + score: None, + reason: reason.into(), + }); } - for (name,reason) in [ - ("Multimedia playback","No decoded frames, dropped-frame or codec playback measurements"), - ("Video creation","No verified software/hardware video encoding workload"), - ("Gaming","No actual GPU frame-rate or game benchmark") - ] {r.push(Role{name,verdict:"NOT_ASSESSED",score:None,reason:reason.into()});} r } // Keep legacy gate-style grading API testable while replacing ungrounded decisions. -fn grade(single_ms:f64,multi_ms:f64,threads:usize)->Vec{ - let b=Bench{single_ms,multi_ms,threads,single_runs_ms:vec![single_ms],parallel_runs_ms:vec![multi_ms]}; - grade_with_evidence(Some(&b),None,None) +#[cfg(test)] +fn grade(single_ms: f64, multi_ms: f64, threads: usize) -> Vec { + let b = Bench { + single_ms, + multi_ms, + threads, + single_runs_ms: vec![single_ms], + parallel_runs_ms: vec![multi_ms], + }; + grade_with_evidence(Some(&b), None, None) } -fn collect(asset:String, quick:bool, do_compile:bool, video_path:Option<&Path>, do_graphics:bool, do_playback:bool, checklist: Vec<(String,String)>) -> Report { - let threads=thread::available_parallelism().map(|n|n.get()).unwrap_or(1); - let mut disk=disks(); assess_disks(&mut disk); let bat=batteries(); let sensors=temperatures();let network=network_devices(); let mem=mem_gib(); let installed=installed_ram_gib(); - let mut concerns=vec![]; - if let Some(physical)=installed { if physical < 14.0 { concerns.push(format!("DMI reports {:.1} GiB installed; verify standardized 16 GB RAM", physical)); } } - if disk.is_empty() {concerns.push("No local block devices detected".into());} - if disk.iter().any(|d|d.rotational==Some(true)) {concerns.push("Rotating HDD detected; expected SSD".into());} - for (name,temp) in &sensors {if *temp>=90.0 {concerns.push(format!("High sensor reading {name}: {temp:.1} C; investigate cooling"));}} - for b in &bat {if let Some(h)=b.health_pct {if h<70 {concerns.push(format!("Battery {} reported {}% of design capacity",b.name,h));}}} - for d in &disk {if d.health!="PASS" {concerns.push(format!("Storage {} health {}: {}",d.name,d.health,d.smart_details));}} - for (name,state) in &checklist {if state!="PASS" && state!="NOT_APPLICABLE" {concerns.push(format!("Technician check {name}: {state}"));}} - if disk.is_empty() {concerns.push("No SSD available to certify".into());} - let bench=if quick {None} else {Some(benchmark())}; - let developer=if quick {None} else {Some(developer_benchmark())}; - let memory_bench=if quick {None} else {Some(memory_benchmark())}; - if let Some(b)=&bench { if cpu_variation_pct(b)>35.0 {concerns.push(format!("CPU benchmark variation {:.1}% across runs; repeat after checking thermal/power conditions",cpu_variation_pct(b)));} } - let compilation=if !quick && do_compile {Some(compilation_benchmark())} else {None}; - if let Some(c)=&compilation {if c.status!="PASS" {concerns.push(format!("Optional compilation benchmark: {}",c.status));}} - let video=video_path.map(ffmpeg_video); - let playback=if do_playback {video_path.map(mpv_playback)} else {None}; - let graphics=if do_graphics {Some(graphics_benchmark())} else {None}; - if let Some(v)=&video {if v.status!="PASS" {concerns.push(format!("Video decode test: {}",v.status));}} - let mut roles=grade_with_evidence(bench.as_ref(),developer.as_ref(),memory_bench.as_ref()); - if let Some(c)=&compilation {if c.status=="PASS" {if let Some(dev)=roles.iter_mut().find(|r|r.name=="Developer") {dev.reason.push_str(&format!("; real C compile: {:.1} ms ({} functions, 3 runs, compiler {})",c.median_ms,c.source_functions,c.compiler));}}} - if let Some(v)=&video {if let Some(role)=roles.iter_mut().find(|r|r.name=="Multimedia playback") {role.verdict=if v.status=="PASS" && v.realtime_factor>=1.2 {"DECODE_CANDIDATE"} else {"DISPLAY_PLAYBACK_NOT_ASSESSED"};role.reason=format!("FFmpeg real-file decode: {} frames, {:.1} fps, {:.2}x realtime. Actual display playback and dropped frames not measured",v.frames,v.fps,v.realtime_factor);}} - if let Some(g)=&graphics {if let Some(role)=roles.iter_mut().find(|r|r.name=="Gaming") {role.reason=format!("OpenGL glmark2: {} score={:?}; real game FPS untested",g.status,g.score);}} +fn collect( + asset: String, + quick: bool, + do_compile: bool, + video_path: Option<&Path>, + do_graphics: bool, + do_playback: bool, + checklist: Vec<(String, String)>, +) -> Report { + let threads = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + let mut disk = disks(); + assess_disks(&mut disk); + let bat = batteries(); + let sensors = temperatures(); + let network = network_devices(); + let mem = mem_gib(); + let installed = installed_ram_gib(); + let mut concerns = vec![]; + if let Some(physical) = installed { + if physical < 14.0 { + concerns.push(format!( + "DMI reports {:.1} GiB installed; verify standardized 16 GB RAM", + physical + )); + } + } + if disk.is_empty() { + concerns.push("No local block devices detected".into()); + } + if disk.iter().any(|d| d.rotational == Some(true)) { + concerns.push("Rotating HDD detected; expected SSD".into()); + } + for (name, temp) in &sensors { + if *temp >= 90.0 { + concerns.push(format!( + "High sensor reading {name}: {temp:.1} C; investigate cooling" + )); + } + } + for b in &bat { + if let Some(h) = b.health_pct { + if h < 70 { + concerns.push(format!( + "Battery {} reported {}% of design capacity", + b.name, h + )); + } + } + } + for d in &disk { + if d.health != "PASS" { + concerns.push(format!( + "Storage {} health {}: {}", + d.name, d.health, d.smart_details + )); + } + } + for (name, state) in &checklist { + if state != "PASS" && state != "NOT_APPLICABLE" { + concerns.push(format!("Technician check {name}: {state}")); + } + } + if disk.is_empty() { + concerns.push("No SSD available to certify".into()); + } + let bench = if quick { None } else { Some(benchmark()) }; + let developer = if quick { + None + } else { + Some(developer_benchmark()) + }; + let memory_bench = if quick { + None + } else { + Some(memory_benchmark()) + }; + if let Some(b) = &bench { + if cpu_variation_pct(b) > 35.0 { + concerns.push(format!("CPU benchmark variation {:.1}% across runs; repeat after checking thermal/power conditions",cpu_variation_pct(b))); + } + } + let compilation = if !quick && do_compile { + Some(compilation_benchmark()) + } else { + None + }; + if let Some(c) = &compilation { + if c.status != "PASS" { + concerns.push(format!("Optional compilation benchmark: {}", c.status)); + } + } + let video = video_path.map(ffmpeg_video); + let playback = if do_playback { + video_path.map(mpv_playback) + } else { + None + }; + let graphics = if do_graphics { + Some(graphics_benchmark()) + } else { + None + }; + if let Some(v) = &video { + if v.status != "PASS" { + concerns.push(format!("Video decode test: {}", v.status)); + } + } + let mut roles = grade_with_evidence(bench.as_ref(), developer.as_ref(), memory_bench.as_ref()); + if let Some(c) = &compilation { + if c.status == "PASS" { + if let Some(dev) = roles.iter_mut().find(|r| r.name == "Developer") { + dev.reason.push_str(&format!( + "; real C compile: {:.1} ms ({} functions, 3 runs, compiler {})", + c.median_ms, c.source_functions, c.compiler + )); + } + } + } + if let Some(v) = &video { + if let Some(role) = roles.iter_mut().find(|r| r.name == "Multimedia playback") { + role.verdict = if v.status == "PASS" && v.realtime_factor >= 1.2 { + "DECODE_CANDIDATE" + } else { + "DISPLAY_PLAYBACK_NOT_ASSESSED" + }; + role.reason=format!("FFmpeg real-file decode: {} frames, {:.1} fps, {:.2}x realtime. Actual display playback and dropped frames not measured",v.frames,v.fps,v.realtime_factor); + } + } + if let Some(g) = &graphics { + if let Some(role) = roles.iter_mut().find(|r| r.name == "Gaming") { + role.reason = format!( + "OpenGL glmark2: {} score={:?}; real game FPS untested", + g.status, g.score + ); + } + } // Unknown SMART or missing functional checks must never mean 'pass'. - let gate=if checklist.iter().any(|(_,s)|s=="FAIL") || disk.iter().any(|d|d.health=="WARN") || disk.iter().any(|d|d.rotational==Some(true)) || bat.iter().any(|b|b.health_pct.is_some_and(|h|h<70)) || sensors.iter().any(|(_,t)|*t>=90.0) { + let gate = if checklist.iter().any(|(_, s)| s == "FAIL") + || disk.iter().any(|d| d.health == "WARN") + || disk.iter().any(|d| d.rotational == Some(true)) + || bat.iter().any(|b| b.health_pct.is_some_and(|h| h < 70)) + || sensors.iter().any(|(_, t)| *t >= 90.0) + { "REPAIR_OR_MANUAL_REVIEW_REQUIRED" - } else if disk.is_empty() || disk.iter().any(|d|d.health!="PASS") || checklist.iter().any(|(_,s)|s!="PASS" && s!="NOT_APPLICABLE") || installed.is_some_and(|g|g<14.0) { + } else if disk.is_empty() + || disk.iter().any(|d| d.health != "PASS") + || checklist + .iter() + .any(|(_, s)| s != "PASS" && s != "NOT_APPLICABLE") + || installed.is_some_and(|g| g < 14.0) + { "TECHNICIAN_AND_SMART_REVIEW_REQUIRED" - } else {"READY_FOR_FINAL_APPROVAL"}; - Report{playback,video,graphics,compilation,memory_bench,developer,sensors,network,installed_ram_gib:installed,checklist,asset,timestamp:SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),distro:distro(),kernel:read("/proc/sys/kernel/osrelease"),vendor:read("/sys/class/dmi/id/sys_vendor"),model:read("/sys/class/dmi/id/product_name"),cpu:cpu(),threads,mem_gib:mem,gpus:gpu(),disks:disk,batteries:bat,bench,roles,concerns,health_gate:gate} + } else { + "READY_FOR_FINAL_APPROVAL" + }; + Report { + playback, + video, + graphics, + compilation, + memory_bench, + developer, + sensors, + network, + installed_ram_gib: installed, + checklist, + asset, + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + distro: distro(), + kernel: read("/proc/sys/kernel/osrelease"), + vendor: read("/sys/class/dmi/id/sys_vendor"), + model: read("/sys/class/dmi/id/product_name"), + cpu: cpu(), + threads, + mem_gib: mem, + gpus: gpu(), + disks: disk, + batteries: bat, + bench, + roles, + concerns, + health_gate: gate, + } } -fn json_report(r:&Report) -> String { +fn json_report(r: &Report) -> String { let mut s=format!("{{\n \"schema_version\": 1,\n \"tool_version\": {},\n \"asset_id\": {},\n \"timestamp_unix\": {},\n \"distro\": {},\n \"kernel\": {},\n \"manufacturer\": {},\n \"model\": {},\n \"cpu\": {},\n \"logical_threads\": {},\n \"ram_gib\": {:.3},\n \"health_gate\": {},\n",json(VERSION),json(&r.asset),r.timestamp,json(&r.distro),json(&r.kernel),json(&r.vendor),json(&r.model),json(&r.cpu),r.threads,r.mem_gib,json(r.health_gate)); - s.push_str(&format!(" \"serial_number\": {},\n \"system_uuid\": {},\n",json(&dmi_identity("product_serial")),json(&dmi_identity("product_uuid")))); - s.push_str(&format!(" \"chassis_type\": {},\n", json(&read("/sys/class/dmi/id/chassis_type")))); - let touchpad=fs::read_to_string("/proc/bus/input/devices").ok().map(|v|v.to_lowercase().contains("touchpad")); - let webcam=fs::read_dir("/sys/class/video4linux").ok().map(|mut e|e.next().is_some()); - s.push_str(&format!(" \"capabilities\": {{\"touchpad\":{},\"webcam\":{}}},\n",touchpad.map(|v|v.to_string()).unwrap_or("null".into()),webcam.map(|v|v.to_string()).unwrap_or("null".into()))); + s.push_str(&format!( + " \"serial_number\": {},\n \"system_uuid\": {},\n", + json(&dmi_identity("product_serial")), + json(&dmi_identity("product_uuid")) + )); + s.push_str(&format!( + " \"chassis_type\": {},\n", + json(&read("/sys/class/dmi/id/chassis_type")) + )); + let touchpad = fs::read_to_string("/proc/bus/input/devices") + .ok() + .map(|v| v.to_lowercase().contains("touchpad")); + let webcam = fs::read_dir("/sys/class/video4linux") + .ok() + .map(|mut e| e.next().is_some()); + s.push_str(&format!( + " \"capabilities\": {{\"touchpad\":{},\"webcam\":{}}},\n", + touchpad.map(|v| v.to_string()).unwrap_or("null".into()), + webcam.map(|v| v.to_string()).unwrap_or("null".into()) + )); - s.push_str(" \"gpus\": [");for (i,g) in r.gpus.iter().enumerate(){if i>0{s.push(',');}s.push_str(&json(g));}s.push_str("],\n \"disks\": ["); - for (i,d) in r.disks.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"name\":{},\"size_gib\":{},\"rotational\":{},\"removable\":{},\"model\":{},\"smart_health\":{},\"smart_details\":{}}}",json(&d.name),d.gib,d.rotational.map(|x|x.to_string()).unwrap_or("null".into()),d.removable,json(&d.model),json(&d.health),json(&d.smart_details)));} + s.push_str(" \"gpus\": ["); + for (i, g) in r.gpus.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&json(g)); + } + s.push_str("],\n \"disks\": ["); + for (i, d) in r.disks.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{{\"name\":{},\"size_gib\":{},\"rotational\":{},\"removable\":{},\"model\":{},\"smart_health\":{},\"smart_details\":{}}}",json(&d.name),d.gib,d.rotational.map(|x|x.to_string()).unwrap_or("null".into()),d.removable,json(&d.model),json(&d.health),json(&d.smart_details))); + } s.push_str("],\n \"temperatures_c\": ["); - for (i,(name,value)) in r.sensors.iter().enumerate() {if i>0{s.push(',');}s.push_str(&format!("{{\"sensor\":{},\"celsius\":{:.2}}}",json(name),value));} + for (i, (name, value)) in r.sensors.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!( + "{{\"sensor\":{},\"celsius\":{:.2}}}", + json(name), + value + )); + } s.push_str("],\n \"network_devices\": ["); - for (i,(name,status)) in r.network.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"interface\":{},\"status\":{}}}",json(name),json(status)));} - s.push_str("],\n \"batteries\": [");for (i,b) in r.batteries.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"name\":{},\"charge_percent\":{},\"health_percent\":{},\"status\":{}}}",json(&b.name),b.capacity_pct.map(|x|x.to_string()).unwrap_or("null".into()),b.health_pct.map(|x|x.to_string()).unwrap_or("null".into()),json(&b.status)));} - s.push_str("],\n \"benchmarks\": "); if let Some(b)=&r.bench {s.push_str(&format!("{{\"type\":\"rust_integer_mix_v1\",\"work_per_thread\":{},\"single_ms\":{:.4},\"parallel_ms\":{:.4},\"parallel_threads\":{}}}",WORK,b.single_ms,b.multi_ms,b.threads)); - s.pop();s.push_str(",\"single_runs_ms\":[");for(i,v)in b.single_runs_ms.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.4}"));} - s.push_str("],\"parallel_runs_ms\":[");for(i,v)in b.parallel_runs_ms.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.4}"));}s.push_str("]}"); - }else{s.push_str("null");} + for (i, (name, status)) in r.network.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!( + "{{\"interface\":{},\"status\":{}}}", + json(name), + json(status) + )); + } + s.push_str("],\n \"batteries\": ["); + for (i, b) in r.batteries.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!( + "{{\"name\":{},\"charge_percent\":{},\"health_percent\":{},\"status\":{}}}", + json(&b.name), + b.capacity_pct + .map(|x| x.to_string()) + .unwrap_or("null".into()), + b.health_pct.map(|x| x.to_string()).unwrap_or("null".into()), + json(&b.status) + )); + } + s.push_str("],\n \"benchmarks\": "); + if let Some(b) = &r.bench { + s.push_str(&format!("{{\"type\":\"rust_integer_mix_v1\",\"work_per_thread\":{},\"single_ms\":{:.4},\"parallel_ms\":{:.4},\"parallel_threads\":{}}}",WORK,b.single_ms,b.multi_ms,b.threads)); + s.pop(); + s.push_str(",\"single_runs_ms\":["); + for (i, v) in b.single_runs_ms.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{v:.4}")); + } + s.push_str("],\"parallel_runs_ms\":["); + for (i, v) in b.parallel_runs_ms.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{v:.4}")); + } + s.push_str("]}"); + } else { + s.push_str("null"); + } s.push_str(",\n \"memory_benchmark\": "); - if let Some(b)=&r.memory_bench { + if let Some(b) = &r.memory_bench { s.push_str(&format!("{{\"type\":\"sequential_read_v1\",\"buffer_mib\":{},\"median_mib_s\":{:.2},\"checksum\":{},\"runs_mib_s\":[", b.buffer_mib,b.median_mib_s,b.checksum)); - for (i,v) in b.runs_mib_s.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.2}"));} + for (i, v) in b.runs_mib_s.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{v:.2}")); + } s.push_str("]}"); - } else {s.push_str("null");} + } else { + s.push_str("null"); + } s.push_str(",\n \"developer_benchmark\": "); - if let Some(d)=&r.developer { + if let Some(d) = &r.developer { s.push_str(&format!("{{\"type\":\"source_index_v1\",\"records\":{},\"median_ms\":{:.4},\"checksum\":{},\"runs_ms\":[",d.records,d.median_ms,d.checksum)); - for (i,v) in d.runs_ms.iter().enumerate() {if i>0{s.push(',');}s.push_str(&format!("{v:.4}"));} + for (i, v) in d.runs_ms.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{v:.4}")); + } s.push_str("]}"); - } else {s.push_str("null");} + } else { + s.push_str("null"); + } s.push_str(",\n \"compilation_benchmark\": "); - if let Some(c)=&r.compilation {s.push_str(&format!("{{\"status\":{},\"compiler\":{},\"median_ms\":{:.3},\"source_functions\":{},\"runs_ms\":[",json(&c.status),json(&c.compiler),c.median_ms,c.source_functions));for(i,v)in c.runs_ms.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{v:.3}"));}s.push_str("]}");} else {s.push_str("null");} + if let Some(c) = &r.compilation { + s.push_str(&format!("{{\"status\":{},\"compiler\":{},\"median_ms\":{:.3},\"source_functions\":{},\"runs_ms\":[",json(&c.status),json(&c.compiler),c.median_ms,c.source_functions)); + for (i, v) in c.runs_ms.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{v:.3}")); + } + s.push_str("]}"); + } else { + s.push_str("null"); + } s.push_str(",\n \"playback_check\": "); - if let Some(v)=&r.playback {s.push_str(&format!("{{\"status\":{},\"elapsed_seconds\":{:.3},\"note\":{}}}",json(&v.status),v.elapsed_seconds,json(&v.note)));} else {s.push_str("null");} + if let Some(v) = &r.playback { + s.push_str(&format!( + "{{\"status\":{},\"elapsed_seconds\":{:.3},\"note\":{}}}", + json(&v.status), + v.elapsed_seconds, + json(&v.note) + )); + } else { + s.push_str("null"); + } s.push_str(",\n \"video_benchmark\": "); - if let Some(v)=&r.video {s.push_str(&format!("{{\"status\":{},\"source\":{},\"codec\":{},\"width\":{},\"height\":{},\"frames\":{},\"elapsed_seconds\":{:.3},\"decode_fps\":{:.2},\"realtime_factor\":{:.3},\"note\":{}}}",json(&v.status),json(&v.source),json(&v.codec),v.width,v.height,v.frames,v.seconds,v.fps,v.realtime_factor,json(&v.note)));} else {s.push_str("null");} + if let Some(v) = &r.video { + s.push_str(&format!("{{\"status\":{},\"source\":{},\"codec\":{},\"width\":{},\"height\":{},\"frames\":{},\"elapsed_seconds\":{:.3},\"decode_fps\":{:.2},\"realtime_factor\":{:.3},\"note\":{}}}",json(&v.status),json(&v.source),json(&v.codec),v.width,v.height,v.frames,v.seconds,v.fps,v.realtime_factor,json(&v.note))); + } else { + s.push_str("null"); + } s.push_str(",\n \"graphics_benchmark\": "); - if let Some(g)=&r.graphics {s.push_str(&format!("{{\"status\":{},\"score\":{},\"note\":{}}}",json(&g.status),g.score.map(|v|v.to_string()).unwrap_or("null".into()),json(&g.note)));}else{s.push_str("null");} - s.push_str(",\n \"roles\": [");for (i,role) in r.roles.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{{\"name\":{},\"verdict\":{},\"score\":{},\"reason\":{}}}",json(role.name),json(role.verdict),role.score.map(|v|v.to_string()).unwrap_or("null".into()),json(&role.reason)));} - s.push_str("],\n \"concerns\": [");for (i,c) in r.concerns.iter().enumerate(){if i>0{s.push(',');}s.push_str(&json(c));}s.push_str("],\n \"installed_ram_gib\": "); - s.push_str(&r.installed_ram_gib.map(|x|format!("{:.3}",x)).unwrap_or("null".into())); + if let Some(g) = &r.graphics { + s.push_str(&format!( + "{{\"status\":{},\"score\":{},\"note\":{}}}", + json(&g.status), + g.score.map(|v| v.to_string()).unwrap_or("null".into()), + json(&g.note) + )); + } else { + s.push_str("null"); + } + s.push_str(",\n \"roles\": ["); + for (i, role) in r.roles.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!( + "{{\"name\":{},\"verdict\":{},\"score\":{},\"reason\":{}}}", + json(role.name), + json(role.verdict), + role.score.map(|v| v.to_string()).unwrap_or("null".into()), + json(&role.reason) + )); + } + s.push_str("],\n \"concerns\": ["); + for (i, c) in r.concerns.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&json(c)); + } + s.push_str("],\n \"installed_ram_gib\": "); + s.push_str( + &r.installed_ram_gib + .map(|x| format!("{:.3}", x)) + .unwrap_or("null".into()), + ); s.push_str(",\n \"technician_checks\": {"); - for (i,(name,value)) in r.checklist.iter().enumerate(){if i>0{s.push(',');}s.push_str(&format!("{}:{}",json(name),json(value)));} - s.push_str("}\n}\n");s + for (i, (name, value)) in r.checklist.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&format!("{}:{}", json(name), json(value))); + } + s.push_str("}\n}\n"); + s } -fn html(r:&Report) -> String { +fn html(r: &Report) -> String { let mut s=format!("PC Intake {}

PC Intake: {}

Scanner version: {}

Release gate: {}

{} · {} · {:.1} GiB RAM

CPU: {}

OS: {} (kernel {})

",esc(&r.asset),esc(&r.asset),esc(VERSION),esc(r.health_gate),esc(&r.vendor),esc(&r.model),r.mem_gib,esc(&r.cpu),esc(&r.distro),esc(&r.kernel)); - s.push_str("

Disk health (native NVMe SMART where available)

");for d in &r.disks{s.push_str(&format!("",esc(&d.name),d.gib,if d.rotational==Some(true){"HDD"}else if d.rotational==Some(false){"SSD / non-rotating"}else{"Unknown"},esc(&d.health),esc(&d.smart_details)));}s.push_str("
DeviceCapacityTypeHealth
{}{} GiB{}{}: {}

Graphics devices

    ");for g in &r.gpus{s.push_str(&format!("
  • {}
  • ",esc(g)));}s.push_str("

Batteries

    ");for b in &r.batteries{s.push_str(&format!("
  • {}: charge {:?}%, capacity vs design {:?}%, status {}
  • ",esc(&b.name),b.capacity_pct,b.health_pct,esc(&b.status)));}s.push_str("

CPU proxy benchmark

");if let Some(b)=&r.bench{s.push_str(&format!("

Integer mix v1 median of five runs: single {:.2} ms, parallel {:.2} ms ({} threads). Lower is better; this is not an application benchmark.

",b.single_ms,b.multi_ms,b.threads));}else{s.push_str("

Not run (--quick)

");} + s.push_str("

Disk health (native NVMe SMART where available)

"); + for d in &r.disks { + s.push_str(&format!( + "", + esc(&d.name), + d.gib, + if d.rotational == Some(true) { + "HDD" + } else if d.rotational == Some(false) { + "SSD / non-rotating" + } else { + "Unknown" + }, + esc(&d.health), + esc(&d.smart_details) + )); + } + s.push_str("
DeviceCapacityTypeHealth
{}{} GiB{}{}: {}

Graphics devices

    "); + for g in &r.gpus { + s.push_str(&format!("
  • {}
  • ", esc(g))); + } + s.push_str("

Batteries

    "); + for b in &r.batteries { + s.push_str(&format!( + "
  • {}: charge {:?}%, capacity vs design {:?}%, status {}
  • ", + esc(&b.name), + b.capacity_pct, + b.health_pct, + esc(&b.status) + )); + } + s.push_str("

CPU proxy benchmark

"); + if let Some(b) = &r.bench { + s.push_str(&format!("

Integer mix v1 median of five runs: single {:.2} ms, parallel {:.2} ms ({} threads). Lower is better; this is not an application benchmark.

",b.single_ms,b.multi_ms,b.threads)); + } else { + s.push_str("

Not run (--quick)

"); + } s.push_str("

Memory bandwidth (single-thread read proxy)

"); - if let Some(b)=&r.memory_bench {s.push_str(&format!("

{:.0} MiB/s median of 5 runs reading {} MiB of RAM. Higher is better; not a full memory bandwidth test.

",b.median_mib_s,b.buffer_mib));}else{s.push_str("

Not run (--quick).

");} + if let Some(b) = &r.memory_bench { + s.push_str(&format!("

{:.0} MiB/s median of 5 runs reading {} MiB of RAM. Higher is better; not a full memory bandwidth test.

",b.median_mib_s,b.buffer_mib)); + } else { + s.push_str("

Not run (--quick).

"); + } s.push_str("

Developer workload (source-indexing proxy)

"); - if let Some(d)=&r.developer {s.push_str(&format!("

{} generated source records scanned in {:.2} ms (median of 3). Not a compilation or IDE benchmark.

",d.records,d.median_ms));}else{s.push_str("

Not run (--quick).

");} + if let Some(d) = &r.developer { + s.push_str(&format!("

{} generated source records scanned in {:.2} ms (median of 3). Not a compilation or IDE benchmark.

",d.records,d.median_ms)); + } else { + s.push_str("

Not run (--quick).

"); + } s.push_str("

On-screen playback check (optional)

"); - if let Some(v)=&r.playback {s.push_str(&format!("

{} in {:.1}s. {}

",esc(&v.status),v.elapsed_seconds,esc(&v.note)));}else{s.push_str("

Not requested (--playback with --video-file PATH).

");} + if let Some(v) = &r.playback { + s.push_str(&format!( + "

{} in {:.1}s. {}

", + esc(&v.status), + v.elapsed_seconds, + esc(&v.note) + )); + } else { + s.push_str("

Not requested (--playback with --video-file PATH).

"); + } s.push_str("

Real media decode (optional)

"); - if let Some(v)=&r.video {s.push_str(&format!("

Status: {}. File: {}. Codec: {}. Resolution: {}x{}. {} frames in {:.2}s = {:.1} decode FPS ({:.2}x nominal real time). {}

",esc(&v.status),esc(&v.source),esc(&v.codec),v.width,v.height,v.frames,v.seconds,v.fps,v.realtime_factor,esc(&v.note)));}else{s.push_str("

Not requested (--video-file PATH).

");} + if let Some(v) = &r.video { + s.push_str(&format!("

Status: {}. File: {}. Codec: {}. Resolution: {}x{}. {} frames in {:.2}s = {:.1} decode FPS ({:.2}x nominal real time). {}

",esc(&v.status),esc(&v.source),esc(&v.codec),v.width,v.height,v.frames,v.seconds,v.fps,v.realtime_factor,esc(&v.note))); + } else { + s.push_str("

Not requested (--video-file PATH).

"); + } s.push_str("

OpenGL benchmark (optional)

"); - if let Some(g)=&r.graphics {s.push_str(&format!("

{}, score: {:?}. {}

",esc(&g.status),g.score,esc(&g.note)));}else{s.push_str("

Not requested (--graphics).

");} + if let Some(g) = &r.graphics { + s.push_str(&format!( + "

{}, score: {:?}. {}

", + esc(&g.status), + g.score, + esc(&g.note) + )); + } else { + s.push_str("

Not requested (--graphics).

"); + } s.push_str("

Optional real C compilation benchmark

"); - if let Some(c)=&r.compilation {s.push_str(&format!("

Status: {}. Compiler: {}. {} functions, median {:.2} ms over {} runs. Lower is better. Compiler version and system load affect comparability.

",esc(&c.status),esc(&c.compiler),c.source_functions,c.median_ms,c.runs_ms.len()));} else {s.push_str("

Not requested (add --compile). An installed C compiler is required only for this optional test.

");} + if let Some(c) = &r.compilation { + s.push_str(&format!("

Status: {}. Compiler: {}. {} functions, median {:.2} ms over {} runs. Lower is better. Compiler version and system load affect comparability.

",esc(&c.status),esc(&c.compiler),c.source_functions,c.median_ms,c.runs_ms.len())); + } else { + s.push_str("

Not requested (add --compile). An installed C compiler is required only for this optional test.

"); + } s.push_str(&format!("

Memory

Usable Linux RAM: {:.2} GiB. Installed DMI RAM: {}. Usable RAM is not the same as physically installed RAM.

",r.mem_gib,r.installed_ram_gib.map(|x|format!("{x:.2} GiB")).unwrap_or("UNKNOWN (DMI unavailable)".into()))); s.push_str("

Temperature sensors (snapshot)

"); - for (name,temp) in &r.sensors {s.push_str(&format!("",esc(name),temp));}s.push_str("
SensorTemperature
{}{:.1} °C

A single idle snapshot is not a load/thermal-throttling test.

"); + for (name, temp) in &r.sensors { + s.push_str(&format!( + "{}{:.1} °C", + esc(name), + temp + )); + } + s.push_str("

A single idle snapshot is not a load/thermal-throttling test.

"); s.push_str("

Network interfaces

"); - for (name,status) in &r.network {s.push_str(&format!("",esc(name),esc(status)));}s.push_str("
InterfaceLink state
{}{}
"); - s.push_str("

Technician checklist

");for (name,value) in &r.checklist{s.push_str(&format!("",esc(name),esc(value)));}s.push_str("
CheckStatus
{}{}
"); - s.push_str("

Provisional role guidance

Scores are uncalibrated workload proxies, not a decision to donate. Empty scores mean not assessed.

");for role in &r.roles{s.push_str(&format!("",esc(role.name),esc(role.verdict),role.score.map(|v|format!("{v}/100")).unwrap_or("—".into()),esc(&role.reason)));}s.push_str("
UseAssessmentProxy scoreLimitations
{}{}{}{}

Concerns

    ");for c in &r.concerns{s.push_str(&format!("
  • {}
  • ",esc(c)));}s.push_str("

READY_FOR_FINAL_APPROVAL still requires an authorized human decision.

");s + for (name, status) in &r.network { + s.push_str(&format!( + "{}{}", + esc(name), + esc(status) + )); + } + s.push_str(""); + s.push_str("

Technician checklist

"); + for (name, value) in &r.checklist { + s.push_str(&format!( + "", + esc(name), + esc(value) + )); + } + s.push_str("
CheckStatus
{}{}
"); + s.push_str("

Provisional role guidance

Scores are uncalibrated workload proxies, not a decision to donate. Empty scores mean not assessed.

"); + for role in &r.roles { + s.push_str(&format!( + "", + esc(role.name), + esc(role.verdict), + role.score.map(|v| format!("{v}/100")).unwrap_or("—".into()), + esc(&role.reason) + )); + } + s.push_str("
UseAssessmentProxy scoreLimitations
{}{}{}{}

Concerns

    "); + for c in &r.concerns { + s.push_str(&format!("
  • {}
  • ", esc(c))); + } + s.push_str("

READY_FOR_FINAL_APPROVAL still requires an authorized human decision.

"); + s } -fn csv_report(r:&Report) -> String { - let single=r.bench.as_ref().map(|b|format!("{:.4}",b.single_ms)).unwrap_or_default(); - let multi=r.bench.as_ref().map(|b|format!("{:.4}",b.multi_ms)).unwrap_or_default(); - let disks=r.disks.iter().map(|d|format!("{}:{}GiB",d.name,d.gib)).collect::>().join(";"); - let gpus=r.gpus.join(";"); - let concerns=r.concerns.join(";"); +fn csv_report(r: &Report) -> String { + let single = r + .bench + .as_ref() + .map(|b| format!("{:.4}", b.single_ms)) + .unwrap_or_default(); + let multi = r + .bench + .as_ref() + .map(|b| format!("{:.4}", b.multi_ms)) + .unwrap_or_default(); + let disks = r + .disks + .iter() + .map(|d| format!("{}:{}GiB", d.name, d.gib)) + .collect::>() + .join(";"); + let gpus = r.gpus.join(";"); + let concerns = r.concerns.join(";"); format!("tool_version,asset_id,timestamp_unix,manufacturer,model,cpu,ram_gib,disks,gpus,cpu_single_ms,cpu_parallel_ms,health_gate,concerns,installed_ram_gib,technician_failed,technician_unknown,developer_index_ms,cpu_single_variation_pct,memory_read_mib_s,office_proxy_score,developer_proxy_score,compile_median_ms,compile_status\n{}\n",[csv(VERSION),csv(&r.asset),r.timestamp.to_string(),csv(&r.vendor),csv(&r.model),csv(&r.cpu),format!("{:.3}",r.mem_gib),csv(&disks),csv(&gpus),single,multi,csv(r.health_gate),csv(&concerns),r.installed_ram_gib.map(|v|format!("{v:.3}")).unwrap_or_default(),r.checklist.iter().filter(|(_,s)|s=="FAIL").count().to_string(),r.checklist.iter().filter(|(_,s)|s=="UNKNOWN").count().to_string(),r.developer.as_ref().map(|d|format!("{:.4}",d.median_ms)).unwrap_or_default(),r.bench.as_ref().map(|b|format!("{:.1}",cpu_variation_pct(b))).unwrap_or_default(),r.memory_bench.as_ref().map(|b|format!("{:.2}",b.median_mib_s)).unwrap_or_default(),r.roles.iter().find(|x|x.name=="Office").and_then(|x|x.score).map(|v|v.to_string()).unwrap_or_default(),r.roles.iter().find(|x|x.name=="Developer").and_then(|x|x.score).map(|v|v.to_string()).unwrap_or_default(),r.compilation.as_ref().filter(|c|c.status=="PASS").map(|c|format!("{:.3}",c.median_ms)).unwrap_or_default(),csv(r.compilation.as_ref().map(|c|c.status.as_str()).unwrap_or("NOT_REQUESTED"))].join(",")) } fn tool_status(name: &str) -> String { let found = env::var_os("PATH").and_then(|paths| { env::split_paths(&paths).find_map(|dir| { - let candidate=dir.join(name); - if candidate.is_file() { Some(candidate) } else { None } + let candidate = dir.join(name); + if candidate.is_file() { + Some(candidate) + } else { + None + } }) }); - match found {Some(p)=>format!("FOUND ({})",p.display()),None=>"NOT FOUND".into()} + match found { + Some(p) => format!("FOUND ({})", p.display()), + None => "NOT FOUND".into(), + } } fn self_check() { println!("PC Intake v{VERSION} — environment diagnostics"); println!("Platform: {} / {}", env::consts::OS, env::consts::ARCH); - println!("DMI access: {}", if Path::new("/sys/class/dmi/id").exists(){"available"}else{"unavailable"}); - println!("Block devices: {}", if Path::new("/sys/block").exists(){"available"}else{"unavailable"}); - for (tool, purpose) in [("smartctl","SATA/USB SMART"),("ffmpeg","video decoding"),("mpv","on-screen playback"),("glmark2","OpenGL benchmark"),("cc","C compilation"),("gcc","C compilation"),("clang","C compilation")] { + println!( + "DMI access: {}", + if Path::new("/sys/class/dmi/id").exists() { + "available" + } else { + "unavailable" + } + ); + println!( + "Block devices: {}", + if Path::new("/sys/block").exists() { + "available" + } else { + "unavailable" + } + ); + for (tool, purpose) in [ + ("smartctl", "SATA/USB SMART"), + ("ffmpeg", "video decoding"), + ("mpv", "on-screen playback"), + ("glmark2", "OpenGL benchmark"), + ("cc", "C compilation"), + ("gcc", "C compilation"), + ("clang", "C compilation"), + ] { println!("{tool}: {} — {purpose}", tool_status(tool)); } println!("NOTE: FOUND does not prove a utility works or has permissions to access the device."); println!("Run the binary from this release, not an older ./pc-intake elsewhere."); } -fn usage() {println!("pc-intake v{VERSION}\nUsage: pc-intake --asset-id ID [--quick] [--output DIR] [--checklist FILE] [--compile] [--video-file PATH] [--playback] [--graphics]\n pc-intake --version | --self-check | --help\nOffline Linux intake; no third-party runtime dependencies.\n--quick: skip CPU work; disk SMART collection still attempted. No GPU/game or video playback benchmark is performed.\n--compile: opt-in C compilation test; requires cc/gcc/clang installed.");} -fn run() -> Result<(),String> { - let mut args=env::args().skip(1);let mut id=None;let mut out=PathBuf::from("reports");let mut quick=false;let mut do_compile=false;let mut do_graphics=false;let mut do_playback=false;let mut video_path:Option=None;let mut checks_path:Option=None; - while let Some(a)=args.next(){match a.as_str(){"--asset-id"=>id=Some(args.next().ok_or("--asset-id needs a value")?),"--output"=>out=PathBuf::from(args.next().ok_or("--output needs a directory")?),"--quick"=>quick=true,"--compile"=>do_compile=true,"--graphics"=>do_graphics=true,"--playback"=>do_playback=true,"--video-file"=>video_path=Some(PathBuf::from(args.next().ok_or("--video-file needs a file")?)),"--checklist"=>checks_path=Some(PathBuf::from(args.next().ok_or("--checklist needs a file")?)),"--version"|"-V"=>{println!("pc-intake v{VERSION}");return Ok(());},"--self-check"=>{self_check();return Ok(());},"--help"|"-h"=>{usage();return Ok(());},_=>return Err(format!("Unknown argument: {a}"))}} - if do_playback && video_path.is_none() {return Err("--playback requires --video-file PATH".into());} - let id=id.ok_or("Provide --asset-id (for example NPC-0042)")?; - if id.is_empty()||id.len()>64||!id.chars().all(|c|c.is_ascii_alphanumeric()||c=='-'||c=='_') {return Err("Asset ID must be 1-64 ASCII letters, digits, '-' or '_'".into());} - let checks=checklist(checks_path.as_deref())?;let report=collect(id.clone(),quick,do_compile,video_path.as_deref(),do_graphics,do_playback,checks);let folder=out.join(&id).join(format!("{}-{}",report.timestamp,process::id())); - fs::create_dir_all(&folder).map_err(|e|e.to_string())?; - fs::write(folder.join("report.json"),json_report(&report)).map_err(|e|e.to_string())?; - fs::write(folder.join("report.html"),html(&report)).map_err(|e|e.to_string())?; - fs::write(folder.join("summary.csv"),csv_report(&report)).map_err(|e|e.to_string())?; +fn usage() { + println!("pc-intake v{VERSION}\nUsage: pc-intake --asset-id ID [--quick] [--output DIR] [--checklist FILE] [--compile] [--video-file PATH] [--playback] [--graphics]\n pc-intake --version | --self-check | --help\nOffline Linux intake; no third-party runtime dependencies.\n--quick: skip CPU work; disk SMART collection still attempted. No GPU/game or video playback benchmark is performed.\n--compile: opt-in C compilation test; requires cc/gcc/clang installed."); +} +fn run() -> Result<(), String> { + let mut args = env::args().skip(1); + let mut id = None; + let mut out = PathBuf::from("reports"); + let mut quick = false; + let mut do_compile = false; + let mut do_graphics = false; + let mut do_playback = false; + let mut video_path: Option = None; + let mut checks_path: Option = None; + while let Some(a) = args.next() { + match a.as_str() { + "--asset-id" => id = Some(args.next().ok_or("--asset-id needs a value")?), + "--output" => out = PathBuf::from(args.next().ok_or("--output needs a directory")?), + "--quick" => quick = true, + "--compile" => do_compile = true, + "--graphics" => do_graphics = true, + "--playback" => do_playback = true, + "--video-file" => { + video_path = Some(PathBuf::from( + args.next().ok_or("--video-file needs a file")?, + )) + } + "--checklist" => { + checks_path = Some(PathBuf::from( + args.next().ok_or("--checklist needs a file")?, + )) + } + "--version" | "-V" => { + println!("pc-intake v{VERSION}"); + return Ok(()); + } + "--self-check" => { + self_check(); + return Ok(()); + } + "--help" | "-h" => { + usage(); + return Ok(()); + } + _ => return Err(format!("Unknown argument: {a}")), + } + } + if do_playback && video_path.is_none() { + return Err("--playback requires --video-file PATH".into()); + } + let id = id.ok_or("Provide --asset-id (for example NPC-0042)")?; + if id.is_empty() + || id.len() > 64 + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err("Asset ID must be 1-64 ASCII letters, digits, '-' or '_'".into()); + } + let checks = checklist(checks_path.as_deref())?; + let report = collect( + id.clone(), + quick, + do_compile, + video_path.as_deref(), + do_graphics, + do_playback, + checks, + ); + let folder = out + .join(&id) + .join(format!("{}-{}", report.timestamp, process::id())); + fs::create_dir_all(&folder).map_err(|e| e.to_string())?; + fs::write(folder.join("report.json"), json_report(&report)).map_err(|e| e.to_string())?; + fs::write(folder.join("report.html"), html(&report)).map_err(|e| e.to_string())?; + fs::write(folder.join("summary.csv"), csv_report(&report)).map_err(|e| e.to_string())?; println!("PC Intake v{VERSION}"); - println!("Asset: {} | CPU: {} | RAM {:.1} GiB",id,report.cpu,report.mem_gib); - for d in &report.disks { println!("Disk {} ({} GiB): SMART {} — {}",d.name,d.gib,d.health,d.smart_details); } - println!("Release: {}",report.health_gate); - println!("Installed RAM (DMI): {}",report.installed_ram_gib.map(|x|format!("{x:.2} GiB")).unwrap_or("UNKNOWN".into())); - println!("Reports: {}",folder.display());Ok(()) + println!( + "Asset: {} | CPU: {} | RAM {:.1} GiB", + id, report.cpu, report.mem_gib + ); + for d in &report.disks { + println!( + "Disk {} ({} GiB): SMART {} — {}", + d.name, d.gib, d.health, d.smart_details + ); + } + println!("Release: {}", report.health_gate); + println!( + "Installed RAM (DMI): {}", + report + .installed_ram_gib + .map(|x| format!("{x:.2} GiB")) + .unwrap_or("UNKNOWN".into()) + ); + println!("Reports: {}", folder.display()); + Ok(()) } -fn main(){if !cfg!(target_os="linux"){eprintln!("Warning: Linux-only hardware inventory");}if let Err(e)=run(){eprintln!("Error: {e}");usage();process::exit(2)}} -#[cfg(test)] mod tests {use super::*;#[test]fn escapes(){assert_eq!(json("x\"\\\n"),"\"x\\\"\\\\\\n\"");assert_eq!(esc("=0.0&&*t<=180.0));} - #[test] fn network_excludes_loopback(){assert!(!network_devices().iter().any(|(name,_)|name=="lo"));} -} - -#[cfg(test)] mod v07_tests { - use super::*; - #[test] fn memory_work_has_data() { let m=memory_benchmark(); assert_eq!(m.runs_mib_s.len(),5); assert!(m.median_mib_s>0.0); assert_eq!(m.buffer_mib,64); } - #[test] fn physical_memory_is_not_usable_memory() { let r=mem_gib(); assert!(r>=0.0); } -} - -#[cfg(test)] mod workflow_compatibility_tests { - use super::*; - #[test] fn accepts_gui_statuses_without_treating_unavailable_as_failure() { - let path=std::env::temp_dir().join(format!("csr-checklist-{}-{}",process::id(),SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())); - fs::write(&path,"display=PASS\nkeyboard=FAIL\ntouchpad=NOT_APPLICABLE\nwifi=SETUP_PROBLEM\nmicrophone=UNAVAILABLE\nwebcam=NOT_TESTED\n").unwrap(); - let checks=checklist(Some(&path)).unwrap();fs::remove_file(path).unwrap(); - let get=|key:&str|checks.iter().find(|(k,_)|k==key).unwrap().1.as_str(); - assert_eq!(get("display"),"PASS");assert_eq!(get("keyboard"),"FAIL");assert_eq!(get("touchpad"),"NOT_APPLICABLE");assert_eq!(get("wifi"),"UNKNOWN");assert_eq!(get("microphone"),"UNKNOWN");assert_eq!(get("webcam"),"UNKNOWN"); +fn main() { + if !cfg!(target_os = "linux") { + eprintln!("Warning: Linux-only hardware inventory"); + } + if let Err(e) = run() { + eprintln!("Error: {e}"); + usage(); + process::exit(2) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn escapes() { + assert_eq!(json("x\"\\\n"), "\"x\\\"\\\\\\n\""); + assert_eq!(esc("= 0.0 && *t <= 180.0)); + } + #[test] + fn network_excludes_loopback() { + assert!(!network_devices().iter().any(|(name, _)| name == "lo")); + } +} + +#[cfg(test)] +mod v07_tests { + use super::*; + #[test] + fn memory_work_has_data() { + let m = memory_benchmark(); + assert_eq!(m.runs_mib_s.len(), 5); + assert!(m.median_mib_s > 0.0); + assert_eq!(m.buffer_mib, 64); + } + #[test] + fn physical_memory_is_not_usable_memory() { + let r = mem_gib(); + assert!(r >= 0.0); + } +} + +#[cfg(test)] +mod workflow_compatibility_tests { + use super::*; + #[test] + fn accepts_gui_statuses_without_treating_unavailable_as_failure() { + let path = std::env::temp_dir().join(format!( + "csr-checklist-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write(&path,"display=PASS\nkeyboard=FAIL\ntouchpad=NOT_APPLICABLE\nwifi=SETUP_PROBLEM\nmicrophone=UNAVAILABLE\nwebcam=NOT_TESTED\n").unwrap(); + let checks = checklist(Some(&path)).unwrap(); + fs::remove_file(path).unwrap(); + let get = |key: &str| checks.iter().find(|(k, _)| k == key).unwrap().1.as_str(); + assert_eq!(get("display"), "PASS"); + assert_eq!(get("keyboard"), "FAIL"); + assert_eq!(get("touchpad"), "NOT_APPLICABLE"); + assert_eq!(get("wifi"), "UNKNOWN"); + assert_eq!(get("microphone"), "UNKNOWN"); + assert_eq!(get("webcam"), "UNKNOWN"); } } diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 78af067..442916b 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -4,6 +4,8 @@ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) export CSR_BUILD_ID CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id) for component in scanner controller gui; do + cargo 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 build --locked --release --manifest-path "$ROOT/$component/Cargo.toml" done diff --git a/scripts/install-usb.sh b/scripts/install-usb.sh index f3ee020..5abd395 100755 --- a/scripts/install-usb.sh +++ b/scripts/install-usb.sh @@ -1,14 +1,13 @@ #!/usr/bin/env bash # DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic. set -Eeuo pipefail -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) DEVICE=${1:-} ISO=${2:-} if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2 fi [[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; } -for cmd in lsblk blkid sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs dd sync awk bsdtar blockdev partprobe udevadm; do +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; } done 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; } iso_bytes=$(stat -c %s "$ISO") ((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) serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs) echo '===== DESTRUCTIVE USB INSTALL =====' @@ -55,8 +77,6 @@ done mkfs.vfat -F32 -n INTAKE_BOOT "$p2" mkfs.ext4 -F -L INTAKE_DATA "$p3" TMP=$(mktemp -d) -cleanup(){ umount "$TMP/boot" 2>/dev/null || true; umount "$TMP/data" 2>/dev/null || true; rm -rf "$TMP"; } -trap cleanup EXIT mkdir -p "$TMP/boot" "$TMP/data" mount "$p2" "$TMP/boot"; mount "$p3" "$TMP/data" mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference" @@ -69,7 +89,6 @@ if ! cp "$ISO" "$TMP/boot/pc-intake.iso" || ! sync -f "$TMP/boot"; then fi exit 1 fi -# Copy video reference files separately under INTAKE_DATA/reference (optional). cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG' set timeout=5 set default=0 @@ -83,13 +102,6 @@ menuentry 'PC Intake Live USB (offline technician mode)' { menuentry 'Reboot' { reboot } menuentry 'Power off' { halt } GRUBCFG -# Ensure expected kernel layout exists *in the built ISO* before proceeding. -if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/vmlinuz-linux'; then - echo 'ISO layout does not match GRUB config (kernel missing). Aborting bootloader setup.' >&2; exit 1 -fi -if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/initramfs-linux.img'; then - echo 'ISO layout does not match GRUB config (initramfs missing).' >&2; exit 1 -fi grub-install --target=x86_64-efi --efi-directory="$TMP/boot" --boot-directory="$TMP/boot/boot" --removable --no-nvram --recheck grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE" sync diff --git a/scripts/inventory-sync.py b/scripts/inventory-sync.py index 9d3b7dc..2489685 100644 --- a/scripts/inventory-sync.py +++ b/scripts/inventory-sync.py @@ -3,6 +3,7 @@ Stdlib only; server assigns numbers and immutable reports stay unchanged. """ import argparse +import contextlib import datetime as dt import hashlib import time @@ -116,7 +117,8 @@ def credentials(config): base = config.get('url', '') if not base: raise ValueError('Inventory URL is not configured; reports remain offline') parsed = urllib.parse.urlparse(base) - if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + 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)') key = os.environ.get('CSR_API_KEY', '') if not key: @@ -124,7 +126,7 @@ def credentials(config): key = secret.read_text(encoding='utf-8').strip() if not key: raise ValueError('No API key configured') - return base, key + return base.rstrip('/'), key def connection_error(ex): # 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 # Error strings deliberately exclude URLs, API keys and raw server responses. error = connection_error(ex) + if isinstance(ex, urllib.error.HTTPError): + ex.close() delay = min(3600, retry_seconds * (2 ** min(attempts, 6))) with db: db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha)) @@ -347,7 +351,7 @@ def run_station(root,action,report_path): 'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)}) if action == 'intake' and report_path: raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest() - with sqlite3.connect(root/'database/intake.sqlite3') as db: + with contextlib.closing(sqlite3.connect(root/'database/intake.sqlite3')) as db, db: init_db(db);discover_queue(root,db) db.execute("INSERT OR REPLACE INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,'SYNCED',?,'OK')",(sha,report.get('asset_id',''),device['number'],dt.datetime.now(dt.timezone.utc).isoformat())) db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,)) @@ -388,10 +392,12 @@ def main(): return 2 if args.station: 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) + if isinstance(ex, urllib.error.HTTPError): + ex.close() 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) discover_queue(root, db) if args.list: diff --git a/tests/test_inventory_sync.py b/tests/test_inventory_sync.py index 4dbe2d0..d037061 100644 --- a/tests/test_inventory_sync.py +++ b/tests/test_inventory_sync.py @@ -1,4 +1,5 @@ import importlib.util +import contextlib import json from pathlib import Path 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'}]: with self.assertRaises(ValueError): bridge.hardware_id(data) def test_missing_hwid_never_uses_manual_number(self): - with tempfile.TemporaryDirectory() as tmp, sqlite3.connect(':memory:') as db: + with tempfile.TemporaryDirectory() as tmp, contextlib.closing(sqlite3.connect(':memory:')) as db: bridge.init_db(db) path=Path(tmp)/'report.json' path.write_text(json.dumps({'asset_id':'45','model':'Laptop'})) @@ -48,7 +49,7 @@ class Tests(unittest.TestCase): if method=='POST': return {'number':501,'revision':3,'model':'HP EliteBook 745 G6','cpu':'old','ram_gb':8,'ssd_gb':128} return {'revision':4} - with sqlite3.connect(':memory:') as db: + with contextlib.closing(sqlite3.connect(':memory:')) as db: bridge.init_db(db) with patch.object(bridge,'api_request',side_effect=fake), patch.dict('os.environ',{'CSR_API_KEY':'fake'}): res=bridge.sync_one(path,{'url':'https://example.org'},db) @@ -63,7 +64,7 @@ class Tests(unittest.TestCase): def test_mismatch_not_patched(self): with tempfile.TemporaryDirectory() as tmp: path=Path(tmp)/'report.json';path.write_text(json.dumps(self.report)) - with sqlite3.connect(':memory:') as db: + with contextlib.closing(sqlite3.connect(':memory:')) as db: bridge.init_db(db) with patch.object(bridge,'api_request',return_value={'number':501,'revision':1,'model':'Different machine'}),patch.dict('os.environ',{'CSR_API_KEY':'fake'}): with self.assertRaisesRegex(ValueError,'MODEL_MISMATCH'): @@ -172,3 +173,19 @@ class ConnectionTests(unittest.TestCase): self.assertIn('API key', bridge.connection_error(error)) self.assertNotIn('secret', bridge.connection_error(error)) 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})