From dd922544406e309943f7aea646cca70992a22fc0 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Thu, 8 Oct 2026 03:57:07 +0200 Subject: [PATCH 1/2] Make updater installation recoverable across component failures --- .github/workflows/windows-ci.yml | 9 + app/updater/src-tauri/Cargo.lock | 2 +- app/updater/src-tauri/Cargo.toml | 2 +- app/updater/src-tauri/src/apply.rs | 95 +--- app/updater/src-tauri/src/install.rs | 696 ++++++++++++++++++++++++++ app/updater/src-tauri/src/main.rs | 205 +++++--- app/updater/src-tauri/src/versions.rs | 19 +- app/updater/src-tauri/tauri.conf.json | 2 +- docs/DECISIONS.md | 10 + docs/audit/2026-10-08-followup.md | 31 ++ docs/audit/2026-10-08.md | 4 + docs/handover/ACTIVE_HANDOVER.md | 10 + tests/rust/src/lib.rs | 3 + 13 files changed, 920 insertions(+), 168 deletions(-) create mode 100644 app/updater/src-tauri/src/install.rs create mode 100644 docs/audit/2026-10-08-followup.md diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 9edd8f6..91a776e 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -10,6 +10,15 @@ permissions: packages: write jobs: + updater: + name: Test updater recovery on Windows + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --locked --no-default-features --manifest-path tests/rust/Cargo.toml + build: name: Build Monolith for Windows runs-on: windows-latest diff --git a/app/updater/src-tauri/Cargo.lock b/app/updater/src-tauri/Cargo.lock index f1d8a9d..51f5ac9 100644 --- a/app/updater/src-tauri/Cargo.lock +++ b/app/updater/src-tauri/Cargo.lock @@ -2161,7 +2161,7 @@ dependencies = [ [[package]] name = "monolith-updater" -version = "1.0.1" +version = "1.0.2" dependencies = [ "base64 0.22.1", "ed25519-dalek", diff --git a/app/updater/src-tauri/Cargo.toml b/app/updater/src-tauri/Cargo.toml index 1fa421f..842ae09 100644 --- a/app/updater/src-tauri/Cargo.toml +++ b/app/updater/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "monolith-updater" -version = "1.0.1" +version = "1.0.2" edition = "2021" [build-dependencies] diff --git a/app/updater/src-tauri/src/apply.rs b/app/updater/src-tauri/src/apply.rs index c43d5fb..37f34a6 100644 --- a/app/updater/src-tauri/src/apply.rs +++ b/app/updater/src-tauri/src/apply.rs @@ -1,98 +1,5 @@ use std::fs; use std::path::Path; -use windows::core::PCWSTR; -use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING}; - -fn wide(path: &Path) -> Vec { - path.as_os_str() - .to_string_lossy() - .encode_utf16() - .chain(std::iter::once(0)) - .collect() -} - -fn to_old(path: &Path) -> Result<(), String> { - // Park replaced binaries as .old; retry deletion after their users exit. - let old = old_path(path); - unsafe { - MoveFileExW( - PCWSTR(wide(path).as_ptr()), - PCWSTR(wide(&old).as_ptr()), - MOVEFILE_REPLACE_EXISTING, - ) - .map_err(|e| format!("park {}: {e}", path.display()))?; - } - Ok(()) -} - -fn old_path(path: &Path) -> std::path::PathBuf { - let mut name = path - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - name.push(".old"); - path.with_file_name(name) -} - -fn place_file(staged: &Path, target: &Path) -> Result<(), String> { - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - let replaced = target.exists(); - if replaced { - to_old(target)?; - } - if fs::rename(staged, target).is_err() { - // Fall back to copy when the staging rename fails. - if let Err(error) = fs::copy(staged, target) { - if replaced { - unsafe { - MoveFileExW( - PCWSTR(wide(&old_path(target)).as_ptr()), - PCWSTR(wide(target).as_ptr()), - MOVEFILE_REPLACE_EXISTING, - ) - .map_err(|restore| { - format!( - "place {}: {error}; restore failed: {restore}", - target.display() - ) - })?; - } - } else { - let _ = fs::remove_file(target); - } - return Err(format!("place {}: {error}", target.display())); - } - let _ = fs::remove_file(staged); - } - Ok(()) -} - -/// Replace files present in the staged component; leave unrelated destination files intact. -pub fn place_tree(src: &Path, dest: &Path) -> Result<(), String> { - place_tree_rec(src, src, dest) -} - -fn place_tree_rec(root: &Path, src: &Path, dest: &Path) -> Result<(), String> { - for entry in fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))? { - let entry = entry.map_err(|e| format!("read {}: {e}", src.display()))?; - let path = entry.path(); - if path.is_dir() { - place_tree_rec(root, &path, dest)?; - } else { - let rel = path.strip_prefix(root).map_err(|e| e.to_string())?; - place_file(&path, &dest.join(rel))?; - } - } - Ok(()) -} - -/// Park the running updater image and place its replacement at the installed path. -pub fn self_swap(new_exe: &Path) -> Result<(), String> { - let current = std::env::current_exe().map_err(|e| format!("self path: {e}"))?; - place_file(new_exe, ¤t) -} /// Retry parked-file cleanup on later launches if files remain locked. pub fn sweep_old(app_dir: &Path) { @@ -103,7 +10,7 @@ pub fn sweep_old(app_dir: &Path) { }; for entry in entries.flatten() { let p = entry.path(); - if p.extension().map(|e| e == "old").unwrap_or(false) { + if p.extension().is_some_and(|e| e == "old") { let _ = fs::remove_file(&p); } } diff --git a/app/updater/src-tauri/src/install.rs b/app/updater/src-tauri/src/install.rs new file mode 100644 index 0000000..35b9e17 --- /dev/null +++ b/app/updater/src-tauri/src/install.rs @@ -0,0 +1,696 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +const DIR: &str = ".update-transaction"; +const MAX_JOURNAL: u64 = 4 * 1024 * 1024; + +#[derive(Clone, Copy, Deserialize, Serialize, PartialEq)] +enum Phase { + Prepared, + Committed, + RolledBack, +} + +#[derive(Deserialize, Serialize)] +struct Entry { + path: PathBuf, + prior: Option, + next: String, +} + +#[derive(Deserialize, Serialize)] +struct Journal { + schema: u32, + phase: Phase, + files: Vec, +} + +pub struct Transaction { + root: PathBuf, + journal: Journal, +} + +fn plain(path: &Path) -> Result, String> { + let m = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("inspect {}: {e}", path.display())), + }; + #[cfg(windows)] + let link = { + use std::os::windows::fs::MetadataExt; + m.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let link = m.file_type().is_symlink(); + if link || (!m.is_file() && !m.is_dir()) { + return Err(format!("unsupported filesystem entry: {}", path.display())); + } + Ok(Some(m)) +} + +fn relative(path: &Path) -> Result<(), String> { + if path.as_os_str().is_empty() || path.components().count() > 32 { + return Err("invalid update path".into()); + } + for part in path.components() { + let Component::Normal(name) = part else { + return Err("non-relative update path".into()); + }; + let name = name.to_string_lossy(); + let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase(); + if name.contains([':', '\\', '*', '?', '"', '<', '>', '|']) + || name.ends_with(['.', ' ']) + || name.eq_ignore_ascii_case(DIR) + || ["CON", "PRN", "AUX", "NUL"].contains(&stem.as_str()) + || (stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) + { + return Err(format!("unsupported update path: {}", path.display())); + } + } + Ok(()) +} + +fn target(root: &Path, rel: &Path) -> Result { + relative(rel)?; + let mut path = root.to_path_buf(); + for part in rel.components() { + path.push(part); + plain(&path)?; + } + Ok(path) +} + +fn digest(path: &Path) -> Result { + if !plain(path)?.is_some_and(|m| m.is_file()) { + return Err(format!("expected regular file: {}", path.display())); + } + let mut f = File::open(path).map_err(|e| format!("read {}: {e}", path.display()))?; + let mut h = Sha256::new(); + let mut buf = [0; 64 * 1024]; + loop { + let n = f.read(&mut buf).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + h.update(&buf[..n]); + } + Ok(format!("{:x}", h.finalize())) +} + +fn verify(path: &Path, hash: &str) -> Result<(), String> { + if digest(path)? != hash { + return Err(format!("file changed during update: {}", path.display())); + } + Ok(()) +} + +fn sync_dir(path: &Path) -> Result<(), String> { + #[cfg(not(windows))] + File::open(path) + .and_then(|f| f.sync_all()) + .map_err(|e| e.to_string())?; + #[cfg(windows)] + let _ = path; + Ok(()) +} + +fn mkdir(path: &Path) -> Result<(), String> { + if let Some(m) = plain(path)? { + return if m.is_dir() { + Ok(()) + } else { + Err(format!("not a directory: {}", path.display())) + }; + } + if let Some(parent) = path.parent() { + mkdir(parent)?; + fs::create_dir(path).map_err(|e| format!("mkdir {}: {e}", path.display()))?; + sync_dir(parent)?; + } + Ok(()) +} + +fn move_file(src: &Path, dest: &Path, replace: bool) -> Result<(), String> { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let wide = |p: &Path| { + p.as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>() + }; + let s = wide(src); + let d = wide(dest); + let flags = if replace { + MOVEFILE_WRITE_THROUGH | MOVEFILE_REPLACE_EXISTING + } else { + MOVEFILE_WRITE_THROUGH + }; + unsafe { MoveFileExW(PCWSTR(s.as_ptr()), PCWSTR(d.as_ptr()), flags) } + .map_err(|e| e.to_string())?; + } + #[cfg(not(windows))] + { + if !replace && plain(dest)?.is_some() { + return Err(format!("destination exists: {}", dest.display())); + } + fs::rename(src, dest).map_err(|e| format!("move {}: {e}", src.display()))?; + sync_dir(src.parent().ok_or("missing source parent")?)?; + sync_dir(dest.parent().ok_or("missing destination parent")?)?; + } + Ok(()) +} + +fn save(root: &Path, journal: &Journal) -> Result<(), String> { + let bytes = serde_json::to_vec(journal).map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_JOURNAL { + return Err("update journal is too large".into()); + } + let dir = root.join(DIR); + let tmp = dir.join("journal.tmp"); + if plain(&tmp)?.is_some() { + fs::remove_file(&tmp).map_err(|e| e.to_string())?; + } + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| e.to_string())?; + f.write_all(&bytes) + .and_then(|_| f.sync_all()) + .map_err(|e| e.to_string())?; + drop(f); + move_file(&tmp, &dir.join("journal.json"), true) +} + +fn load(root: &Path) -> Result, String> { + let path = root.join(DIR).join("journal.json"); + if plain(&path)?.is_none() { + return Ok(None); + } + let mut bytes = Vec::new(); + File::open(&path) + .map_err(|e| e.to_string())? + .take(MAX_JOURNAL + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_JOURNAL { + return Err("update journal is too large".into()); + } + let journal: Journal = + serde_json::from_slice(&bytes).map_err(|e| format!("update journal: {e}"))?; + if journal.schema != 1 || journal.files.is_empty() || journal.files.len() > 12289 { + return Err("invalid update journal".into()); + } + let mut names = HashSet::new(); + for e in &journal.files { + relative(&e.path)?; + if !names.insert(e.path.to_string_lossy().to_lowercase()) { + return Err("duplicate update path".into()); + } + for hash in std::iter::once(&e.next).chain(e.prior.iter()) { + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("invalid journal hash".into()); + } + } + } + Ok(Some(journal)) +} + +fn cleanup(root: &Path) { + // Loaded parked binaries can retain this directory until the next launch. + let _ = fs::remove_dir_all(root.join(DIR)); +} + +pub fn pending(root: &Path) -> Result { + if let Some(m) = plain(&root.join(DIR))? { + if !m.is_dir() { + return Err("invalid transaction directory".into()); + } + } else { + return Ok(false); + } + Ok(load(root)?.is_some_and(|j| j.phase == Phase::Prepared)) +} + +pub fn recover(root: &Path) -> Result<(), String> { + match plain(&root.join(DIR))? { + None => return Ok(()), + Some(m) if m.is_dir() => {} + _ => return Err("invalid transaction directory".into()), + } + let Some(mut j) = load(root)? else { + cleanup(root); + return Ok(()); + }; + if j.phase != Phase::Prepared { + cleanup(root); + return Ok(()); + } + let dir = root.join(DIR); + let discard = dir.join("discard").join(format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos() + )); + for e in j.files.iter().rev() { + let installed = target(root, &e.path)?; + let backup = target(&dir.join("backup"), &e.path)?; + if let Some(prior) = &e.prior { + if plain(&backup)?.is_some() { + verify(&backup, prior)?; + if plain(&installed)?.is_some() { + verify(&installed, &e.next)?; + let parked = discard.join(&e.path); + mkdir(parked.parent().ok_or("missing discard parent")?)?; + move_file(&installed, &parked, false)?; + } + move_file(&backup, &installed, false)?; + } else { + verify(&installed, prior)?; + } + } else if plain(&dir.join("new").join(&e.path))?.is_none() { + if plain(&installed)?.is_some() { + verify(&installed, &e.next)?; + let parked = discard.join(&e.path); + mkdir(parked.parent().ok_or("missing discard parent")?)?; + move_file(&installed, &parked, false)?; + } + } else if plain(&installed)?.is_some() { + return Err(format!( + "unexpected recovery target: {}", + installed.display() + )); + } + } + j.phase = Phase::RolledBack; + save(root, &j)?; + cleanup(root); + Ok(()) +} + +impl Transaction { + pub fn prepare(root: &Path, files: &[(PathBuf, PathBuf)]) -> Result { + if !plain(root)?.is_some_and(|m| m.is_dir()) { + return Err("app directory is unavailable".into()); + } + recover(root)?; + let dir = root.join(DIR); + if plain(&dir)?.is_some() { + return Err( + "parked update files are still in use; close Monolith and restart the updater" + .into(), + ); + } + if files.is_empty() || files.len() > 12289 { + return Err("invalid update file count".into()); + } + let mut names = HashSet::new(); + let mut entries = Vec::new(); + for (src, rel) in files { + let dest = target(root, rel)?; + if !names.insert(rel.to_string_lossy().to_lowercase()) { + return Err("duplicate update path".into()); + } + let prior = if plain(&dest)?.is_some() { + Some(digest(&dest)?) + } else { + None + }; + entries.push(Entry { + path: rel.clone(), + prior, + next: digest(src)?, + }); + } + mkdir(&dir)?; + for ((src, rel), e) in files.iter().zip(&entries) { + let new = dir.join("new").join(rel); + mkdir(new.parent().ok_or("missing staged parent")?)?; + let mut input = File::open(src).map_err(|e| e.to_string())?; + let mut out = OpenOptions::new() + .write(true) + .create_new(true) + .open(&new) + .map_err(|e| e.to_string())?; + std::io::copy(&mut input, &mut out) + .and_then(|_| out.sync_all()) + .map_err(|e| e.to_string())?; + drop(out); + verify(&new, &e.next)?; + } + let j = Journal { + schema: 1, + phase: Phase::Prepared, + files: entries, + }; + save(root, &j)?; + Ok(Self { + root: root.into(), + journal: j, + }) + } + + pub fn abort(self) -> Result<(), String> { + recover(&self.root) + } + + pub fn install(mut self) -> Result<(), String> { + if let Err(error) = self.place(&mut |_| Ok(())) { + return match recover(&self.root) { + Ok(()) => Err(error), + Err(restore) => Err(format!("{error}; recovery required: {restore}")), + }; + } + self.journal.phase = Phase::Committed; + if let Err(e) = save(&self.root, &self.journal) { + recover(&self.root).map_err(|restore| format!("{e}; recovery required: {restore}"))?; + return Err(e); + } + cleanup(&self.root); + Ok(()) + } + + fn place( + &self, + checkpoint: &mut impl FnMut(usize) -> Result<(), String>, + ) -> Result<(), String> { + let dir = self.root.join(DIR); + let mut step = 0; + for e in &self.journal.files { + let dest = target(&self.root, &e.path)?; + let new = target(&dir.join("new"), &e.path)?; + verify(&new, &e.next)?; + mkdir(dest.parent().ok_or("missing destination parent")?)?; + if let Some(prior) = &e.prior { + verify(&dest, prior)?; + let backup = dir.join("backup").join(&e.path); + mkdir(backup.parent().ok_or("missing backup parent")?)?; + move_file(&dest, &backup, false)?; + step += 1; + checkpoint(step)?; + } + move_file(&new, &dest, false)?; + step += 1; + checkpoint(step)?; + } + Ok(()) + } +} + +pub fn files(src: &Path, prefix: &Path) -> Result, String> { + fn walk( + root: &Path, + src: &Path, + prefix: &Path, + out: &mut Vec<(PathBuf, PathBuf)>, + ) -> Result<(), String> { + if !plain(src)?.is_some_and(|m| m.is_dir()) { + return Err("component directory is unavailable".into()); + } + for entry in fs::read_dir(src).map_err(|e| e.to_string())? { + let p = entry.map_err(|e| e.to_string())?.path(); + let m = plain(&p)?.ok_or("component entry disappeared")?; + if m.is_dir() { + walk(root, &p, prefix, out)?; + } else { + let rel = prefix.join(p.strip_prefix(root).map_err(|e| e.to_string())?); + relative(&rel)?; + out.push((p, rel)); + } + } + Ok(()) + } + let mut out = Vec::new(); + walk(src, src, prefix, &mut out)?; + out.sort_by(|a, b| a.1.cmp(&b.1)); + if out.is_empty() { + return Err("component is empty".into()); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + + struct Fixture { + root: PathBuf, + stage: PathBuf, + plan: Vec<(PathBuf, PathBuf)>, + } + impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "monolith-install-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + mkdir(&root).unwrap(); + let stage = root.join("stage"); + mkdir(&stage).unwrap(); + let mut plan = Vec::new(); + for name in [ + "Monolith.exe", + "ui/UI.exe", + "Updater.exe", + "new.dll", + "components.json", + ] { + let rel = PathBuf::from(name); + mkdir(stage.join(&rel).parent().unwrap()).unwrap(); + fs::write(stage.join(&rel), format!("new-{name}")).unwrap(); + if name != "new.dll" { + mkdir(root.join(&rel).parent().unwrap()).unwrap(); + fs::write(root.join(&rel), format!("old-{name}")).unwrap(); + } + plan.push((stage.join(&rel), rel)); + } + fs::write(root.join("unrelated.json"), b"user settings").unwrap(); + Self { root, stage, plan } + } + fn original(&self) { + for (_, rel) in &self.plan { + if rel == Path::new("new.dll") { + assert!(!self.root.join(rel).exists()); + } else { + assert_eq!( + fs::read_to_string(self.root.join(rel)).unwrap(), + format!("old-{}", rel.to_str().unwrap().replace('\\', "/")) + ); + } + } + assert_eq!( + fs::read(self.root.join("unrelated.json")).unwrap(), + b"user settings" + ); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + #[test] + fn every_interrupted_move_restores_all_components_and_versions() { + for fail_at in 1..=9 { + let f = Fixture::new(); + let tx = Transaction::prepare(&f.root, &f.plan).unwrap(); + assert!(tx + .place(&mut |step| if step == fail_at { + Err("interrupted".into()) + } else { + Ok(()) + }) + .is_err()); + drop(tx); + recover(&f.root).unwrap(); + recover(&f.root).unwrap(); + f.original(); + assert!(f.stage.join("Monolith.exe").is_file()); + } + } + + #[test] + fn late_placement_failure_rolls_back_the_whole_install() { + let f = Fixture::new(); + let tx = Transaction::prepare(&f.root, &f.plan).unwrap(); + fs::remove_file(f.root.join(DIR).join("new/components.json")).unwrap(); + assert!(tx.install().is_err()); + f.original(); + } + + #[test] + fn interrupted_recovery_can_resume_without_overwriting_foreign_data() { + let f = Fixture::new(); + let tx = Transaction::prepare(&f.root, &f.plan).unwrap(); + tx.place(&mut |_| Ok(())).unwrap(); + fs::write(f.root.join("Monolith.exe"), b"external replacement").unwrap(); + assert!(recover(&f.root).is_err()); + assert_eq!( + fs::read(f.root.join("Monolith.exe")).unwrap(), + b"external replacement" + ); + fs::write(f.root.join("Monolith.exe"), b"new-Monolith.exe").unwrap(); + recover(&f.root).unwrap(); + f.original(); + } + + #[test] + fn committed_install_and_abort_have_truthful_metadata() { + let f = Fixture::new(); + Transaction::prepare(&f.root, &f.plan) + .unwrap() + .abort() + .unwrap(); + f.original(); + Transaction::prepare(&f.root, &f.plan) + .unwrap() + .install() + .unwrap(); + recover(&f.root).unwrap(); + for (_, rel) in &f.plan { + assert_eq!( + fs::read_to_string(f.root.join(rel)).unwrap(), + format!("new-{}", rel.to_str().unwrap().replace('\\', "/")) + ); + } + assert_eq!( + fs::read(f.root.join("unrelated.json")).unwrap(), + b"user settings" + ); + } + + #[test] + fn committed_journal_survives_restart_without_rollback() { + let f = Fixture::new(); + let mut tx = Transaction::prepare(&f.root, &f.plan).unwrap(); + tx.place(&mut |_| Ok(())).unwrap(); + tx.journal.phase = Phase::Committed; + save(&f.root, &tx.journal).unwrap(); + drop(tx); + assert!(!pending(&f.root).unwrap()); + recover(&f.root).unwrap(); + for (_, rel) in &f.plan { + assert_eq!( + fs::read_to_string(f.root.join(rel)).unwrap(), + format!("new-{}", rel.to_str().unwrap().replace('\\', "/")) + ); + } + } + + #[test] + fn unfinished_preparation_does_not_change_installed_files() { + let f = Fixture::new(); + mkdir(&f.root.join(DIR).join("new")).unwrap(); + fs::write(f.root.join(DIR).join("new/orphan"), b"partial copy").unwrap(); + assert!(!pending(&f.root).unwrap()); + recover(&f.root).unwrap(); + f.original(); + Transaction::prepare(&f.root, &f.plan) + .unwrap() + .abort() + .unwrap(); + f.original(); + } + + #[test] + fn malformed_journal_and_case_aliases_are_rejected() { + let f = Fixture::new(); + let mut plan = f.plan.clone(); + plan.push((f.stage.join("Monolith.exe"), "MONOLITH.EXE".into())); + assert!(Transaction::prepare(&f.root, &plan).is_err()); + for path in [ + "../outside", + "/outside", + "a:stream", + "CON.txt", + "a.", + ".update-transaction/a", + ] { + assert!( + Transaction::prepare(&f.root, &[(f.stage.join("Monolith.exe"), path.into())]) + .is_err() + ); + } + mkdir(&f.root.join(DIR)).unwrap(); + fs::write(f.root.join(DIR).join("journal.json"), br#"{"schema":1,"phase":"Prepared","files":[{"path":"../outside","prior":null,"next":"00"}]}"#).unwrap(); + assert!(recover(&f.root).is_err()); + f.original(); + } + + #[cfg(unix)] + #[test] + fn linked_destination_is_rejected_before_any_replacement() { + let f = Fixture::new(); + let other = f.root.join("other"); + mkdir(&other).unwrap(); + fs::remove_dir_all(f.root.join("ui")).unwrap(); + std::os::unix::fs::symlink(&other, f.root.join("ui")).unwrap(); + assert!(Transaction::prepare(&f.root, &f.plan).is_err()); + assert!(!other.join("UI.exe").exists()); + } + + #[cfg(windows)] + #[test] + #[ignore] + fn loaded_child() { + let ready = std::env::var_os("MONOLITH_INSTALL_READY").unwrap(); + fs::write(ready, b"ready").unwrap(); + std::thread::sleep(std::time::Duration::from_secs(300)); + } + + #[cfg(windows)] + #[test] + fn loaded_updater_image_can_be_replaced_and_recovered() { + let f = Fixture::new(); + let exe = f.root.join("Updater.exe"); + fs::copy(std::env::current_exe().unwrap(), &exe).unwrap(); + let original = digest(&exe).unwrap(); + let ready = f.root.join("ready"); + let mut child = std::process::Command::new(&exe) + .args(["--ignored", "--exact", "install::tests::loaded_child"]) + .env("MONOLITH_INSTALL_READY", &ready) + .spawn() + .unwrap(); + let result = std::panic::catch_unwind(|| { + for _ in 0..500 { + if ready.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(ready.exists()); + let tx = Transaction::prepare( + &f.root, + &[(f.stage.join("Updater.exe"), "Updater.exe".into())], + ) + .unwrap(); + tx.place(&mut |_| Ok(())).unwrap(); + recover(&f.root).unwrap(); + assert_eq!(digest(&exe).unwrap(), original); + }); + let _ = child.kill(); + let _ = child.wait(); + result.unwrap(); + } +} diff --git a/app/updater/src-tauri/src/main.rs b/app/updater/src-tauri/src/main.rs index 1353836..17feca4 100644 --- a/app/updater/src-tauri/src/main.rs +++ b/app/updater/src-tauri/src/main.rs @@ -7,6 +7,7 @@ mod archive; mod download; mod engine_rpc; mod http; +mod install; mod manifest; mod paths; mod state; @@ -42,7 +43,6 @@ fn fail(app: &AppHandle, message: &str) { emit_state(app); } - #[tauri::command] fn updater_state() -> serde_json::Value { core().snapshot() @@ -75,13 +75,19 @@ fn updater_retry(app: AppHandle) { }); } - /// Use the first three Windows FileVersion fields for semver comparison. fn three_part(v: &str) -> String { v.split('.').take(3).collect::>().join(".") } fn run_check(app: &AppHandle, auto: bool) { + if let Err(e) = recover_update(&paths::app_dir()) { + fail(app, &format!("recovering interrupted update: {e}")); + if let Some(win) = app.get_webview_window("main") { + let _ = win.show(); + } + return; + } apply::sweep_old(&paths::app_dir()); *core().manifest.lock().unwrap() = None; set_phase(app, Phase::Checking); @@ -102,7 +108,9 @@ fn run_check(app: &AppHandle, auto: bool) { let force = FORCE.load(Ordering::Relaxed); let mut comps: Vec = Vec::new(); for key in ["engine", "ui", "updater"] { - let Some(info) = m.components.get(key) else { continue }; + let Some(info) = m.components.get(key) else { + continue; + }; let from = match key { "engine" => installed.engine.clone(), "ui" => installed.ui.clone(), @@ -191,7 +199,6 @@ fn spawn_recording_watch(app: &AppHandle) { }); } - fn set_comp(key: &str, mutate: impl FnOnce(&mut ComponentState)) { let mut s = core().state.lock().unwrap(); for c in s.components.iter_mut() { @@ -232,7 +239,9 @@ fn run_pipeline(app: &AppHandle) { // 1. Download + verify each changed component into staging. for key in &keys { - let Some(info) = m.components.get(key.as_str()) else { continue }; + let Some(info) = m.components.get(key.as_str()) else { + continue; + }; set_comp(key, |c| { c.status = CompStatus::Downloading; c.downloaded = 0; @@ -303,65 +312,15 @@ fn run_pipeline(app: &AppHandle) { emit_state(app); } - // 2. Apply. UI first (its process must close), engine next (its process - // must exit), then relaunch the engine, then self-update last. core().state.lock().unwrap().speed_bps = 0; if engine_rpc::recording() { fail(app, "a recording is in progress - stop it and retry"); return; } set_phase(app, Phase::Applying); - let has = |k: &str| keys.iter().any(|x| x == k); let engine_was_running = engine_rpc::engine_running(); - - if has("ui") { - if engine_was_running { - if let Err(e) = engine_rpc::close_ui() { - fail(app, &format!("closing the interface failed: {e}")); - return; - } - } - let src = staging.join("ui"); - if let Err(e) = archive::extract_zip(&staging.join("ui.zip"), &src) - .and_then(|_| apply::place_tree(&src, &app_dir.join("ui"))) - { - fail(app, &format!("applying interface: {e}")); - return; - } - } - - if has("engine") { - if engine_was_running { - engine_rpc::request_engine_exit(); - if !engine_rpc::wait_engine_exit(Duration::from_secs(20)) { - fail(app, "the engine did not exit in time - close Monolith and retry"); - return; - } - } - let src = staging.join("engine"); - if let Err(e) = archive::extract_zip(&staging.join("engine.zip"), &src) - .and_then(|_| apply::place_tree(&src, &app_dir)) - { - fail(app, &format!("applying engine: {e}")); - return; - } - } - - // Relaunch before replacing the running updater image. - if has("engine") && engine_was_running { - use std::process::{Command, Stdio}; - let _ = Command::new(app_dir.join("Monolith.exe")) - .current_dir(&app_dir) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - - // Record the applied versions (exe resources are the live source; this - // file is the fallback) before anything else can go wrong. - { + let installed = { let s = core().state.lock().unwrap(); let mut v = s.installed.clone().unwrap_or(versions::Installed { engine: "0.0.0".into(), @@ -376,15 +335,75 @@ fn run_pipeline(app: &AppHandle) { _ => {} } } - versions::write_components_json(&v); + v + }; + let tx = match prepare_update(&keys, &staging, &app_dir, &installed) { + Ok(tx) => tx, + Err(e) => { + fail(app, &format!("preparing update: {e}")); + return; + } + }; + if engine_rpc::recording() { + let error = tx + .abort() + .err() + .map(|e| format!("; recovery: {e}")) + .unwrap_or_default(); + fail( + app, + &format!("a recording started while preparing the update{error}"), + ); + return; } - - if has("updater") { - let src = staging.join("updater"); - if let Err(e) = archive::extract_zip(&staging.join("updater.zip"), &src) - .and_then(|_| apply::self_swap(&src.join("Updater.exe"))) + if has("ui") && engine_was_running { + if let Err(e) = engine_rpc::close_ui() { + let restore = tx + .abort() + .err() + .map(|e| format!("; recovery: {e}")) + .unwrap_or_default(); + fail(app, &format!("closing the interface: {e}{restore}")); + return; + } + } + if has("engine") && engine_was_running { + engine_rpc::request_engine_exit(); + if !engine_rpc::wait_engine_exit(Duration::from_secs(20)) { + let restore = tx + .abort() + .err() + .map(|e| format!("; recovery: {e}")) + .unwrap_or_default(); + fail( + app, + &format!("the engine did not exit in time - close Monolith and retry{restore}"), + ); + return; + } + } + if let Err(e) = tx.install() { + let restart = if has("engine") + && engine_was_running + && matches!(install::pending(&app_dir), Ok(false)) { - fail(app, &format!("applying updater: {e}")); + restart_engine(&app_dir) + .err() + .map(|e| format!("; restarting restored engine: {e}")) + .unwrap_or_default() + } else { + String::new() + }; + fail(app, &format!("applying update: {e}{restart}")); + return; + } + core().state.lock().unwrap().installed = Some(installed); + if has("engine") && engine_was_running { + if let Err(e) = restart_engine(&app_dir) { + fail( + app, + &format!("update applied, but the engine could not restart: {e}"), + ); return; } } @@ -393,6 +412,66 @@ fn run_pipeline(app: &AppHandle) { set_phase(app, Phase::Done); } +fn recover_update(app_dir: &std::path::Path) -> Result<(), String> { + let interrupted = install::pending(app_dir)?; + let running = interrupted && engine_rpc::engine_running(); + if running { + if engine_rpc::recording() { + return Err("stop the recording before recovering the update".into()); + } + engine_rpc::close_ui()?; + engine_rpc::request_engine_exit(); + if !engine_rpc::wait_engine_exit(Duration::from_secs(20)) { + return Err("close Monolith before recovering the update".into()); + } + } + install::recover(app_dir)?; + if running { + restart_engine(app_dir)?; + } + Ok(()) +} + +fn prepare_update( + keys: &[String], + staging: &std::path::Path, + app_dir: &std::path::Path, + installed: &versions::Installed, +) -> Result { + let mut files = Vec::new(); + for key in keys { + let src = staging.join(key); + archive::extract_zip(&staging.join(format!("{key}.zip")), &src)?; + let prefix = if key == "ui" { + std::path::Path::new("ui") + } else { + std::path::Path::new("") + }; + let component = install::files(&src, prefix)?; + if key == "updater" + && (component.len() != 1 || component[0].1 != std::path::Path::new("Updater.exe")) + { + return Err("updater payload must contain only Updater.exe".into()); + } + files.extend(component); + } + let metadata = staging.join("components.json"); + versions::stage_components_json(installed, &metadata)?; + files.push((metadata, "components.json".into())); + install::Transaction::prepare(app_dir, &files) +} + +fn restart_engine(app_dir: &std::path::Path) -> Result<(), String> { + use std::process::{Command, Stdio}; + Command::new(app_dir.join("Monolith.exe")) + .current_dir(app_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} fn focus_existing_instance() -> bool { use windows::core::HSTRING; diff --git a/app/updater/src-tauri/src/versions.rs b/app/updater/src-tauri/src/versions.rs index 4440564..42e5e7b 100644 --- a/app/updater/src-tauri/src/versions.rs +++ b/app/updater/src-tauri/src/versions.rs @@ -19,17 +19,20 @@ pub fn installed() -> Installed { ui: file_version(&dir.join("ui").join("Monolith.UI.exe")) .or_else(|| fallback.as_ref().map(|f| f.ui.clone())) .unwrap_or_else(|| "0.0.0".into()), - // Own build: exact, no resource-file read needed. - updater: env!("CARGO_PKG_VERSION").to_string(), + updater: file_version(&dir.join("Updater.exe")) + .or_else(|| fallback.as_ref().map(|f| f.updater.clone())) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()), } } -/// components.json is a fallback when installed executable version resources are unavailable. -pub fn write_components_json(v: &Installed) { - let path = paths::app_dir().join("components.json"); - if let Ok(json) = serde_json::to_string_pretty(v) { - let _ = std::fs::write(path, json); - } +/// Stage metadata for inclusion in the same transaction as the binaries. +pub fn stage_components_json(v: &Installed, path: &Path) -> Result<(), String> { + use std::io::Write; + let json = serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?; + let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; + file.write_all(&json) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string()) } fn read_components_json() -> Option { diff --git a/app/updater/src-tauri/tauri.conf.json b/app/updater/src-tauri/tauri.conf.json index 2671fc7..bb1b3ee 100644 --- a/app/updater/src-tauri/tauri.conf.json +++ b/app/updater/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Updater", - "version": "1.0.1", + "version": "1.0.2", "identifier": "dev.scatola.monolith.updater", "build": { "frontendDist": "../dist", diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 265ebe1..4c3a406 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -319,3 +319,13 @@ Collection hydration distinguishes unavailable catalogs from absent clips, loads Both Rust engine clients require bounded JSON-RPC response envelopes tied to the request ID. Updater components require validated size, SHA256 and Ed25519 metadata; unsigned components have no bypass. Archive extraction is a separate module with Windows path/link/resource limits. Per-file replacement restoration does not provide component-wide rollback. Manual recording uses one presentation origin across all streams to preserve composition and intertrack offsets. Failed packet/finalization output is not returned for cataloging. Synchronous recording I/O and pause/resume continuity remain follow-up work. + +## ADR-0021: Recoverable component installation + +Date: 2026-10-08 + +Replace the independent component swaps with one app-local transaction containing every selected payload file and the final component metadata. Preparation copies and syncs the entire plan before stopping the engine. A schema-versioned journal stores old/new hashes and a prepared/committed/rolled-back phase. Files are parked and placed with Windows `MoveFileExW` using write-through; prepared journals restore the previous plan, while committed journals retain the new files. Journal replacement uses a synced temporary file. + +Recover before querying installed versions. Preserve unexpectedly changed files and surface recovery errors. Keep transaction backups until commit or complete rollback; retry cleanup if loaded images prevent removal. Read installed updater version resources and report engine spawn errors. Do not start a restored engine while recovery is still pending. + +This provides recoverable disk installation, not instantaneous multi-file visibility or certified power-loss durability. A missing updater executable between its two moves can require a retained updater/reinstallation to invoke recovery. Stable file identity, concurrent external writers, engine readiness and clean Windows installer execution remain separate work. See `docs/audit/2026-10-08-followup.md` for test evidence. diff --git a/docs/audit/2026-10-08-followup.md b/docs/audit/2026-10-08-followup.md new file mode 100644 index 0000000..82f5f25 --- /dev/null +++ b/docs/audit/2026-10-08-followup.md @@ -0,0 +1,31 @@ +# Audit follow-up: recoverable updater installation + +Date: 2026-10-08 + +Baseline: merged main `34285273f29d547d17a8f828a154a16e8dfbf957`. Packaging PR [#3](https://github.com/fraa2a/Monolith/pull/3), technical audit PR [#4](https://github.com/fraa2a/Monolith/pull/4) and comment PR [#5](https://github.com/fraa2a/Monolith/pull/5) were merged with explicit user authorization while the full Windows build was pending. The earlier report remains a historical account; this document updates O01 and O02 only. + +## Changes + +| Finding | Change | Remaining limits | +| --- | --- | --- | +| O01: partial component replacement | Extract and validate all payloads before shutdown. Copy their files into one app-local transaction, sync staged files and a bounded journal, retain original files, and restore the whole plan after a placement failure. An interrupted prepared journal is recovered before the next updater check. Recovery can resume after a partial restoration. Committed journals are cleaned without rollback. | Multi-file replacement is recoverable, not instantaneously atomic. Recovery requires a runnable updater; interruption between parking and placing its executable can require launching a retained updater or reinstalling. Real power-loss durability and a comprehensive Windows fault matrix remain unverified. | +| O02: premature versions and ignored relaunch errors | Include `components.json` last in the same rollback plan as engine, UI and updater files. Read the updater's installed version resource rather than the running process's old build version. Report preparation, metadata, recovery and engine spawn errors. Restart a previously running engine after successful rollback, and report restart failure after a committed update. | Process spawn does not prove engine readiness or successful DLL loading. A full installer/runtime smoke test remains required. | + +The journal records relative paths and old/new SHA256 values. Recovery rejects malformed journals, duplicate case aliases, Windows reserved names and observed links/reparse points. It refuses to overwrite unexpectedly changed installed files. Hash checks and path inspection do not establish stable file identity against concurrent external filesystem changes. O05 remains open. + +Only planned files are replaced. Unrelated installed files are retained, including user data. Obsolete component files are not automatically deleted. Transaction cleanup is retried on the next updater launch when a parked loaded binary prevents deletion. A new update is refused while those files remain in use. + +Updater version advances to 1.0.2 in Cargo, its lockfile and Tauri configuration. Engine and UI versions are unchanged. No signing protocol, dependency, installer publication or release tag changes are included. + +## Verification + +- `cargo test --locked --manifest-path tests/rust/Cargo.toml`: 14 tests pass on Linux. Eight install regressions exercise interruption after each of nine file moves, late metadata failure, repeat recovery, foreign-file preservation, abort, committed-journal recovery, incomplete preparation, aliases and linked destinations. The six existing tests also pass. +- `cargo check --locked --tests --no-default-features --target x86_64-pc-windows-msvc --manifest-path tests/rust/Cargo.toml`: passes. This type-checks the Windows file operations and Windows-only loaded-updater test; it neither links nor executes the Tauri host. +- `git diff --check`, the repository text policy and focused Rust formatting checks pass. +- A separate Windows CI job runs the production-module tests without waiting for the full vcpkg build. Its loaded-image test starts a copied test executable, replaces it while running and recovers its original bytes. The result must be read from the PR checks; this report does not claim Windows execution before that job completes. + +Fault injection stops the actual placement routine and then invokes recovery from the persisted journal. It is not an OS power cut, disk-full simulation or destructive hardware test. The application-level preparation/restart integration has source review and formatting validation locally; the full Tauri/Windows build remains CI work. + +## Outstanding work + +O03 through O14 in [the original report](2026-10-08.md#remaining-findings-and-recommended-follow-up) remain open. Highest priority is bounded recorder workers (O03), engine ownership of catalog writes (O04), and storage identity/crash recovery (O05). These require distinct changes and focused coverage. Clean Windows installation, DLL loading, WGC/WASAPI and capture stop/restart testing (O14) remain release validation work. diff --git a/docs/audit/2026-10-08.md b/docs/audit/2026-10-08.md index 93900a4..7bce1a9 100644 --- a/docs/audit/2026-10-08.md +++ b/docs/audit/2026-10-08.md @@ -100,3 +100,7 @@ The three PRs are intended for review. No merge, release tag, installer publicat 326 comment groups were changed across 65 source files, plus brief inline contract corrections, two CSS files and the generated plugin counterparts. All surviving source lines in the 69 code/style/generated files match the audit parent after excluding only comment/blank lines and three named inline field annotations. Checked-in plugin JavaScript matches compiler output. Both frontend production builds produced the same asset hashes as the audit build. This check is deliberately scoped to these edits, not advertised as a general parser. Published technical PR: [#4](https://github.com/fraa2a/Monolith/pull/4). Packaging and release fixes: [#3](https://github.com/fraa2a/Monolith/pull/3). Review the comment PR against `fix/code-audit` so functional fixes stay outside its diff. + +## Merge and follow-up checkpoint + +The user subsequently authorized merging before the Windows checks completed. PRs #3, #4 and #5 are merged. [The updater follow-up report](2026-10-08-followup.md) records the new O01/O02 changes and their limits. O03 through O14 remain open. Earlier review/publication statements above describe the initial audit checkpoint. diff --git a/docs/handover/ACTIVE_HANDOVER.md b/docs/handover/ACTIVE_HANDOVER.md index c5723a1..10a64f2 100644 --- a/docs/handover/ACTIVE_HANDOVER.md +++ b/docs/handover/ACTIVE_HANDOVER.md @@ -3,6 +3,16 @@ Updated: 2026-10-08 +## Current checkpoint: merged audit and updater follow-up + +- The user explicitly authorized publication and merging while checks are pending. Packaging #3, audit #4 and comments #5 are merged; current baseline main is `34285273f29d547d17a8f828a154a16e8dfbf957`. Earlier approval/review restrictions below describe historical checkpoints and do not override this authorization. +- `fix/audit-followup` addresses O01/O02 with one staged file plan, persisted recovery journal, resumable rollback, metadata in the same plan, installed updater version reads and surfaced restart errors. Updater is version 1.0.2. No new dependencies or release publication. +- Local production-module harness: 14 Rust tests pass. Windows-target tests compile with `cargo check --locked --tests --no-default-features --target x86_64-pc-windows-msvc --manifest-path tests/rust/Cargo.toml`. Focused formatting, whitespace and text-policy checks pass. Full Tauri host build and Windows execution are not local results. +- Added an independent Windows recovery job including a real running executable replacement/recovery test. Read current PR checks for its execution result. Full native Windows dependency compilation was still running at this checkpoint. +- Follow-up evidence and limitations: `docs/audit/2026-10-08-followup.md`; decision: ADR-0021. O03 through O14 remain open. Recovery requires launching an updater; the two-move executable replacement has a possible missing-path interruption window. No claim of instantaneous filesystem atomicity, certified power-loss durability or successful engine readiness. + + + ## Continuation checkpoint 2026-10-08: status verification - Published packaging PR: https://github.com/fraa2a/Monolith/pull/3. Remote head verified as `8ba6db2`; Windows CI run `37710318411` failed during Configure because `overlay-ports` was in `vcpkg.json` instead of `vcpkg-configuration.json`. The earlier x264 archive/hash issue prompted the pinned Git overlay. diff --git a/tests/rust/src/lib.rs b/tests/rust/src/lib.rs index 97cafd2..8d734c5 100644 --- a/tests/rust/src/lib.rs +++ b/tests/rust/src/lib.rs @@ -1,5 +1,8 @@ #![allow(dead_code)] +#[path = "../../../app/updater/src-tauri/src/install.rs"] +mod install; + #[path = "../../../app/updater/src-tauri/src/archive.rs"] mod archive; #[cfg(feature = "catalogs")] From a5d2611967844450ac92e31ab208ec0cd43a8aa7 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Thu, 8 Oct 2026 03:59:40 +0200 Subject: [PATCH 2/2] Record Windows updater recovery test results --- docs/audit/2026-10-08-followup.md | 2 +- docs/handover/ACTIVE_HANDOVER.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/audit/2026-10-08-followup.md b/docs/audit/2026-10-08-followup.md index 82f5f25..25f26ad 100644 --- a/docs/audit/2026-10-08-followup.md +++ b/docs/audit/2026-10-08-followup.md @@ -22,7 +22,7 @@ Updater version advances to 1.0.2 in Cargo, its lockfile and Tauri configuration - `cargo test --locked --manifest-path tests/rust/Cargo.toml`: 14 tests pass on Linux. Eight install regressions exercise interruption after each of nine file moves, late metadata failure, repeat recovery, foreign-file preservation, abort, committed-journal recovery, incomplete preparation, aliases and linked destinations. The six existing tests also pass. - `cargo check --locked --tests --no-default-features --target x86_64-pc-windows-msvc --manifest-path tests/rust/Cargo.toml`: passes. This type-checks the Windows file operations and Windows-only loaded-updater test; it neither links nor executes the Tauri host. - `git diff --check`, the repository text policy and focused Rust formatting checks pass. -- A separate Windows CI job runs the production-module tests without waiting for the full vcpkg build. Its loaded-image test starts a copied test executable, replaces it while running and recovers its original bytes. The result must be read from the PR checks; this report does not claim Windows execution before that job completes. +- Windows execution passed on code commit `dd922544406e309943f7aea646cca70992a22fc0`: [recovery job 113110828408](https://github.com/fraa2a/Monolith/actions/runs/37715473818/job/113110828408), 11 tests passed, zero failed. The one ignored helper is invoked as a child by the loaded-image test, which passed after replacing and recovering a running executable. This separate job does not require the full vcpkg build. The full Tauri/native Windows build remains in progress. Fault injection stops the actual placement routine and then invokes recovery from the persisted journal. It is not an OS power cut, disk-full simulation or destructive hardware test. The application-level preparation/restart integration has source review and formatting validation locally; the full Tauri/Windows build remains CI work. diff --git a/docs/handover/ACTIVE_HANDOVER.md b/docs/handover/ACTIVE_HANDOVER.md index 10a64f2..254eab4 100644 --- a/docs/handover/ACTIVE_HANDOVER.md +++ b/docs/handover/ACTIVE_HANDOVER.md @@ -8,7 +8,7 @@ Updated: 2026-10-08 - The user explicitly authorized publication and merging while checks are pending. Packaging #3, audit #4 and comments #5 are merged; current baseline main is `34285273f29d547d17a8f828a154a16e8dfbf957`. Earlier approval/review restrictions below describe historical checkpoints and do not override this authorization. - `fix/audit-followup` addresses O01/O02 with one staged file plan, persisted recovery journal, resumable rollback, metadata in the same plan, installed updater version reads and surfaced restart errors. Updater is version 1.0.2. No new dependencies or release publication. - Local production-module harness: 14 Rust tests pass. Windows-target tests compile with `cargo check --locked --tests --no-default-features --target x86_64-pc-windows-msvc --manifest-path tests/rust/Cargo.toml`. Focused formatting, whitespace and text-policy checks pass. Full Tauri host build and Windows execution are not local results. -- Added an independent Windows recovery job including a real running executable replacement/recovery test. Read current PR checks for its execution result. Full native Windows dependency compilation was still running at this checkpoint. +- Published follow-up PR #6: https://github.com/fraa2a/Monolith/pull/6. Independent Windows recovery job `113110828408` passed on code commit `dd922544406e309943f7aea646cca70992a22fc0`: 11 tests passed, zero failed, including replacement/recovery of a running executable. The ignored child helper is explicitly launched by that test. Full native Windows dependency compilation remains in progress. PR #6 remains open. - Follow-up evidence and limitations: `docs/audit/2026-10-08-followup.md`; decision: ADR-0021. O03 through O14 remain open. Recovery requires launching an updater; the two-move executable replacement has a possible missing-path interruption window. No claim of instantaneous filesystem atomicity, certified power-loss durability or successful engine readiness.