diff --git a/.agents/skills/read-logs/scripts/logs.py b/.agents/skills/read-logs/scripts/logs.py index e49b7de..bf94df0 100644 --- a/.agents/skills/read-logs/scripts/logs.py +++ b/.agents/skills/read-logs/scripts/logs.py @@ -63,9 +63,9 @@ def make_replay_case(entry, log_dir): manifest = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError as exc: if developer.get("status") != "pending": - raise ValueError("Developer evidence is not saved yet; retry after capture finishes") from exc + raise ValueError("Developer evidence manifest is missing (possibly pruned); use a saved replay case or complete inline inputs") from exc manifest_pending = True - evidence_warning = "Developer evidence is pending; replay uses validated complete inline inputs only" + evidence_warning = "Developer evidence is pending or pruned; replay uses validated complete inline inputs only" if manifest is not None and (not isinstance(manifest, dict) or manifest.get("schema_version") != 1 or manifest.get("run_id") != detail.get("run_id")): raise ValueError("Developer evidence schema or run identity does not match") @@ -82,7 +82,12 @@ def payload(field, required): if required: raise ValueError(f"Incomplete developer evidence: {field}") return None - raw = _contained_path(path.parent, item["path"]).read_bytes() + try: + raw = _contained_path(path.parent, item["path"]).read_bytes() + except FileNotFoundError as exc: + if required: + raise ValueError(f"Missing replay evidence (possibly partially pruned): {field}") from exc + return None if len(raw) != item.get("bytes") or hashlib.sha256(raw).hexdigest() != item.get("sha256"): raise ValueError(f"Developer evidence integrity check failed: {field}") value = raw.decode("utf-8") @@ -94,7 +99,7 @@ def payload(field, required): if (not isinstance(value, str) or detail.get(field + "_truncated") or detail.get(count_key, _utf16_chars(value) if isinstance(value, str) else -1) != _utf16_chars(value or "")): if required: - note = "; developer evidence is pending, retry after capture finishes" if manifest_pending else "" + note = "; developer evidence is pending or pruned; retry pending capture or use a saved replay case" if manifest_pending else "" raise ValueError(f"Missing or truncated replay evidence: {field}{note}") return None return value diff --git a/docs/architecture.md b/docs/architecture.md index f0881ab..bb1b7f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -The product is the native Rust tray app and dashboard under `rust/`. `main.rs` bootstraps Velopack; `app.rs` owns tray/hotkey lifetime; `runtime.rs` runs corrections; `dashboard.rs` owns the GUI; `update.rs` owns serialized update work; `store.rs` and `platform.rs` retain compatible settings, DPAPI keys and legacy-data migration. Channel constants are generated from `src/BuildChannel.cs`. +The product is the native Rust tray app and dashboard under `rust/`. `main.rs` bootstraps Velopack; `app.rs` owns tray/hotkey lifetime; `runtime.rs` runs corrections; `dashboard.rs` owns the GUI; `update.rs` owns serialized update work; `store.rs` and `platform.rs` retain compatible settings, DPAPI keys and legacy-data migration. Channel constants are generated from `src/BuildChannel.cs`. Optional evidence uses `evidence.rs` for capture and `evidence_storage.rs` for bounded storage and oldest-first retention; both channels share its existing named mutex. The sections below document the retained C#/.NET 10 reference under `src/`. It bootstraps via Velopack, registers a global hotkey, and runs a clipboard-capture → API-request → post-process → paste pipeline. AHK is archived at `.archive/ahk-legacy/` and is not part of the active system. @@ -229,7 +229,7 @@ Triggered by `v*.*.*` tags. Steps: checkout → dotnet publish Release win-x64 - Use one app-lifetime native WinHTTP session/connection with automatic proxy discovery and HTTP/2 enabled. Keep each request's body, headers and read buffer alive through `HANDLE_CLOSING`; enforce a 30-second deadline per attempt, matching two attempts and a 500 ms retry interval. - Keep shared stamped log timestamps at event time while writing asynchronously; flush through a bounded barrier on orderly shutdown. - Save the existing named DPAPI key collection with synchronized temporary-file contents and atomic replacement. Preserve corrupt files rather than overwriting them with defaults. -- Keep bounded screenshot and UIA workers off the correction path; preserve the existing cross-channel conservative evidence quota and explicit failure/busy statuses. +- Keep bounded screenshot and UIA workers off the correction path; preserve the existing cross-channel bounded evidence storage with oldest-first Rust retention and explicit failure/busy statuses. - Queue and deduplicate restart requests during update checks. Use the existing updater's `start --waitPid` wire behavior, then let normal startup own the unified update check/install flow. - Keep synthetic C# golden cases under `rust/tests/fixtures/`; do not add real runtime logs, copied selections or credentials to source control. diff --git a/docs/replacements-and-logging.md b/docs/replacements-and-logging.md index ca88a15..d6f25a8 100644 --- a/docs/replacements-and-logging.md +++ b/docs/replacements-and-logging.md @@ -288,8 +288,29 @@ UTF-8 sidecars for `input_text`, `clipboard_html`, `clipboard_rtf`, `clipboard_f `readback_text/html/rtf/formats`. Entries expose relative `path`, SHA-256 `sha256`, UTF-8 `bytes`, .NET UTF-16 `chars`, `complete` and `status`. Empty source markup is complete only when the format list confirms that flavor is absent. An offered-but-empty flavor or an unavailable format list is explicitly incomplete, as are null values. Inline 512K markup caps remain, but the sidecars preserve -larger ordinary selections. A 16MiB per-payload cap and 256MiB total evidence store cap explicitly -return `size_limit` / `storage_limit` instead of deleting existing data. Evidence is local and can +larger ordinary selections. The Rust store retains the 16MiB per-payload cap (`size_limit`) and 256MiB total +cap. The former stop-at-full policy is superseded: when a write needs space, +Rust reconciles the quota counter against actual files, then removes oldest +eligible whole-run folders until the pending write fits near 192MiB. This runs +on the optional evidence worker under the existing shared cross-channel mutex; +it adds no clipboard/UI work or correction-thread waits. Ordinary JSONL logs, +settings, keys, and separately exported replay cases are not rotated. + +Only 32-hex run folders containing known regular evidence files are eligible. +Current runs, incomplete runs modified within 24 hours, backups/custom files, +nested directories, and reparse points are preserved. Incomplete abandoned +runs become eligible after 24 hours. If protected content or an I/O error +prevents reclamation, evidence reports `storage_limit` or `failed`; corrections +continue. `developer_evidence_pruned` records removed run/byte counts; +`developer_evidence_storage_failed` records storage errors. A locked run is skipped +so other runs can be reclaimed; `developer_evidence_storage_pressure` records +blocked-run counts or insufficient reclaimable capacity. Storage warnings are +limited to one per run. A partly pruned run can retain its manifest with missing +payloads; replay reports required missing data explicitly and omits missing optional +payloads. Optional storage workers wait up to 15 seconds for their shared mutex; +the correction thread never waits for this work. Historical manifest +references may expire; export a replay case before preserving a specific incident. +The retained C# reference keeps its historical stop-at-full policy. Evidence is local and can contain selected text and surrounding editor content; API key/settings files are never included. `contexts` links automatic `before-source` and `after-paste` captures with requested/captured/completed @@ -310,7 +331,7 @@ best-effort background STA worker with a 500ms deadline and clipboard sequence c blocks later readback jobs rather than accumulating workers. Absent formats, failures and races are explicit; a successful read proves the clipboard state at its timestamp, not the editor rendering. Evidence admission permits at most two in-flight runs. Shared cross-channel quota reservations -are serialized with a named mutex; a failed write may conservatively overcount space. +are serialized with a named mutex; a failed write may conservatively overcount space until the next pressure-triggered reconciliation. `--developer-evidence-smoke` runs the rebuilt native executable with a disposable RichTextBox, real Ctrl+C/Ctrl+V, enabled readback and automatic images, disabled readback, live settings cache diff --git a/rust/src/evidence.rs b/rust/src/evidence.rs index 1e371e9..8452ec6 100644 --- a/rust/src/evidence.rs +++ b/rust/src/evidence.rs @@ -4,9 +4,7 @@ use crate::{ store::{Store, wide}, }; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use std::{ - fs, path::Path, ptr::{null, null_mut}, sync::{ @@ -70,7 +68,14 @@ impl Evidence { let _ = thread::Builder::new() .name("evidence-save".into()) .spawn(move || { - let result = save_inner(&store, &detail, &self.source, &paste, vec![before, after]); + let result = save_inner( + &store, + &logger, + &detail, + &self.source, + &paste, + vec![before, after], + ); logger.data( "developer_evidence_completed", json!({"run_id":detail["run_id"],"reference":result}), @@ -284,7 +289,14 @@ fn screenshot(hwnd: usize) -> Result, String> { Ok(png) } } -fn quota_write(root: &Path, id: &str, name: &str, bytes: &[u8]) -> Value { +fn quota_write( + root: &Path, + id: &str, + name: &str, + bytes: &[u8], + logger: &Logger, + warned: &mut bool, +) -> Value { if bytes.len() > 16 * 1024 * 1024 { return json!({"status":"size_limit","complete":false}); } @@ -293,61 +305,50 @@ fn quota_write(root: &Path, id: &str, name: &str, bytes: &[u8]) -> Value { if mutex.is_null() { return json!({"status":"storage_busy"}); } - let status = WaitForSingleObject(mutex, 1000); + let status = WaitForSingleObject(mutex, 15_000); if status != WAIT_OBJECT_0 && status != WAIT_ABANDONED { CloseHandle(mutex); return json!({"status":"storage_busy"}); } - let result = (|| -> Result { - fs::create_dir_all(root).map_err(|e| e.to_string())?; - let counter = root.join(".reserved-bytes"); - let used = fs::read_to_string(&counter) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or_else(|| size(root)); - if used + bytes.len() as u64 > 256 * 1024 * 1024 { - return Ok(json!({"status":"storage_limit","complete":false})); - } - fs::write(counter, (used + bytes.len() as u64).to_string()) - .map_err(|e| e.to_string())?; - let directory = root.join(id); - fs::create_dir_all(&directory).map_err(|e| e.to_string())?; - fs::write(directory.join(name), bytes).map_err(|e| e.to_string())?; - Ok( - json!({"path":name,"sha256":format!("{:x}",Sha256::digest(bytes)),"bytes":bytes.len(),"complete":true,"status":"ok"}), - ) - })(); + let result = crate::evidence_storage::write_locked( + root, + id, + name, + bytes, + crate::evidence_storage::STORE_LIMIT, + ); ReleaseMutex(mutex); CloseHandle(mutex); - result.unwrap_or_else(|_| json!({"status":"failed","complete":false})) - } -} -fn size(root: &Path) -> u64 { - let mut total = 0; - let mut count = 0; - let mut pending = vec![root.to_path_buf()]; - while let Some(dir) = pending.pop() { - for entry in fs::read_dir(dir).into_iter().flatten().flatten() { - count += 1; - if count > 20000 { - return 256 * 1024 * 1024; - } - if let Ok(meta) = entry.metadata() { - if meta.is_dir() { - pending.push(entry.path()); - } else { - total += meta.len(); + match result { + Ok((value, rotation)) => { + if rotation.bytes > 0 { + logger.data( + "developer_evidence_pruned", + json!({"runs":rotation.runs,"bytes":rotation.bytes}), + ); } + if (rotation.blocked_runs > 0 || value["status"] == "storage_limit") && !*warned { + logger.data("developer_evidence_storage_pressure", json!({"run_id":id,"status":value["status"],"blocked_runs":rotation.blocked_runs,"stored_bytes":value["stored_bytes"],"required_bytes":value["required_bytes"]})); + *warned = true; + } + value } - if total > 256 * 1024 * 1024 { - return total; + Err(error) => { + if !*warned { + logger.data( + "developer_evidence_storage_failed", + json!({"run_id":id,"error":error.to_string()}), + ); + *warned = true; + } + json!({"status":"failed","complete":false}) } } } - total } fn save_inner( store: &Store, + logger: &Logger, detail: &Value, source: &(usize, u32, String), paste: &(usize, u32, String), @@ -356,6 +357,7 @@ fn save_inner( let id = detail["run_id"].as_str().unwrap_or("unknown"); let root = store.log_directory.join("developer-evidence"); let mut payloads = serde_json::Map::new(); + let mut storage_warned = false; let mut detail = detail.clone(); if let Some(readback) = detail["clipboard_readback"].as_object().cloned() { for (key, value) in readback { @@ -383,7 +385,14 @@ fn save_inner( ] { let result = match detail[name].as_str() { Some(text) => { - let mut v = quota_write(&root, id, &format!("{name}.txt"), text.as_bytes()); + let mut v = quota_write( + &root, + id, + &format!("{name}.txt"), + text.as_bytes(), + logger, + &mut storage_warned, + ); v["chars"] = json!(text.encode_utf16().count()); v } @@ -395,7 +404,14 @@ fn save_inner( for context in contexts { let image = match context.image { Some(rx) => match rx.recv_timeout(Duration::from_millis(1500)) { - Ok(Ok(bytes)) => quota_write(&root, id, &format!("{}.png", context.phase), &bytes), + Ok(Ok(bytes)) => quota_write( + &root, + id, + &format!("{}.png", context.phase), + &bytes, + logger, + &mut storage_warned, + ), Ok(Err(e)) => json!({"status":e}), Err(_) => json!({"status":"timeout"}), }, @@ -409,7 +425,14 @@ fn save_inner( } let manifest = json!({"schema_version":1,"run_id":id,"channel":channel::CHANNEL,"app_version":channel::VERSION,"created_at":now(),"source_target":{"hwnd":source.0,"pid":source.1,"process":source.2},"paste_target":{"hwnd":paste.0,"pid":paste.1,"process":paste.2},"payloads":payloads,"contexts":captured,"clipboard_readback_boundary":detail["clipboard_readback"]["boundary"],"capture_contract":"Native screen pixels and bounded UIA; no rendering-completion claim"}); let bytes = serde_json::to_vec(&manifest).unwrap_or_default(); - let result = quota_write(&root, id, "manifest.json", &bytes); + let result = quota_write( + &root, + id, + "manifest.json", + &bytes, + logger, + &mut storage_warned, + ); json!({"schema_version":1,"manifest_path":format!("developer-evidence/{id}/manifest.json"),"status":result["status"]}) } // Verify before injecting input: a reader after SendInput can lock out the target's paste. diff --git a/rust/src/evidence_storage.rs b/rust/src/evidence_storage.rs new file mode 100644 index 0000000..f3cda40 --- /dev/null +++ b/rust/src/evidence_storage.rs @@ -0,0 +1,474 @@ +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + fs, io, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +pub const STORE_LIMIT: u64 = 256 * 1024 * 1024; +const PAYLOAD_LIMIT: usize = 16 * 1024 * 1024; +const ORPHAN_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const PAYLOADS: &[&str] = &[ + "input_text", + "clipboard_html", + "clipboard_rtf", + "clipboard_formats", + "raw_ai_output", + "output_text", + "paste_text", + "paste_html", + "clipboard_chromium_mime_base64", + "paste_chromium_mime_base64", + "readback_text", + "readback_html", + "readback_rtf", + "readback_formats", + "readback_chromium_mime_base64", +]; + +#[derive(Default, Debug)] +pub struct Rotation { + pub runs: usize, + pub bytes: u64, + pub blocked_runs: usize, +} + +fn ordinary(meta: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if meta.file_attributes() & 0x400 != 0 { + return false; + } // Reparse points may leave the store. + } + !meta.file_type().is_symlink() +} +fn run_id(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn artifact(name: &str) -> bool { + matches!( + name, + "manifest.json" | "before-source.png" | "after-paste.png" + ) || name + .strip_suffix(".txt") + .is_some_and(|s| PAYLOADS.contains(&s)) +} +fn invalid() -> io::Error { + io::Error::other("Unexpected evidence storage path") +} +fn directory(path: &Path) -> io::Result<()> { + let meta = fs::symlink_metadata(path)?; + if !ordinary(&meta) || !meta.is_dir() { + return Err(invalid()); + } + Ok(()) +} + +// Count actual files on pressure, repairing old reservations after failed writes or manual cleanup. +fn size(root: &Path) -> io::Result { + let mut pending = vec![root.to_owned()]; + let mut bytes = 0u64; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + if entry.path() == root.join(".reserved-bytes") { + continue; + } + let meta = entry.metadata()?; + if !ordinary(&meta) { + continue; + } + if meta.is_dir() { + pending.push(entry.path()); + } else { + bytes = bytes.saturating_add(meta.len()); + } + } + } + Ok(bytes) +} + +fn candidate(path: &Path) -> io::Result, u64)>> { + let meta = fs::symlink_metadata(path)?; + if !ordinary(&meta) || !meta.is_dir() { + return Ok(None); + } + let mut files = Vec::new(); + let mut bytes = 0; + let mut modified = meta.modified()?; + let mut complete = false; + for entry in fs::read_dir(path)? { + let entry = entry?; + let meta = entry.metadata()?; + if !ordinary(&meta) || !meta.is_file() || !artifact(&entry.file_name().to_string_lossy()) { + return Ok(None); // Preserve backups, custom files, nested folders, and links. + } + modified = modified.max(meta.modified()?); + complete |= entry.file_name() == "manifest.json"; + bytes += meta.len(); + files.push(entry.path()); + } + if !complete + && SystemTime::now() + .duration_since(modified) + .unwrap_or_default() + < ORPHAN_AGE + { + return Ok(None); // Another process may still be writing a run without a manifest. + } + Ok(Some((modified, files, bytes))) +} + +// Caller holds the shared cross-channel evidence mutex for this entire operation. +pub fn write_locked( + root: &Path, + id: &str, + name: &str, + bytes: &[u8], + limit: u64, +) -> io::Result<(Value, Rotation)> { + let mut rotation = Rotation::default(); + if !run_id(id) || !artifact(name) { + return Err(invalid()); + } + if bytes.len() > PAYLOAD_LIMIT { + return Ok((json!({"status":"size_limit","complete":false}), rotation)); + } + match fs::symlink_metadata(root) { + Ok(_) => directory(root)?, + Err(e) if e.kind() == io::ErrorKind::NotFound => fs::create_dir_all(root)?, + Err(e) => return Err(e), + } + directory(root)?; + let counter = root.join(".reserved-bytes"); + match fs::symlink_metadata(&counter) { + Ok(meta) if ordinary(&meta) && meta.is_file() => (), + Ok(_) => return Err(invalid()), + Err(e) if e.kind() == io::ErrorKind::NotFound => (), + Err(e) => return Err(e), + } + let run = root.join(id); + match fs::symlink_metadata(&run) { + Ok(_) => directory(&run)?, + Err(e) if e.kind() == io::ErrorKind::NotFound => (), + Err(e) => return Err(e), + } + let target = run.join(name); + let replaced = match fs::symlink_metadata(&target) { + Ok(meta) if ordinary(&meta) && meta.is_file() => meta.len(), + Ok(_) => return Err(invalid()), + Err(e) if e.kind() == io::ErrorKind::NotFound => 0, + Err(e) => return Err(e), + }; + let additional = (bytes.len() as u64).saturating_sub(replaced); + let cached = fs::read_to_string(&counter) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let mut used = match cached { + Some(used) => used, + None => size(root)?, + }; + if used.saturating_add(additional) > limit { + used = size(root)?; + if used.saturating_add(additional) > limit { + let mut candidates = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + let name = entry.file_name(); + if name == id || !run_id(&name.to_string_lossy()) { + continue; + } + match candidate(&entry.path()) { + Ok(Some((age, _, _))) => candidates.push((age, entry.path())), + Ok(None) => (), + Err(_) => rotation.blocked_runs += 1, + } + } + candidates.sort_by_key(|(age, path)| (*age, path.clone())); + let target_used = (limit * 3 / 4).saturating_sub(additional); + for (_, path) in candidates { + if used <= target_used { + break; + } + let canonical_root = root.canonicalize()?; + let canonical_path = match path.canonicalize() { + Ok(path) => path, + Err(_) => { + rotation.blocked_runs += 1; + continue; + } + }; + if canonical_path.parent() != Some(canonical_root.as_path()) { + return Err(invalid()); + } + let mut files = match candidate(&path) { + Ok(Some((_, files, _))) => files, + Ok(None) => continue, + Err(_) => { + rotation.blocked_runs += 1; + continue; + } + }; + // Keep the completion marker until every payload is removed, even if a viewer holds one open. + files.sort_by_key(|file| { + file.file_name().is_some_and(|name| name == "manifest.json") + }); + let mut blocked = false; + for file in files { + let bytes = match fs::symlink_metadata(&file) { + Ok(meta) => meta.len(), + Err(_) => { + blocked = true; + break; + } + }; + if fs::remove_file(&file).is_err() { + blocked = true; + break; + } + used = used.saturating_sub(bytes); + rotation.bytes += bytes; + } + if blocked || fs::remove_dir(path).is_err() { + rotation.blocked_runs += 1; + } else { + rotation.runs += 1; + } + } + } + fs::write(&counter, used.to_string())?; + } + if used.saturating_add(additional) > limit { + return Ok(( + json!({"status":"storage_limit","complete":false,"stored_bytes":used,"required_bytes":additional}), + rotation, + )); + } + fs::create_dir_all(&run)?; + // Reserve before writing: a failed write cannot allow concurrent over-allocation. + fs::write(&counter, used.saturating_add(additional).to_string())?; + fs::write(target, bytes)?; + Ok(( + json!({"path":name,"sha256":format!("{:x}",Sha256::digest(bytes)), + "bytes":bytes.len(),"complete":true,"status":"ok"}), + rotation, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let p = std::env::temp_dir() + .join(format!("usc-evidence-retention-{}", crate::store::new_id())); + fs::create_dir_all(&p).unwrap(); + Self(p) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn modified(path: &Path, time: SystemTime) { + #[cfg(windows)] + let file = { + use std::os::windows::fs::OpenOptionsExt; + fs::OpenOptions::new() + .access_mode(0x100) + .custom_flags(0x02000000) + .open(path) + .unwrap() + }; + #[cfg(not(windows))] + let file = fs::File::open(path).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)) + .unwrap(); + } + fn completed(root: &Path, id: &str, bytes: usize) -> PathBuf { + let dir = root.join(id); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join("input_text.txt"), vec![b'x'; bytes]).unwrap(); + fs::write(dir.join("manifest.json"), b"{}").unwrap(); + dir + } + const OLD: &str = "ffffffffffffffffffffffffffffff01"; + const NEW: &str = "00000000000000000000000000000002"; + const ACTIVE: &str = "00000000000000000000000000000003"; + #[test] + fn full_store_evicts_oldest_whole_run_and_next_capture_continues() { + let t = Temp::new(); + let old = completed(&t.0, OLD, 80); + let new = completed(&t.0, NEW, 10); + let time = SystemTime::now() - Duration::from_secs(60); + for e in fs::read_dir(&old).unwrap() { + modified(&e.unwrap().path(), time); + } + modified(&old, time); + let (v, rotation) = write_locked(&t.0, ACTIVE, "input_text.txt", &[b'n'; 20], 100).unwrap(); + assert_eq!(v["status"], "ok"); + assert_eq!(rotation.runs, 1); + assert!(!old.exists()); + assert!(new.join("manifest.json").exists()); + assert_eq!( + fs::read(t.0.join(ACTIVE).join("input_text.txt")).unwrap(), + [b'n'; 20] + ); + assert_eq!( + write_locked(&t.0, ACTIVE, "paste_text.txt", b"next", 100) + .unwrap() + .0["status"], + "ok" + ); + } + #[test] + fn stale_counter_is_repaired_without_deleting_existing_runs() { + let t = Temp::new(); + let old = completed(&t.0, OLD, 10); + fs::write(t.0.join(".reserved-bytes"), "100").unwrap(); + let (v, rotation) = write_locked(&t.0, NEW, "input_text.txt", b"new", 100).unwrap(); + assert_eq!(v["status"], "ok"); + assert_eq!(rotation.runs, 0); + assert!(old.exists()); + assert_eq!( + fs::read_to_string(t.0.join(".reserved-bytes")).unwrap(), + "15" + ); + } + #[test] + fn pressure_preserves_inflight_current_and_backup_content() { + let t = Temp::new(); + let old = completed(&t.0, OLD, 30); + fs::write(old.join("capture.bak"), b"keep").unwrap(); + let inflight = t.0.join(NEW); + fs::create_dir(&inflight).unwrap(); + fs::write(inflight.join("input_text.txt"), [b'x'; 30]).unwrap(); + let current = completed(&t.0, ACTIVE, 30); + let (v, rotation) = write_locked(&t.0, ACTIVE, "paste_text.txt", b"new", 100).unwrap(); + assert_eq!(v["status"], "storage_limit"); + assert_eq!(rotation.runs, 0); + assert!(old.join("capture.bak").exists()); + assert!(inflight.exists()); + assert!(current.exists()); + } + #[test] + fn stale_incomplete_run_can_be_reclaimed_but_custom_folder_is_preserved() { + let t = Temp::new(); + let orphan = t.0.join(OLD); + fs::create_dir(&orphan).unwrap(); + let file = orphan.join("input_text.txt"); + fs::write(&file, [b'x'; 80]).unwrap(); + let stale = SystemTime::now() - ORPHAN_AGE - Duration::from_secs(60); + modified(&file, stale); + modified(&orphan, stale); + let saved = t.0.join("saved-replay"); + fs::create_dir(&saved).unwrap(); + fs::write(saved.join("case.json"), b"{}").unwrap(); + assert_eq!( + write_locked(&t.0, NEW, "input_text.txt", &[b'n'; 30], 100) + .unwrap() + .0["status"], + "ok" + ); + assert!(!orphan.exists()); + assert!(saved.exists()); + } + #[test] + fn rewrites_are_not_double_counted_and_oversized_or_escaping_paths_are_rejected() { + let t = Temp::new(); + write_locked(&t.0, OLD, "input_text.txt", b"first", 100).unwrap(); + write_locked(&t.0, OLD, "input_text.txt", b"again", 100).unwrap(); + assert_eq!( + fs::read_to_string(t.0.join(".reserved-bytes")).unwrap(), + "5" + ); + assert!(write_locked(&t.0, "../escape", "input_text.txt", b"x", 100).is_err()); + assert!(write_locked(&t.0, OLD, "../escape", b"x", 100).is_err()); + assert_eq!( + write_locked( + &t.0, + NEW, + "input_text.txt", + &vec![0; PAYLOAD_LIMIT + 1], + STORE_LIMIT + ) + .unwrap() + .0["status"], + "size_limit" + ); + assert!(!t.0.join(NEW).exists()); + } + #[cfg(windows)] + #[test] + fn locked_oldest_file_does_not_block_other_evictions_or_erase_its_manifest() { + use std::os::windows::fs::OpenOptionsExt; + let t = Temp::new(); + let old = completed(&t.0, OLD, 70); + let new = completed(&t.0, NEW, 60); + fs::write(old.join("paste_text.txt"), [b'p'; 10]).unwrap(); + let time = SystemTime::now() - Duration::from_secs(60); + for file in fs::read_dir(&old).unwrap() { + modified(&file.unwrap().path(), time); + } + modified(&old, time); + let held = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(old.join("paste_text.txt")) + .unwrap(); + let (v, rotation) = write_locked(&t.0, ACTIVE, "input_text.txt", &[b'n'; 20], 100).unwrap(); + assert_eq!(v["status"], "ok"); + assert_eq!(rotation.blocked_runs, 1); + assert!(old.join("manifest.json").exists()); + assert!(!old.join("input_text.txt").exists()); + assert!(rotation.bytes > 0); + assert!(!new.exists()); + drop(held); + } + #[cfg(windows)] + #[test] + fn junctions_are_never_written_through_or_evicted() { + let t = Temp::new(); + let outside = Temp::new(); + let linked = t.0.join(OLD); + let result = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&linked) + .arg(&outside.0) + .output() + .unwrap(); + assert!( + result.status.success(), + "Unable to create isolated junction fixture" + ); + fs::write(outside.0.join("input_text.txt"), [b'x'; 90]).unwrap(); + fs::write(outside.0.join("manifest.json"), b"{}").unwrap(); + assert!(write_locked(&t.0, OLD, "paste_text.txt", b"unsafe", 100).is_err()); + assert_eq!( + write_locked(&t.0, NEW, "input_text.txt", b"new", 100) + .unwrap() + .0["status"], + "ok" + ); + assert!(outside.0.join("manifest.json").exists()); + assert!(!outside.0.join("paste_text.txt").exists()); + fs::remove_dir(&linked).unwrap(); + let linked_root = t.0.join("redirect"); + assert!( + std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&linked_root) + .arg(&outside.0) + .output() + .unwrap() + .status + .success() + ); + assert!(write_locked(&linked_root, ACTIVE, "input_text.txt", b"unsafe", 100).is_err()); + fs::remove_dir(&linked_root).unwrap(); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 144b876..1f2519a 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -13,6 +13,7 @@ mod desktop_bench; mod diff; mod docs_native; mod evidence; +mod evidence_storage; mod http; mod logging; mod platform; diff --git a/tests/test_read_logs.py b/tests/test_read_logs.py index 4499071..e0dbb71 100644 --- a/tests/test_read_logs.py +++ b/tests/test_read_logs.py @@ -221,7 +221,7 @@ def test_pending_missing_manifest_uses_complete_inline_but_rejects_truncated(tmp assert "pending" in case["source"]["evidence_warning"] assert case["detail"]["clipboard_formats"] == "UnicodeText" entry["detail"]["clipboard_html_truncated"] = True - with pytest.raises(ValueError, match="pending, retry"): + with pytest.raises(ValueError, match="pending or pruned"): _reader().make_replay_case(entry, tmp_path) @@ -335,3 +335,38 @@ def test_errors_includes_rust_failures_and_preserves_legacy_events(tmp_path): rows = [json.loads(line) for line in result.stdout.splitlines()] assert len(rows) == 7 assert [row['event'] for row in rows] == [event for event, _ in events[:7]] + + +def test_pruned_manifest_diagnostic_does_not_promise_eventual_availability(tmp_path): + entry = _replay_entry() + entry["detail"]["developer_evidence"] = { + "schema_version": 1, "status": "pending", "manifest_path": "expired/manifest.json"} + case = _reader().make_replay_case(entry, tmp_path) + assert "pruned" in case["source"]["evidence_warning"] + entry["detail"]["clipboard_html_truncated"] = True + try: + _reader().make_replay_case(entry, tmp_path) + except ValueError as error: + assert "pending or pruned" in str(error) + else: + raise AssertionError("Missing full evidence must not accept truncated inline inputs") + entry["detail"]["developer_evidence"]["status"] = "ok" + try: + _reader().make_replay_case(entry, tmp_path) + except ValueError as error: + assert "possibly pruned" in str(error) + else: + raise AssertionError("A missing saved manifest must fail explicitly") + + +def test_partially_pruned_payload_has_explicit_diagnostic(tmp_path): + entry, path, manifest = _developer_entry(tmp_path) + (path.parent / "paste_text.txt").unlink() + assert _reader().make_replay_case(entry, tmp_path)["detail"]["input_text"] == entry["detail"]["input_text"] + (path.parent / "input_text.txt").unlink() + try: + _reader().make_replay_case(entry, tmp_path) + except ValueError as error: + assert "partially pruned" in str(error) + else: + raise AssertionError("Required missing payload must fail explicitly")