//! TRACES: NFR-OPS-1 | NFR-SEC-2 | NFR-SEC-4 | NFR-SEC-5 //! The diagnostics bundle: one file, shown before it is written. //! //! NFR-OPS-1's second sentence asks for "a one-click diagnostics bundle" of //! the log, the schema version, the GPU and driver, and the app version — //! "with an explicit preview-and-consent step before anything leaves the //! device". The log and the crash records have existed since the sink and the //! panic hook landed; what did not exist was any way to hand them over that //! was not `adb pull` and a knowledge of where the state directory is. //! //! # What "leaves the device" means here, and why the step is where it is //! //! Nothing in this module sends anything. There is no endpoint, no upload, //! no queue — `crash.rs` says why, and the reason holds: a transport built //! ahead of the consent is the shape of thing that gets switched on by //! default. What the bundle does is write **one text file** to a place the //! user can find, so that *they* can attach it to a message. That is the //! moment it leaves, and it is theirs. //! //! So the consent step guards the write, not a send. [`Bundle::gather`] reads //! everything into memory and touches no file; [`Bundle::preview`] says what //! was gathered, how much of it, and what was taken out; and only //! [`Bundle::write_to`] puts bytes on disk. A user who reads the preview and //! closes the panel has changed nothing anywhere. The interface has to keep //! those as two presses, and does. //! //! # One file, and text //! //! A directory is a thing to zip and a zip is a thing to explain. A single //! `.txt` opens on every platform the user might be sitting at, pastes into //! an issue, and can be read *in the preview* as exactly the bytes that will //! be written — which is what makes the consent honest rather than a summary //! of something else. The sections are separated by a fence no log line can //! produce, so a reader can find the seams and a tool could split them. //! //! # Redacted again, on the way in //! //! Every line here has already been through a redaction: the log at its sink //! ([`super::redact`]), the crash records when they were composed //! ([`crate::crash::redact`]). The bundle runs the *blunter* of the two over //! all of it regardless. The log's own rule keeps file paths, because a path //! in the log a developer reads over `adb` is context; the bundle is a file //! meant to be attached to a public report by someone who may not read it //! first, and a directory listing of their photographs is the thing the //! preview exists to prevent. Redacting twice costs nothing and makes the //! promise in the preview a property of this module rather than of the //! modules upstream having done their part. //! //! NFR-SEC-5 needs no work here and gets a tag anyway: no face data can reach //! this bundle because nothing in `dr-plat` can see a catalog, an embedding //! or a crop. The tag records that the property was checked, not that it was //! built. use std::fmt::Write as _; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::crash; /// The facts the requirement names beside the log, supplied by the caller /// because none of them is this crate's to know: the running version, the /// catalog schema, and what the GPU said it was. #[derive(Debug, Clone, Default)] pub struct Facts { pub app_version: String, pub schema_version: i64, /// Adapter, backend, and driver, in whatever words the GPU gave. pub graphics: String, } /// One section of the bundle: a named piece of text and how big it is. #[derive(Debug, Clone)] pub struct Item { /// What the section is called in the file — a filename, or `manifest`. pub name: String, pub lines: usize, pub bytes: usize, text: String, } /// Everything the bundle would write, gathered and held in memory. #[derive(Debug, Clone)] pub struct Bundle { when: u64, items: Vec, } /// The fence between sections. Long enough that no redacted log line is it. const FENCE: &str = "================================================================"; impl Bundle { /// Gather the log, its rotated predecessor, and every crash record on /// disk, redacted. Reads files; writes none. pub fn gather(facts: &Facts) -> Self { let log = super::log_path(); let previous = log .as_deref() .and_then(Path::parent) .map(|dir| dir.join(format!("{}.1", super::FILE_NAME))); Self::from_sources( facts, now(), log.as_deref(), previous.as_deref(), &crash::records(), ) } /// The gathering, with every path handed in — so a test can build a /// bundle from a directory of its own without touching the process-wide /// log. pub fn from_sources( facts: &Facts, when: u64, log: Option<&Path>, previous: Option<&Path>, crashes: &[PathBuf], ) -> Self { let mut items = vec![manifest(facts, when, log)]; for path in [log, previous].into_iter().flatten() { if let Some(item) = read_item(path) { items.push(item); } } for path in crashes { if let Some(item) = read_item(path) { items.push(item); } } Self { when, items } } pub fn items(&self) -> &[Item] { &self.items } /// The name the file is written under: stamped, so two bundles from one /// machine do not overwrite each other, and matching the crash records' /// convention so a directory of both sorts by time. pub fn file_name(&self) -> String { format!("darkroom-diagnostics-{}.txt", self.when) } /// What the user reads before deciding. Every claim in it is a property /// of [`Self::render`], which is the same items in the same order. pub fn preview(&self, destination: &Path) -> String { let mut out = String::new(); let _ = writeln!(out, "Nothing has been written yet. This is what would be:"); let _ = writeln!(out); for item in &self.items { let _ = writeln!( out, " {:<32} {:>7} lines {}", item.name, item.lines, size(item.bytes) ); } let total: usize = self.items.iter().map(|i| i.bytes).sum(); let _ = writeln!(out); let _ = writeln!( out, "Credentials, tokens, and anything that reads as a file path or a web \ address have been replaced in every line; the names of Rust source \ files in a backtrace are kept. No photograph, thumbnail, face or \ person is in it — nothing here can read the catalog." ); let _ = writeln!(out); let _ = writeln!( out, "Saving writes one text file, {} ({}), to:\n {}\n\ It is sent nowhere. Attaching it to a report is yours to do.", self.file_name(), size(total), destination.display() ); out } /// The whole bundle as the text that would be written. pub fn render(&self) -> String { let mut out = String::new(); for item in &self.items { let _ = writeln!(out, "{FENCE}"); let _ = writeln!(out, "== {}", item.name); let _ = writeln!(out, "{FENCE}"); out.push_str(&item.text); if !item.text.ends_with('\n') { out.push('\n'); } out.push('\n'); } out } /// Write the bundle into `dir`, creating it, and return the file's path. /// The only function here that writes. pub fn write_to(&self, dir: &Path) -> io::Result { fs::create_dir_all(dir)?; let path = dir.join(self.file_name()); fs::write(&path, self.render())?; Ok(path) } } /// Where a bundle is written when nobody says otherwise. /// /// The desktop gets the downloads directory — the one place a file can be /// put that every "attach a file" dialog opens on, and that the user already /// knows how to find and how to clear. `XDG_DOWNLOAD_DIR` is only in /// `user-dirs.dirs`, which nothing here parses, so the conventional name /// under the home directory stands in for it; failing a home, the state /// directory, which always exists by the time this can be called. /// /// Android has no downloads directory an app may write without a permission /// prompt, and the state directory there *is* the externally readable one — /// `adb pull` and the files app both reach it — so it is used directly. pub fn default_dir() -> PathBuf { if cfg!(target_os = "android") { return crate::state::state_dir(); } std::env::var_os("HOME") .map(|home| PathBuf::from(home).join("Downloads")) .filter(|d| d.is_dir()) .unwrap_or_else(crate::state::state_dir) } /// The section that carries what the requirement asks for beside the log. /// First, so the version is the first thing anyone reading the file sees. fn manifest(facts: &Facts, when: u64, log: Option<&Path>) -> Item { let mut text = String::new(); let _ = writeln!(text, "darkroom {}", facts.app_version); let _ = writeln!(text, "catalog schema {}", facts.schema_version); let _ = writeln!(text, "graphics {}", facts.graphics); let _ = writeln!( text, "platform {} {}", std::env::consts::OS, std::env::consts::ARCH ); let _ = writeln!(text, "gathered {when} (unix seconds, UTC)"); let _ = writeln!( text, "log {}", match log { // The basename only: the directory is a path, and the manifest // is held to the same rule as everything under it. Some(p) => p.file_name().map_or_else( || "present".to_string(), |n| n.to_string_lossy().into_owned() ), None => "none — this session logs to the console only".to_string(), } ); let _ = writeln!( text, "log cap {} bytes per file, {} rotated file kept", super::MAX_FILE_BYTES, super::RETAINED_GENERATIONS ); item("manifest", text) } /// A file on disk as a redacted section, or nothing if it cannot be read — /// an unreadable log is reported in the preview by its absence, which is the /// truthful thing, rather than by a bundle that fails to build. fn read_item(path: &Path) -> Option { let raw = fs::read(path).ok()?; let name = path.file_name()?.to_string_lossy().into_owned(); let text = String::from_utf8_lossy(&raw); let text: String = text .lines() .map(crash::redact) .collect::>() .join("\n"); Some(item(&name, text)) } fn item(name: &str, text: String) -> Item { Item { name: name.to_string(), lines: text.lines().count(), bytes: text.len(), text, } } fn size(bytes: usize) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{} KB", bytes / 1024) } else { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } } fn now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; fn facts() -> Facts { Facts { app_version: "0.12.0".into(), schema_version: 14, graphics: "Test GPU (VULKAN) driver 1.0".into(), } } fn dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("dr-bundle-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); dir } /// The property the consent rests on: the preview describes the file, /// and building the preview writes nothing. #[test] fn gathering_and_previewing_write_nothing() { let d = dir("preview"); let log = d.join("darkroom.log"); fs::write(&log, "one\ntwo\n").unwrap(); let before = fs::read_dir(&d).unwrap().flatten().count(); let bundle = Bundle::from_sources(&facts(), 1_700_000_000, Some(&log), None, &[]); let preview = bundle.preview(&d); assert!(preview.contains("darkroom.log"), "{preview}"); assert!(preview.contains("2 lines"), "{preview}"); assert!( preview.contains("Nothing has been written yet"), "{preview}" ); assert_eq!( fs::read_dir(&d).unwrap().flatten().count(), before, "the preview put a file on disk" ); } /// And the write is one file, named as the preview said, holding the /// sections the preview listed in the order it listed them. #[test] fn saving_writes_one_file_with_every_section() { let d = dir("save"); let log = d.join("darkroom.log"); let previous = d.join("darkroom.log.1"); let crash = d.join("crash-1700000000-1.txt"); fs::write(&log, "current\n").unwrap(); fs::write(&previous, "older\n").unwrap(); fs::write(&crash, "panicked at library.rs:12\n").unwrap(); let bundle = Bundle::from_sources( &facts(), 1_700_000_001, Some(&log), Some(&previous), &[crash], ); let out = d.join("out"); let written = bundle.write_to(&out).unwrap(); assert_eq!( written.file_name().unwrap(), "darkroom-diagnostics-1700000001.txt" ); assert_eq!(fs::read_dir(&out).unwrap().flatten().count(), 1); let text = fs::read_to_string(&written).unwrap(); let names: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix("== ")).collect(); assert_eq!( names, [ "manifest", "darkroom.log", "darkroom.log.1", "crash-1700000000-1.txt" ] ); assert!(text.contains("darkroom 0.12.0"), "{text}"); assert!(text.contains("catalog schema 14"), "{text}"); assert!(text.contains("Test GPU"), "{text}"); assert!( text.contains("library.rs:12"), "a backtrace keeps its source names" ); } /// The redaction is this module's promise, not an assumption about the /// files it read: a path or a secret that reached the log unredacted /// still does not reach the bundle. #[test] fn a_path_in_the_log_does_not_reach_the_bundle() { let d = dir("redact"); let log = d.join("darkroom.log"); fs::write( &log, "opened /home/someone/Photos/2024/wedding/IMG_0001.CR3\npassword=hunter2hunter2\n", ) .unwrap(); let bundle = Bundle::from_sources(&facts(), 1, Some(&log), None, &[]); let text = bundle.render(); assert!(!text.contains("wedding"), "{text}"); assert!(!text.contains("hunter2"), "{text}"); assert!( text.contains("opened"), "the rest of the line survives: {text}" ); } /// A session logging to the console only still gets a bundle — the /// manifest and the crash records — and the manifest says the log is /// missing rather than the bundle failing to build. #[test] fn no_log_is_a_bundle_that_says_so() { let bundle = Bundle::from_sources(&facts(), 1, None, None, &[]); assert_eq!(bundle.items().len(), 1); assert!(bundle.render().contains("console only")); } }