//! Two-phase boot-time sentinel wipe. //! //! **Phase 1** (`write_sentinel`) — called by `sign_out`: writes a durable //! reset-intent file outside every path that will be wiped. //! //! **Phase 2** (`run_boot_reset`) — called at the very top of `setup()` in //! `lib.rs`, before migrations and identity resolution: if the sentinel is //! present the wipe runs atomically and the app falls through into clean //! onboarding. //! //! The sentinel lives at `/..reset-pending` //! and survives the app-data wipe because the wipe targets the exact //! app-data dir, not its parent. //! //! Idempotency: if the process crashes mid-wipe the sentinel is still present //! on next boot and the wipe retries from the top. use std::path::{Path, PathBuf}; // ── Sentinel helpers ────────────────────────────────────────────────────────── /// Sentinel path: `/..reset-pending` /// where `bundle_id` is the file-name component of `app_data_dir` /// (e.g. `xyz.block.buzz.app` or `xyz.block.buzz.app.dev`). pub(crate) fn sentinel_path(app_data_dir: &Path) -> PathBuf { let bundle_id = app_data_dir .file_name() .and_then(|n| n.to_str()) .unwrap_or("buzz"); let name = format!(".{bundle_id}.reset-pending"); match app_data_dir.parent() { Some(parent) => parent.join(name), None => PathBuf::from(&name), } } /// Atomically write the sentinel file. Content is intentionally empty — /// existence is the signal. pub(crate) fn write_sentinel(app_data_dir: &Path) -> Result<(), String> { let path = sentinel_path(app_data_dir); std::fs::write(&path, b"").map_err(|e| format!("write sentinel {}: {e}", path.display())) } /// Return `true` when the sentinel file exists. pub(crate) fn check_sentinel(app_data_dir: &Path) -> bool { sentinel_path(app_data_dir).exists() } /// Remove the sentinel file. A missing file is not an error. pub(crate) fn delete_sentinel(app_data_dir: &Path) -> Result<(), String> { let path = sentinel_path(app_data_dir); match std::fs::remove_file(&path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(format!("delete sentinel {}: {e}", path.display())), } } // ── Keychain abstraction (enables unit testing) ─────────────────────────────── /// Keychain operations needed by the boot-time reset. /// Implemented for `SecretStore`; a fake is used in tests. pub(crate) trait ResetKeychain { /// Delete the blob + all per-key legacy entries. fn delete_all_with_legacy(&self) -> Result<(), String>; /// Return `true` when all keychain shapes (main blob, DPK blob, per-key /// entries) that `migrate_legacy_key` can consume are absent. fn verify_fully_wiped(&self) -> bool; } impl ResetKeychain for crate::secret_store::SecretStore { fn delete_all_with_legacy(&self) -> Result<(), String> { self.delete_all_with_legacy_cleanup() } fn verify_fully_wiped(&self) -> bool { self.verify_fully_wiped() } } // ── Result type ─────────────────────────────────────────────────────────────── /// Outcome of the boot-time reset check. #[derive(Debug, Default)] pub(crate) struct ResetOutcome { /// Wipe completed successfully this boot — suppress nest migrations. pub completed: bool, /// Wipe was attempted but verification failed — surface error state. pub failed: bool, } // ── Boot-time reset ─────────────────────────────────────────────────────────── /// Wipe parameters assembled by `lib.rs` and passed into `run_boot_reset_with_keychain`. pub(crate) struct ResetContext<'a> { pub app_data_dir: &'a Path, /// Legacy App Support dir for this build (Sprout import source). When /// present and non-empty, wiped alongside `app_data_dir` to prevent /// `migrate_legacy_app_data_dir` from restoring the old identity. pub legacy_app_data_dir: Option, /// Nest dir (`~/.buzz` or `~/.buzz-dev`) scoped to this build's variant, /// injected so unit tests can override without touching the global OnceLock. pub nest_dir: Option, pub keychain: &'a dyn ResetKeychain, pub home_dir: Option, pub is_dev: bool, /// Build-owned config root for demos. Production leaves this unset. pub demo_config_dir: Option, /// Demo builds own only build-scoped state and must never delete shared /// production or legacy agent roots. pub is_demo: bool, } /// Entry point called from `lib.rs` setup (before migrations). /// /// Constructs a `SecretStore` for the running build's keyring service and /// delegates to `run_boot_reset_with_keychain` for testable wipe logic. pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { if !check_sentinel(app_data_dir) { return ResetOutcome::default(); } let is_dev = app_data_dir .file_name() .and_then(|n| n.to_str()) .map(crate::migration::is_dev_data_dir_name) .unwrap_or(false); let store = crate::secret_store::SecretStore::keyring(crate::app_state::keyring_service()); let home_dir = dirs::home_dir(); let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir); let nest_dir = crate::managed_agents::nest_dir(); let demo_config_dir = match crate::build_identity::demo_config_home() { Ok(dir) => dir, Err(error) => { eprintln!("buzz-desktop reset: {error}"); return ResetOutcome { completed: false, failed: true, }; } }; let ctx = ResetContext { app_data_dir, legacy_app_data_dir: legacy_dir, nest_dir, keychain: &store, home_dir, is_dev, demo_config_dir, is_demo: crate::build_identity::is_demo_build(), }; run_boot_reset_with_keychain(ctx) } /// Deterministic trash path: `.reset-trash`. Unlike PID-based names, /// any boot can discover and clean trash from a prior crashed attempt. fn trash_path(original: &Path) -> PathBuf { original.with_file_name(format!( "{}.reset-trash", original .file_name() .and_then(|n| n.to_str()) .unwrap_or("buzz") )) } /// Remove an existing reset-trash directory if present (from a prior crashed /// attempt), then rename `src` into the deterministic trash path. Returns /// `Ok(trash_path)` on success. fn rename_to_trash(src: &Path) -> Result { let dst = trash_path(src); // Clear prior trash before renaming so a collision doesn't fail the rename. if dst.exists() { let _ = std::fs::remove_dir_all(&dst); } std::fs::rename(src, &dst) .map_err(|e| format!("rename {} → {}: {e}", src.display(), dst.display()))?; Ok(dst) } /// Core wipe logic — separated for testing. pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome { // An unknown demo credential root is not evidence of an absent root. Refuse // before any destructive work and retain reset intent for the next boot. if ctx.is_demo && ctx.demo_config_dir.is_none() { eprintln!("buzz-desktop reset: cannot resolve demo credential directory"); return ResetOutcome { completed: false, failed: true, }; } let app_data_dir = ctx.app_data_dir; // ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ── let trash_app = trash_path(app_data_dir); if app_data_dir.exists() { if let Err(e) = rename_to_trash(app_data_dir) { eprintln!("buzz-desktop reset: {e}"); return ResetOutcome { completed: false, failed: true, }; } } // ── Step 1b: rename legacy App Support dir (sprout import source) ──────── let trash_legacy: Option = ctx.legacy_app_data_dir.as_ref().map(|l| trash_path(l)); if let Some(ref legacy) = ctx.legacy_app_data_dir { if legacy.exists() { if let Err(e) = rename_to_trash(legacy) { eprintln!("buzz-desktop reset: {e}"); // Non-fatal for legacy dir — continue } } } // ── Step 2: rename WebKit dir for this build ────────────────────────────── let trash_webkit: Option = if let Some(ref home) = ctx.home_dir { let bundle_id = app_data_dir .file_name() .and_then(|n| n.to_str()) .unwrap_or("buzz"); let webkit_dir = home.join("Library").join("WebKit").join(bundle_id); let tw = trash_path(&webkit_dir); if webkit_dir.exists() { if let Err(e) = rename_to_trash(&webkit_dir) { eprintln!("buzz-desktop reset: {e}"); // Non-fatal — continue } } Some(tw) } else { None }; // ── Step 3: remove build-owned nest and CLI symlink ────────────────────── // Production and dev preserve their existing legacy/global cleanup. A demo // never owns these shared roots, so signing out of one must leave them // available to production and every other demo. if let Some(ref nest) = ctx.nest_dir { let _ = std::fs::remove_dir_all(nest); } // A demo owns credentials here. Failure to remove them must keep the reset // pending, even if the app data and keychain were successfully wiped. let demo_config_removed = ctx.demo_config_dir .as_ref() .is_none_or(|path| match std::fs::remove_dir_all(path) { Ok(()) => true, Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, Err(error) => { eprintln!( "buzz-desktop reset: remove demo config {}: {error}", path.display() ); false } }); if let Some(ref home) = ctx.home_dir { if !ctx.is_demo { let _ = std::fs::remove_dir_all(home.join(".sprout")); let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); } let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); } // ── Step 4: keychain — LAST so we can read keys before deleting ────────── if let Err(e) = ctx.keychain.delete_all_with_legacy() { eprintln!("buzz-desktop reset: keychain delete: {e}"); // Keychain failure is fatal: keep sentinel, signal failure. // Restore all three dirs so the app returns to a coherent pre-reset state. if trash_app.exists() { let _ = std::fs::rename(&trash_app, app_data_dir); } if let Some(ref legacy) = ctx.legacy_app_data_dir { if let Some(ref tl) = trash_legacy { if tl.exists() { let _ = std::fs::rename(tl, legacy); } } } if let Some(ref home) = ctx.home_dir { let bundle_id = app_data_dir .file_name() .and_then(|n| n.to_str()) .unwrap_or("buzz"); let webkit_dir = home.join("Library").join("WebKit").join(bundle_id); if let Some(ref tw) = trash_webkit { if tw.exists() { let _ = std::fs::rename(tw, &webkit_dir); } } } return ResetOutcome { completed: false, failed: true, }; } // ── Step 5: sweep ALL reset trash (including from prior crashed boots) ─── let _ = std::fs::remove_dir_all(&trash_app); if let Some(ref tl) = trash_legacy { let _ = std::fs::remove_dir_all(tl); } if let Some(ref tw) = trash_webkit { let _ = std::fs::remove_dir_all(tw); } // ── Step 6: verify ──────────────────────────────────────────────────────── let keychain_ok = ctx.keychain.verify_fully_wiped(); let app_data_gone = !app_data_dir.exists(); let legacy_gone = ctx .legacy_app_data_dir .as_ref() .map(|p| !p.exists()) .unwrap_or(true); let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true); // `exists()` treats metadata errors as absence. Only NotFound establishes // that credentials are gone; a dangling symlink is not an absent root. let demo_config_gone = ctx.demo_config_dir.as_ref().is_none_or(|path| { matches!(std::fs::symlink_metadata(path), Err(error) if error.kind() == std::io::ErrorKind::NotFound) }); let trash_app_gone = !trash_app.exists(); let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true); let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true); if !keychain_ok || !app_data_gone || !legacy_gone || !nest_gone || !demo_config_removed || !demo_config_gone || !trash_app_gone || !trash_legacy_gone || !trash_webkit_gone { eprintln!( "buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \ app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \ demo_config_removed={demo_config_removed}, demo_config_gone={demo_config_gone}, \ trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \ trash_webkit_gone={trash_webkit_gone})" ); return ResetOutcome { completed: false, failed: true, }; } // ── Step 7: delete sentinel → success ──────────────────────────────────── if let Err(e) = delete_sentinel(app_data_dir) { eprintln!("buzz-desktop reset: delete sentinel: {e}"); // Sentinel not deleted — keep failed=false so the app boots into // onboarding, but on next boot the reset will retry (idempotent). } ResetOutcome { completed: true, failed: false, } } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::cell::Cell; use tempfile::TempDir; mod demo { include!("reset_demo_tests.rs"); } // ── Fake keychain ───────────────────────────────────────────────────────── struct FakeKeychain { delete_result: Result<(), String>, /// Tracks number of times delete was called. delete_calls: Cell, /// Whether `verify_fully_wiped` returns true after a successful delete. wiped_after_delete: bool, /// When true, verify_fully_wiped always returns false regardless of /// delete outcome — simulates a transient/unknown keychain error /// during verification that cannot confirm absence. verify_always_fails: bool, } impl FakeKeychain { fn ok() -> Self { FakeKeychain { delete_result: Ok(()), delete_calls: Cell::new(0), wiped_after_delete: true, verify_always_fails: false, } } fn fail(msg: &str) -> Self { FakeKeychain { delete_result: Err(msg.to_string()), delete_calls: Cell::new(0), wiped_after_delete: false, verify_always_fails: false, } } fn ok_but_not_wiped() -> Self { FakeKeychain { delete_result: Ok(()), delete_calls: Cell::new(0), wiped_after_delete: false, verify_always_fails: false, } } /// Delete succeeds but verify returns false — simulates a transient /// unknown error during verification (e.g. keyring constructor failure /// or unclassified read error that cannot confirm absence). fn ok_but_verify_fails() -> Self { FakeKeychain { delete_result: Ok(()), delete_calls: Cell::new(0), wiped_after_delete: true, verify_always_fails: true, } } } impl ResetKeychain for FakeKeychain { fn delete_all_with_legacy(&self) -> Result<(), String> { self.delete_calls.set(self.delete_calls.get() + 1); self.delete_result.clone() } fn verify_fully_wiped(&self) -> bool { if self.verify_always_fails { return false; } self.wiped_after_delete && self.delete_calls.get() > 0 } } fn make_app_data(tmp: &TempDir) -> PathBuf { let dir = tmp .path() .join("Application Support") .join("xyz.block.buzz.app"); std::fs::create_dir_all(&dir).unwrap(); dir } fn make_ctx<'a>( app_data_dir: &'a Path, keychain: &'a dyn ResetKeychain, is_dev: bool, ) -> ResetContext<'a> { ResetContext { app_data_dir, legacy_app_data_dir: None, nest_dir: None, keychain, home_dir: None, // skip nest/sprout/CLI ops in unit tests is_dev, demo_config_dir: None, is_demo: false, } } // ── Test 1: no sentinel ─────────────────────────────────────────────────── #[test] fn test_no_sentinel_returns_no_op() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); let kc = FakeKeychain::ok(); let outcome = run_boot_reset(app_data.as_path()); assert!(!outcome.completed, "no sentinel → not completed"); assert!(!outcome.failed, "no sentinel → not failed"); assert_eq!(kc.delete_calls.get(), 0, "keychain not touched"); assert!(app_data.exists(), "app-data dir untouched"); } // ── Test 2: full wipe succeeds ──────────────────────────────────────────── #[test] fn test_sentinel_present_full_wipe_succeeds() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); // Also create a legacy App Support dir (sprout import source). let legacy_dir = tmp .path() .join("Application Support") .join("xyz.block.sprout.app"); std::fs::create_dir_all(&legacy_dir).unwrap(); std::fs::write(legacy_dir.join("identity.key"), b"old-identity").unwrap(); write_sentinel(&app_data).unwrap(); let kc = FakeKeychain::ok(); let ctx = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: Some(legacy_dir.clone()), nest_dir: None, keychain: &kc, home_dir: None, is_dev: false, demo_config_dir: None, is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "should complete"); assert!(!outcome.failed, "should not fail"); assert!(!app_data.exists(), "app-data must be gone"); assert!(!legacy_dir.exists(), "legacy app-data must be gone"); assert!(!sentinel_path(&app_data).exists(), "sentinel must be gone"); assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); } // ── NIP-49: the boot wipe destroys the app-managed key backup ───────────── #[test] fn test_wipe_removes_app_managed_key_backup() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); let backup = crate::key_backup::backup_file_path(&app_data); std::fs::write(&backup, b"encrypted-backup-bytes").unwrap(); write_sentinel(&app_data).unwrap(); let kc = FakeKeychain::ok(); let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false)); assert!(outcome.completed); assert!( !backup.exists(), "sign-out wipe must destroy the app-managed key backup" ); } // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── #[test] fn test_sentinel_present_keychain_failure_keeps_sentinel() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); write_sentinel(&app_data).unwrap(); let kc = FakeKeychain::fail("keychain unavailable"); let ctx = make_ctx(&app_data, &kc, false); let outcome = run_boot_reset_with_keychain(ctx); assert!(!outcome.completed); assert!(outcome.failed); assert!( sentinel_path(&app_data).exists(), "sentinel must be preserved on failure" ); } // ── Test 4: app-data rename works but verify fails ──────────────────────── #[test] fn test_sentinel_present_verify_failure_keeps_sentinel() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); write_sentinel(&app_data).unwrap(); // Keychain delete "succeeds" but verify_fully_wiped still returns false. let kc = FakeKeychain::ok_but_not_wiped(); let ctx = make_ctx(&app_data, &kc, false); let outcome = run_boot_reset_with_keychain(ctx); assert!(!outcome.completed); assert!(outcome.failed); assert!(sentinel_path(&app_data).exists(), "sentinel preserved"); } // ── Test 5: crash-then-retry completes ─────────────────────────────────── #[test] fn test_crash_then_retry_completes() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); write_sentinel(&app_data).unwrap(); // First run — keychain fails (simulates a crash mid-wipe). let kc1 = FakeKeychain::fail("transient error"); let ctx1 = make_ctx(&app_data, &kc1, false); let first = run_boot_reset_with_keychain(ctx1); assert!(first.failed); assert!( sentinel_path(&app_data).exists(), "sentinel must survive first attempt" ); // Second run — keychain succeeds. App-data dir was renamed but we need // to recreate it for the test (the wipe tried to rename it). // In production the dir would already be gone; here it was renamed to // .trash- and then not cleaned up (keychain failed before cleanup). // Create a fresh app-data dir to simulate a reboot where app recreated it. std::fs::create_dir_all(&app_data).unwrap(); let kc2 = FakeKeychain::ok(); let ctx2 = make_ctx(&app_data, &kc2, false); let second = run_boot_reset_with_keychain(ctx2); assert!(second.completed, "second attempt must complete"); assert!(!second.failed); assert!( !sentinel_path(&app_data).exists(), "sentinel cleared on success" ); } // ── Test 6: dev build wipes dev nest, leaves prod nest intact ──────────── #[test] fn test_dev_build_wipes_dev_nest_not_prod() { let tmp = TempDir::new().unwrap(); // Create both nests. let dev_nest = tmp.path().join(".buzz-dev"); let prod_nest = tmp.path().join(".buzz"); std::fs::create_dir_all(&dev_nest).unwrap(); std::fs::create_dir_all(&prod_nest).unwrap(); let app_data = tmp .path() .join("Application Support") .join("xyz.block.buzz.app.dev"); std::fs::create_dir_all(&app_data).unwrap(); write_sentinel(&app_data).unwrap(); let kc = FakeKeychain::ok(); let ctx = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: None, nest_dir: Some(dev_nest.clone()), keychain: &kc, home_dir: None, is_dev: true, demo_config_dir: None, is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "wipe must complete"); assert!(!dev_nest.exists(), "dev nest must be wiped"); assert!(prod_nest.exists(), "prod nest must survive"); } // ── Test 7: prod build wipes prod nest, leaves dev nest intact ─────────── #[test] fn test_prod_build_wipes_prod_nest_not_dev() { let tmp = TempDir::new().unwrap(); // Create both nests. let dev_nest = tmp.path().join(".buzz-dev"); let prod_nest = tmp.path().join(".buzz"); std::fs::create_dir_all(&dev_nest).unwrap(); std::fs::create_dir_all(&prod_nest).unwrap(); let app_data = tmp .path() .join("Application Support") .join("xyz.block.buzz.app"); std::fs::create_dir_all(&app_data).unwrap(); write_sentinel(&app_data).unwrap(); let kc = FakeKeychain::ok(); let ctx = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: None, nest_dir: Some(prod_nest.clone()), keychain: &kc, home_dir: None, is_dev: false, demo_config_dir: None, is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "wipe must complete"); assert!(!prod_nest.exists(), "prod nest must be wiped"); assert!(dev_nest.exists(), "dev nest must survive"); } // ── Test 8: legacy app-data removed on reset ────────────────────────────── #[test] fn test_legacy_app_data_removed_on_reset() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); // Seed a legacy sprout dir with data that would be re-imported. let legacy_dir = tmp .path() .join("Application Support") .join("xyz.block.sprout.app"); std::fs::create_dir_all(legacy_dir.join("agents")).unwrap(); std::fs::write(legacy_dir.join("identity.key"), b"sprout-nsec").unwrap(); write_sentinel(&app_data).unwrap(); let kc = FakeKeychain::ok(); let ctx = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: Some(legacy_dir.clone()), nest_dir: None, keychain: &kc, home_dir: None, is_dev: false, demo_config_dir: None, is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "reset must complete"); assert!(!legacy_dir.exists(), "legacy app-data dir must be removed"); } // ── Test 9: unknown error during delete → failed, sentinel kept ──────── #[test] fn test_unknown_delete_error_keeps_sentinel() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); write_sentinel(&app_data).unwrap(); // Simulates an unclassified/transient keychain error during delete. let kc = FakeKeychain::fail("unknown transient keychain error"); let ctx = make_ctx(&app_data, &kc, false); let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.failed, "unknown delete error must fail"); assert!(!outcome.completed, "must not complete on delete error"); assert!( sentinel_path(&app_data).exists(), "sentinel must survive unknown delete error" ); } // ── Test 10: unknown error during verify → failed, sentinel kept ───── #[test] fn test_unknown_verify_error_keeps_sentinel() { let tmp = TempDir::new().unwrap(); let app_data = make_app_data(&tmp); write_sentinel(&app_data).unwrap(); // Delete succeeds but verification fails — simulates a transient // keychain error (e.g. constructor failure, unclassified read error) // that cannot confirm absence. The sentinel must survive. let kc = FakeKeychain::ok_but_verify_fails(); let ctx = make_ctx(&app_data, &kc, false); let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.failed, "unknown verify error must fail"); assert!(!outcome.completed, "must not complete on verify error"); assert!( sentinel_path(&app_data).exists(), "sentinel must survive unknown verify error" ); } // ── Test 11: completed dev reset suppresses dev repos-dir migration ──── // Behavioral test at the composed seam: exercises real filesystem effects // through maybe_migrate_dev_repos_dir, not just the boolean predicate. #[test] fn test_completed_dev_reset_suppresses_dev_repos_dir_migration() { let tmp = TempDir::new().unwrap(); let app_data = tmp .path() .join("Application Support") .join("xyz.block.buzz.app.dev"); std::fs::create_dir_all(&app_data).unwrap(); write_sentinel(&app_data).unwrap(); // Seed prod ~/.buzz/.repos-dir so the migration has something to copy. let home = tmp.path().join("home"); let prod_nest = home.join(".buzz"); std::fs::create_dir_all(&prod_nest).unwrap(); std::fs::write(prod_nest.join(".repos-dir"), "/some/workspace").unwrap(); let dev_nest = tmp.path().join(".buzz-dev"); // Run a real reset and take the REAL outcome.completed. let kc = FakeKeychain::ok(); let ctx = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: None, nest_dir: Some(dev_nest.clone()), keychain: &kc, home_dir: None, is_dev: true, demo_config_dir: None, is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "reset must complete"); // Arm 1: completed dev reset → dev nest must NOT get .repos-dir. crate::migration::maybe_migrate_dev_repos_dir(true, outcome.completed, &home, &dev_nest); assert!( !dev_nest.join(".repos-dir").exists(), "completed dev reset must suppress .repos-dir import into dev nest" ); // Arm 2 (positive control): non-reset dev boot → dev nest IS created // with .repos-dir copied. This proves the test would have caught the // pass-3 resurrection live. let dev_nest_2 = tmp.path().join(".buzz-dev-control"); crate::migration::maybe_migrate_dev_repos_dir(true, false, &home, &dev_nest_2); assert!( dev_nest_2.join(".repos-dir").exists(), "non-reset dev boot must import .repos-dir into dev nest" ); assert_eq!( std::fs::read_to_string(dev_nest_2.join(".repos-dir")).unwrap(), "/some/workspace", ".repos-dir content must match prod source" ); // Arm 3: prod build (is_dev=false) → nothing created regardless. let dev_nest_3 = tmp.path().join(".buzz-dev-prod"); crate::migration::maybe_migrate_dev_repos_dir(false, false, &home, &dev_nest_3); assert!( !dev_nest_3.join(".repos-dir").exists(), "prod builds must never run dev repos-dir migration" ); } // ── Test 12: crash-after-rename retry cleans prior trash ───────────── #[test] fn test_crash_retry_cleans_prior_deterministic_trash() { let tmp = TempDir::new().unwrap(); let app_support = tmp.path().join("Application Support"); let app_data = app_support.join("xyz.block.buzz.app"); std::fs::create_dir_all(&app_data).unwrap(); write_sentinel(&app_data).unwrap(); // Simulate a prior crashed boot: originals absent, deterministic trash // present from the crash (as if the process renamed then died). let trash_app_dir = app_support.join("xyz.block.buzz.app.reset-trash"); std::fs::create_dir_all(&trash_app_dir).unwrap(); std::fs::write(trash_app_dir.join("identity.key"), b"old-key").unwrap(); // Original is gone (crashed mid-wipe), so remove it for the retry. std::fs::remove_dir_all(&app_data).unwrap(); let kc = FakeKeychain::ok(); let ctx = make_ctx(&app_data, &kc, false); let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "retry must complete"); assert!( !trash_app_dir.exists(), "prior crash trash must be cleaned by retry" ); assert!( !sentinel_path(&app_data).exists(), "sentinel must be cleared" ); } // ── Test 13: keychain-fail restores all dirs, retry cleans trash ────── #[test] fn test_keychain_fail_restores_all_then_retry_cleans() { let tmp = TempDir::new().unwrap(); let app_support = tmp.path().join("Application Support"); let app_data = app_support.join("xyz.block.buzz.app"); std::fs::create_dir_all(&app_data).unwrap(); std::fs::write(app_data.join("config.json"), b"{}").unwrap(); let legacy = app_support.join("xyz.block.sprout.app"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("identity.key"), b"sprout-key").unwrap(); write_sentinel(&app_data).unwrap(); // First attempt: keychain fails → dirs restored. let kc1 = FakeKeychain::fail("keychain locked"); let ctx1 = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: Some(legacy.clone()), nest_dir: None, keychain: &kc1, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, demo_config_dir: None, is_demo: false, }; let first = run_boot_reset_with_keychain(ctx1); assert!(first.failed, "first attempt must fail"); assert!( app_data.exists(), "app-data must be restored after keychain fail" ); assert!( legacy.exists(), "legacy dir must be restored after keychain fail" ); assert!(sentinel_path(&app_data).exists(), "sentinel survives"); // Second attempt: keychain succeeds → everything cleaned including // any residual trash from prior attempts. let kc2 = FakeKeychain::ok(); let ctx2 = ResetContext { app_data_dir: &app_data, legacy_app_data_dir: Some(legacy.clone()), nest_dir: None, keychain: &kc2, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, demo_config_dir: None, is_demo: false, }; let second = run_boot_reset_with_keychain(ctx2); assert!(second.completed, "second attempt must complete"); assert!(!app_data.exists(), "app-data must be gone"); assert!(!legacy.exists(), "legacy must be gone"); // No trash directories should remain. let trash_app = app_support.join("xyz.block.buzz.app.reset-trash"); let trash_legacy = app_support.join("xyz.block.sprout.app.reset-trash"); assert!(!trash_app.exists(), "app trash must be cleaned"); assert!(!trash_legacy.exists(), "legacy trash must be cleaned"); } }