use std::path::{Path, PathBuf}; use crate::types::ModelId; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppPaths { app_data_dir: PathBuf, } impl AppPaths { pub fn current() -> Self { Self { app_data_dir: resolve_app_data_dir(), } } pub fn app_data_dir(&self) -> PathBuf { self.app_data_dir.clone() } pub fn database_path(&self) -> PathBuf { self.app_data_dir.join("lumotia.db") } pub fn recordings_dir(&self) -> PathBuf { self.app_data_dir.join("recordings") } pub fn crashes_dir(&self) -> PathBuf { self.app_data_dir.join("crashes") } pub fn logs_dir(&self) -> PathBuf { self.app_data_dir.join("logs") } pub fn diagnostic_reports_dir(&self) -> PathBuf { self.app_data_dir.join("diagnostic-reports") } pub fn models_dir(&self) -> PathBuf { self.app_data_dir.join("models") } pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf { self.models_dir().join(id.as_str()) } pub fn llm_models_dir(&self) -> PathBuf { self.models_dir().join("llm") } pub fn migration_sentinel(&self, name: &str) -> PathBuf { self.app_data_dir.join(format!(".{name}.sentinel")) } } pub fn app_paths() -> AppPaths { AppPaths::current() } pub fn app_data_dir() -> PathBuf { app_paths().app_data_dir() } /// Surfaced when two or more lumotia data-dir candidates exist on disk /// simultaneously (e.g. both `~/.lumotia` and `~/.local/share/lumotia`). /// Picking one silently risks pointing at the wrong copy of the user's /// transcripts. The caller (typically the Tauri setup hook) should refuse /// to start and surface the paths to the user for manual consolidation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TargetAmbiguityError { pub candidates: Vec, } impl std::fmt::Display for TargetAmbiguityError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "ambiguous lumotia data directory — multiple candidate paths exist: {}. \ Please consolidate manually (move data into one path and delete the other) \ then restart.", self.candidates .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ") ) } } impl std::error::Error for TargetAmbiguityError {} fn resolve_app_data_dir() -> PathBuf { match resolve_app_data_dir_strict() { Ok(p) => p, Err(e) => { // Refuse to start rather than silently picking one of several // candidate target paths. This is intentionally a panic — the // process must not be allowed to begin writing into the wrong // half of a split data directory. The setup hook also calls // `check_target_ambiguity` explicitly to surface this error // before tracing/log subsystems are spun up. panic!("{e}"); } } } /// Fallible variant of [`resolve_app_data_dir`]: returns the conventional /// target path for the current platform, or a [`TargetAmbiguityError`] if /// more than one candidate target path currently exists on disk. /// /// Public so that the application setup hook can perform the check /// explicitly (and report the ambiguity through tracing) rather than /// relying on the panic that backs the infallible `resolve_app_data_dir`. pub fn resolve_app_data_dir_strict() -> Result { let candidates = target_data_dir_candidates(); let existing: Vec = candidates.iter().filter(|p| p.exists()).cloned().collect(); if existing.len() > 1 { return Err(TargetAmbiguityError { candidates: existing, }); } // If exactly one candidate exists, prefer it (it's where the user's // data lives). If none exist, fall through to the platform-canonical // path so a fresh install creates the right convention. if existing.len() == 1 { return Ok(existing.into_iter().next().unwrap()); } Ok(canonical_target_data_dir()) } /// Public counterpart to [`resolve_app_data_dir_strict`] returning `Ok(())` /// when the data dir is unambiguous and the [`TargetAmbiguityError`] /// otherwise. Useful when the caller just wants to fail-fast at boot /// without yet caring about the path itself. pub fn check_target_ambiguity() -> Result<(), TargetAmbiguityError> { resolve_app_data_dir_strict().map(|_| ()) } /// All conventional lumotia data-dir target paths for the current /// platform. Lumotia chooses one canonical path at install time, but a /// previous magnotia install or a hand-edited XDG_DATA_HOME can leave /// data in any of these — the migration driver probes them all and the /// resolver refuses to start if more than one survives. fn target_data_dir_candidates() -> Vec { let mut out = Vec::new(); #[cfg(target_os = "windows")] { if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { if !local_app_data.is_empty() { out.push(PathBuf::from(local_app_data).join("lumotia")); } } } #[cfg(target_os = "macos")] { if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { out.push( PathBuf::from(home) .join("Library") .join("Application Support") .join("Lumotia"), ); } } } #[cfg(target_os = "linux")] { if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { out.push(PathBuf::from(&home).join(".lumotia")); if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { out.push(PathBuf::from(xdg).join("lumotia")); } } out.push( PathBuf::from(home) .join(".local") .join("share") .join("lumotia"), ); } } } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { out.push(PathBuf::from(home).join(".lumotia")); } } } // De-duplicate while preserving order: on Linux XDG_DATA_HOME may be // set to `~/.local/share` explicitly, in which case the explicit XDG // candidate and the XDG default collapse to one path. let mut seen = std::collections::HashSet::new(); out.retain(|p| seen.insert(p.clone())); out } /// The single canonical target path for the current platform — what a /// fresh install would create. Used when no existing candidate is found. fn canonical_target_data_dir() -> PathBuf { #[cfg(target_os = "windows")] { let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()); return PathBuf::from(local_app_data).join("lumotia"); } #[cfg(target_os = "macos")] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); return PathBuf::from(home) .join("Library") .join("Application Support") .join("Lumotia"); } #[cfg(target_os = "linux")] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { return PathBuf::from(xdg).join("lumotia"); } } PathBuf::from(home) .join(".local") .join("share") .join("lumotia") } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(".lumotia") } } /// Outcome of attempting to migrate an existing magnotia-era data directory /// to its lumotia equivalent on first launch after the rebrand. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MigrationStatus { /// Renamed legacy dir to the new path. Includes optional db-file rename /// if `magnotia.db` was found inside. Migrated { from: PathBuf, to: PathBuf, renamed_db: bool, }, /// New path already exists. Did not touch legacy (if any) to avoid /// destroying user data on the new path. TargetAlreadyExists { target: PathBuf }, /// No legacy data dir on disk. Nothing to do. NoLegacyFound, } /// Probe ALL legacy magnotia data dir paths on the current platform. /// Returns one (legacy, target) pair per legacy candidate that exists on /// disk. The target is convention-preserving so the migration lands the /// same kind of dir it found (dot-home stays dot-home, XDG stays XDG, /// macOS Application Support stays the same). /// /// Previously this returned `Option<(legacy, target)>` and short-circuited /// on the first match. On Linux that allowed a user with both /// `~/.magnotia` AND `~/.local/share/magnotia` to migrate only one, /// leaving the other orphaned forever (subsequent boots prefer the new /// `~/.lumotia` so the XDG legacy is invisible). Now every legacy variant /// is probed and migrated independently. fn legacy_and_target_paths() -> Vec<(PathBuf, PathBuf)> { let mut out = Vec::new(); #[cfg(target_os = "windows")] { if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { if !local_app_data.is_empty() { let legacy = PathBuf::from(&local_app_data).join("magnotia"); let target = PathBuf::from(local_app_data).join("lumotia"); if legacy.exists() { out.push((legacy, target)); } } } } #[cfg(target_os = "macos")] { if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { let app_support = PathBuf::from(home) .join("Library") .join("Application Support"); let legacy = app_support.join("Magnotia"); let target = app_support.join("Lumotia"); if legacy.exists() { out.push((legacy, target)); } } } } #[cfg(target_os = "linux")] { if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { let dot_legacy = PathBuf::from(&home).join(".magnotia"); if dot_legacy.exists() { out.push((dot_legacy, PathBuf::from(&home).join(".lumotia"))); } if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { let xdg_legacy = PathBuf::from(&xdg).join("magnotia"); if xdg_legacy.exists() { out.push((xdg_legacy, PathBuf::from(&xdg).join("lumotia"))); } } } let xdg_default_legacy = PathBuf::from(&home) .join(".local") .join("share") .join("magnotia"); if xdg_default_legacy.exists() { let xdg_default_target = PathBuf::from(&home) .join(".local") .join("share") .join("lumotia"); out.push((xdg_default_legacy, xdg_default_target)); } } } } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { if let Ok(home) = std::env::var("HOME") { if !home.is_empty() { let legacy = PathBuf::from(&home).join(".magnotia"); let target = PathBuf::from(home).join(".lumotia"); if legacy.exists() { out.push((legacy, target)); } } } } // De-duplicate: e.g. XDG_DATA_HOME set explicitly to `~/.local/share` // would otherwise produce the same pair twice on Linux. let mut seen = std::collections::HashSet::new(); out.retain(|pair| seen.insert(pair.clone())); out } /// Migrate every legacy magnotia data directory to its /// convention-preserving lumotia equivalent on first launch. Idempotent: /// safe to call on every boot. /// /// Returns one [`MigrationStatus`] per legacy candidate probed, in /// platform-deterministic order. An empty Vec means there are no legacy /// directories on disk (clean install). Callers should log per-candidate /// outcomes and treat any `Err` as a hard startup failure: silently /// continuing past a migration error orphans user data behind a fresh /// empty lumotia dir. /// /// Per-candidate rules (same as before, applied independently to each /// legacy path that exists): /// * If the matching target already exists, do nothing for that /// candidate and emit `TargetAlreadyExists`. We do not destroy /// lumotia data, even if a stale legacy dir is also present. /// * If only the legacy path exists, rename it to the matching lumotia /// target (same convention) and rename `magnotia.db` -> `lumotia.db` /// inside it if found. pub fn migrate_legacy_data_dir() -> Result, std::io::Error> { migrate_legacy_data_dir_with_pairs(legacy_and_target_paths()) } /// Driver that takes the list of (legacy, target) pairs explicitly so /// callers can substitute synthetic paths. Production path goes through /// [`migrate_legacy_data_dir`], which resolves the pairs from /// platform-specific HOME / LOCALAPPDATA / XDG env vars. Integration /// tests in sibling crates call this directly with tempdir pairs. /// /// An empty input is shorthand for "no legacy on disk" and yields a /// single [`MigrationStatus::NoLegacyFound`] entry so callers can still /// rely on a non-empty result to drive their logging. pub fn migrate_legacy_data_dir_with_pairs( pairs: Vec<(PathBuf, PathBuf)>, ) -> Result, std::io::Error> { if pairs.is_empty() { return Ok(vec![MigrationStatus::NoLegacyFound]); } let mut out = Vec::with_capacity(pairs.len()); for (from, to) in pairs { out.push(migrate_one(from, to)?); } Ok(out) } /// Run the single-candidate migration. Extracted so the driver can loop /// over every legacy path discovered on disk and surface per-candidate /// outcomes individually. fn migrate_one(from: PathBuf, to: PathBuf) -> Result { if to.exists() { return Ok(MigrationStatus::TargetAlreadyExists { target: to }); } if let Some(parent) = to.parent() { std::fs::create_dir_all(parent)?; } rename_or_copy_tree(&from, &to)?; let renamed_db = rename_db_file_if_present(&to)?; Ok(MigrationStatus::Migrated { from, to, renamed_db, }) } /// Move a directory tree, falling back to copy + remove if the /// destination is on a different filesystem (EXDEV / CrossesDevices). /// /// Real-world cases this defends against: /// * `~/.magnotia` on the user's home partition, `~/.local/share/lumotia` /// on a bind-mounted partition. /// * Encrypted-home (`/home/.ecryptfs/`) vs decrypted view. /// * `$XDG_DATA_HOME` set to a non-home device. /// /// On a copy-then-remove fallback we tolerate partial cleanup failure /// (the source dir not fully deleted) by surfacing the error from the /// remove step only if the copy succeeded — losing data via a half-rolled /// migration is worse than leaving a stale legacy dir behind. fn rename_or_copy_tree(from: &Path, to: &Path) -> Result<(), std::io::Error> { match std::fs::rename(from, to) { Ok(()) => Ok(()), Err(e) if is_cross_device(&e) => { copy_dir_recursive(from, to)?; // Only attempt removal after the copy fully succeeded. // remove_dir_all is best-effort: if it leaves files behind // (permission edge cases), the user can clean up the stale // legacy dir manually. std::fs::remove_dir_all(from)?; Ok(()) } Err(e) => Err(e), } } fn is_cross_device(err: &std::io::Error) -> bool { // ErrorKind::CrossesDevices is stable from Rust 1.85. Fall back to // the raw OS error code (EXDEV = 18 on Linux, 17 on macOS, 17 on // BSDs) for older toolchains. if err.kind() == std::io::ErrorKind::CrossesDevices { return true; } #[cfg(unix)] { return matches!(err.raw_os_error(), Some(18)); } #[allow(unreachable_code)] false } /// Symlink-aware recursive directory copy used by the legacy data-dir /// migration and by the Tauri-side `app_data_dir` bundle-identifier /// migration in `src-tauri/src/tauri_app_data_migration.rs`. Exposed as /// `pub` so callers in sibling crates can reuse the same hardened /// implementation (see commit history for the symlink-loop defence). pub fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { let entry = entry?; let entry_path = entry.path(); let target_path = to.join(entry.file_name()); // CRITICAL: use file_type() rather than metadata(). metadata() // follows symlinks, so a directory symlink reports is_dir==true // and would recurse unconditionally — a self-referential or // ancestor-targeting directory symlink loops until the disk // fills. file_type() is symlink-aware on both Unix and Windows. let file_type = entry.file_type()?; if file_type.is_symlink() { // Recreate symlink rather than dereferencing — the // transcription app stores recording paths verbatim so a // dereferenced symlink could orphan large audio blobs, and // a directory symlink is the only way to terminate the // recursion at the link boundary. let link_target = std::fs::read_link(&entry_path)?; #[cfg(unix)] { std::os::unix::fs::symlink(link_target, &target_path)?; } #[cfg(windows)] { // On Windows we have to pick file vs dir symlink at // creation time. Probe the link target with full // metadata (it resolves through the link) to decide. // If the target is missing or unreadable, fall back to // a file symlink — safer than panicking the migration. let target_is_dir = std::fs::metadata(&entry_path) .map(|m| m.is_dir()) .unwrap_or(false); if target_is_dir { std::os::windows::fs::symlink_dir(link_target, &target_path)?; } else { std::os::windows::fs::symlink_file(link_target, &target_path)?; } } } else if file_type.is_dir() { copy_dir_recursive(&entry_path, &target_path)?; } else if file_type.is_file() { std::fs::copy(&entry_path, &target_path)?; } else { // Anything that is neither a symlink, a directory, nor a // regular file lands here: on Unix that's FIFOs, sockets, // and character / block device nodes. `std::fs::copy()` on // a FIFO would block forever waiting for a writer, and on // a device node would either fail unpredictably or attempt // to read until the device's end-of-stream. Both turn a // legacy-dir leftover into a silent migration hang. We // refuse to cross the boundary and surface the path so // the user can clean it up manually. The migration is // re-runnable once the offending node is removed. return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, format!( "refusing to copy non-regular filesystem object during migration: {}", entry_path.display() ), )); } } Ok(()) } fn rename_db_file_if_present(dir: &Path) -> Result { let legacy_db = dir.join("magnotia.db"); if !legacy_db.exists() { return Ok(false); } let new_db = dir.join("lumotia.db"); rename_or_copy_file(&legacy_db, &new_db)?; Ok(true) } fn rename_or_copy_file(from: &Path, to: &Path) -> Result<(), std::io::Error> { match std::fs::rename(from, to) { Ok(()) => Ok(()), Err(e) if is_cross_device(&e) => { std::fs::copy(from, to)?; std::fs::remove_file(from)?; Ok(()) } Err(e) => Err(e), } } #[cfg(test)] mod tests { use super::*; use crate::types::ModelId; #[test] fn derives_all_paths_from_one_base() { let paths = AppPaths { app_data_dir: PathBuf::from("/tmp/lumotia-test"), }; assert_eq!( paths.database_path(), PathBuf::from("/tmp/lumotia-test/lumotia.db") ); assert_eq!( paths.speech_model_dir(&ModelId::new("whisper-base-en")), PathBuf::from("/tmp/lumotia-test/models/whisper-base-en") ); assert_eq!( paths.llm_models_dir(), PathBuf::from("/tmp/lumotia-test/models/llm") ); } fn unique_tmp(base: &str) -> PathBuf { let pid = std::process::id(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); std::env::temp_dir().join(format!("lumotia-paths-test-{base}-{pid}-{nanos}")) } /// Helper: drive the migration with a single (legacy, target) pair /// and return the (only) status it produced. Keeps existing tests /// readable after the Option -> Vec API change. fn migrate_one_pair_inner(pair: (PathBuf, PathBuf)) -> Result { let mut statuses = migrate_legacy_data_dir_with_pairs(vec![pair])?; assert_eq!( statuses.len(), 1, "single-pair driver should yield exactly one status" ); Ok(statuses.pop().unwrap()) } #[test] fn migrate_with_legacy_present_renames_dir_and_db() { let root = unique_tmp("legacy-present"); let legacy = root.join("magnotia"); let target = root.join("lumotia"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap(); std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); match result { MigrationStatus::Migrated { from, to, renamed_db, } => { assert_eq!(from, legacy); assert_eq!(to, target); assert!(renamed_db, "expected db file to be renamed"); } other => panic!("expected Migrated, got {other:?}"), } assert!(!legacy.exists(), "legacy dir should be gone"); assert!(target.exists(), "new dir should exist"); assert!(target.join("lumotia.db").exists(), "db at new name"); assert!(!target.join("magnotia.db").exists(), "old db gone"); assert!( target.join("recordings.placeholder").exists(), "other files preserved" ); std::fs::remove_dir_all(&root).ok(); } #[test] fn migrate_with_both_present_returns_target_exists_and_preserves_lumotia() { let root = unique_tmp("both-present"); let legacy = root.join("magnotia"); let target = root.join("lumotia"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::create_dir_all(&target).unwrap(); std::fs::write(target.join("lumotia.db"), b"new-data").unwrap(); std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap(); let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); assert_eq!( result, MigrationStatus::TargetAlreadyExists { target: target.clone() } ); assert!(legacy.exists(), "legacy dir preserved"); assert!(target.exists(), "new dir preserved"); assert_eq!( std::fs::read(target.join("lumotia.db")).unwrap(), b"new-data".to_vec(), "lumotia.db not overwritten" ); std::fs::remove_dir_all(&root).ok(); } #[test] fn migrate_with_neither_present_returns_no_legacy() { let result = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("migrate ok"); assert_eq!(result, vec![MigrationStatus::NoLegacyFound]); } #[test] fn migrate_with_legacy_present_but_no_db_inside() { let root = unique_tmp("legacy-no-db"); let legacy = root.join("magnotia"); let target = root.join("lumotia"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap(); let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); match result { MigrationStatus::Migrated { renamed_db, .. } => { assert!(!renamed_db, "no db to rename"); } other => panic!("expected Migrated, got {other:?}"), } assert!(target.exists()); assert!(target.join("recordings.placeholder").exists()); std::fs::remove_dir_all(&root).ok(); } #[test] fn migrate_preserves_dot_home_to_dot_home_convention() { // Regression for B2 from Phase 5 QC: dot-home legacy must land in // dot-home target, not XDG target. let root = unique_tmp("convention"); let legacy = root.join(".magnotia"); let target = root.join(".lumotia"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("magnotia.db"), b"data").unwrap(); let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok"); assert!(matches!(result, MigrationStatus::Migrated { .. })); assert!(target.exists()); assert!(target.join("lumotia.db").exists()); std::fs::remove_dir_all(&root).ok(); } #[test] fn copy_dir_recursive_preserves_nested_files_and_directories() { // Regression for Codex Blocker 11: EXDEV fallback path uses // copy_dir_recursive. Verify it preserves directory structure, // file contents, and arbitrary depth. let root = unique_tmp("copy-recursive"); let src = root.join("legacy"); let dst = root.join("new"); std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap(); std::fs::create_dir_all(src.join("models/whisper-base-en")).unwrap(); std::fs::write(src.join("magnotia.db"), b"sqlite-bytes").unwrap(); std::fs::write(src.join("recordings/2026-05/clip-001.wav"), b"wav-bytes").unwrap(); std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap(); copy_dir_recursive(&src, &dst).expect("copy ok"); assert_eq!( std::fs::read(dst.join("magnotia.db")).unwrap(), b"sqlite-bytes" ); assert_eq!( std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(), b"wav-bytes" ); assert!(dst.join("models/whisper-base-en/manifest.json").exists()); // Source still present — copy_dir_recursive does not delete. assert!(src.exists()); std::fs::remove_dir_all(&root).ok(); } #[test] fn rename_or_copy_tree_succeeds_on_same_filesystem() { // Smoke for the happy path: same-filesystem case uses rename // and leaves no source behind. let root = unique_tmp("rename-tree"); let src = root.join("legacy"); let dst = root.join("new"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("magnotia.db"), b"data").unwrap(); rename_or_copy_tree(&src, &dst).expect("rename ok"); assert!(!src.exists(), "source removed"); assert!(dst.exists(), "destination present"); assert_eq!(std::fs::read(dst.join("magnotia.db")).unwrap(), b"data"); std::fs::remove_dir_all(&root).ok(); } #[test] fn is_cross_device_classifies_exdev_error_kind() { // Synthesise an io::Error tagged with CrossesDevices and ensure // the classifier returns true. This is the path that triggers // the copy-then-delete fallback in rename_or_copy_tree on Rust // 1.85+ toolchains. let err = std::io::Error::new(std::io::ErrorKind::CrossesDevices, "test exdev"); assert!(is_cross_device(&err)); let unrelated = std::io::Error::new(std::io::ErrorKind::NotFound, "test notfound"); assert!(!is_cross_device(&unrelated)); } #[cfg(unix)] #[test] fn is_cross_device_classifies_raw_exdev_on_unix() { // Belt-and-braces: ensure raw errno 18 (EXDEV on Linux) is // classified as cross-device even if the ErrorKind doesn't // map to CrossesDevices (older toolchains, future kernel // surprises). let err = std::io::Error::from_raw_os_error(18); assert!(is_cross_device(&err)); } // ------------------------------------------------------------------ // Defect A regression tests: multi-legacy-candidate + ambiguity guard // ------------------------------------------------------------------ #[test] fn migrate_handles_both_dot_home_and_xdg() { // Reproduces the multi-legacy orphan scenario: a Linux user with // BOTH `~/.magnotia` and `~/.local/share/magnotia` on disk. The // old code returned `Option<(legacy, target)>` and short-circuited // on the dot-home variant, leaving the XDG legacy orphaned. The // new driver loops over the Vec and migrates every candidate. let root = unique_tmp("both-legacy"); let dot_legacy = root.join(".magnotia"); let dot_target = root.join(".lumotia"); let xdg_legacy = root.join(".local/share/magnotia"); let xdg_target = root.join(".local/share/lumotia"); std::fs::create_dir_all(&dot_legacy).unwrap(); std::fs::create_dir_all(&xdg_legacy).unwrap(); std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap(); std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap(); let statuses = migrate_legacy_data_dir_with_pairs(vec![ (dot_legacy.clone(), dot_target.clone()), (xdg_legacy.clone(), xdg_target.clone()), ]) .expect("migrate ok"); assert_eq!( statuses.len(), 2, "expected one status per legacy candidate" ); for s in &statuses { assert!( matches!(s, MigrationStatus::Migrated { .. }), "expected Migrated, got {s:?}" ); } assert!(!dot_legacy.exists(), "dot-home legacy should be gone"); assert!(!xdg_legacy.exists(), "XDG legacy should be gone"); assert!(dot_target.exists(), "dot-home target should exist"); assert!(xdg_target.exists(), "XDG target should exist"); assert_eq!( std::fs::read(dot_target.join("marker")).unwrap(), b"dot-home".to_vec(), "dot-home content preserved" ); assert_eq!( std::fs::read(xdg_target.join("marker")).unwrap(), b"xdg".to_vec(), "XDG content preserved" ); std::fs::remove_dir_all(&root).ok(); } #[test] fn resolve_app_data_dir_refuses_on_multiple_targets() { // Reproduces the stray-dot-home orphan scenario: after a partial // migration the user may end up with BOTH `~/.lumotia` and // `~/.local/share/lumotia` on disk. Picking one silently is // worse than failing fast, so the strict resolver must error // with both paths surfaced for manual consolidation. // // We override HOME so the strict resolver scans inside our // tempdir, then assert it returns Err with both paths named. let root = unique_tmp("ambiguous-target"); let fake_home = root.join("home"); std::fs::create_dir_all(&fake_home).unwrap(); let dot = fake_home.join(".lumotia"); let xdg_default = fake_home.join(".local/share/lumotia"); std::fs::create_dir_all(&dot).unwrap(); std::fs::create_dir_all(&xdg_default).unwrap(); // Serialise env mutation: HOME / XDG_DATA_HOME are process-global, // and other tests in this module rely on them being unchanged. // We restore the previous values before returning. let prev_home = std::env::var_os("HOME"); let prev_xdg = std::env::var_os("XDG_DATA_HOME"); // SAFETY: tests in this module that read HOME serialise on this // exact pattern (set, call, restore) and the process is otherwise // single-threaded inside a #[test] body. std::env::set_var("HOME", &fake_home); std::env::remove_var("XDG_DATA_HOME"); let result = resolve_app_data_dir_strict(); // Restore env BEFORE asserting so a panic doesn't poison // subsequent tests. match prev_home { Some(v) => std::env::set_var("HOME", v), None => std::env::remove_var("HOME"), } if let Some(v) = prev_xdg { std::env::set_var("XDG_DATA_HOME", v); } let err = result.expect_err("expected ambiguity error"); assert!( err.candidates.iter().any(|p| p == &dot), "error must name dot-home candidate: {err}" ); assert!( err.candidates.iter().any(|p| p == &xdg_default), "error must name XDG default candidate: {err}" ); let msg = err.to_string(); assert!( msg.contains("ambiguous"), "message should flag ambiguity: {msg}" ); std::fs::remove_dir_all(&root).ok(); } // ------------------------------------------------------------------ // Defect B regression tests: copy_dir_recursive symlink loop // ------------------------------------------------------------------ #[cfg(unix)] #[test] fn copy_dir_recursive_does_not_loop_on_self_referential_dir_symlink() { // The original code used `entry.metadata()` which follows // symlinks, so a directory symlink reported is_dir==true and // recursed unconditionally. A self-referential dir symlink would // then loop until the disk filled. Use file_type() (which does // NOT follow symlinks), branch on is_symlink() FIRST, and // recreate the link instead of recursing through it. let root = unique_tmp("symlink-self"); let src = root.join("src"); let dst = root.join("dst"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("regular-file"), b"hello").unwrap(); // Self-reference: src/oops -> src. std::os::unix::fs::symlink(&src, src.join("oops")).unwrap(); copy_dir_recursive(&src, &dst).expect("copy must terminate, not loop"); // The regular file should have been copied. assert_eq!(std::fs::read(dst.join("regular-file")).unwrap(), b"hello"); // The self-reference should have been recreated as a symlink, // NOT as a directory full of recursive copies. let oops = dst.join("oops"); let oops_meta = std::fs::symlink_metadata(&oops).expect("oops should exist"); assert!( oops_meta.file_type().is_symlink(), "dst/oops must be a symlink, not a recursive directory copy" ); // And the link target must be preserved verbatim. let link_target = std::fs::read_link(&oops).unwrap(); assert_eq!(link_target, src, "symlink target should be preserved"); std::fs::remove_dir_all(&root).ok(); } #[cfg(unix)] #[test] fn copy_dir_recursive_preserves_directory_symlinks() { // A directory symlink to a real sibling dir must be recreated as // a symlink in dst (preserving the link-shape), not dereferenced // into a recursive copy of the sibling's contents. let root = unique_tmp("symlink-dir"); let src = root.join("src"); let sibling = root.join("sibling"); let dst = root.join("dst"); std::fs::create_dir_all(&src).unwrap(); std::fs::create_dir_all(&sibling).unwrap(); std::fs::write(sibling.join("payload"), b"sibling-data").unwrap(); // src/link -> sibling (directory symlink). std::os::unix::fs::symlink(&sibling, src.join("link")).unwrap(); copy_dir_recursive(&src, &dst).expect("copy ok"); let dst_link = dst.join("link"); let meta = std::fs::symlink_metadata(&dst_link).expect("dst/link should exist"); assert!( meta.file_type().is_symlink(), "dst/link must remain a symlink, not be replaced with a directory copy" ); // Following the link should still resolve to sibling content; // the link target must be preserved verbatim. let link_target = std::fs::read_link(&dst_link).unwrap(); assert_eq!(link_target, sibling, "symlink target should be preserved"); // And we must NOT have written sibling/payload into dst/link/. // (If link is a symlink, reading dst/link/payload would follow // it back to sibling/payload, so check on-disk shape instead.) let entries: Vec<_> = std::fs::read_dir(&dst).unwrap().collect(); let dst_link_entry = entries .iter() .find_map(|e| e.as_ref().ok()) .filter(|e| e.file_name() == "link"); if let Some(e) = dst_link_entry { assert!( e.file_type().unwrap().is_symlink(), "directory entry for dst/link must report symlink" ); } std::fs::remove_dir_all(&root).ok(); } // ------------------------------------------------------------------ // Adversarial probes: hostile filesystem objects inside the legacy // tree that could turn a benign cross-device migration into a hang, // a silent data loss, or an unhelpful panic. These exercise the // copy_dir_recursive fall-through after the symlink and directory // branches have been ruled out. Same-filesystem rename via // `std::fs::rename` is atomic and bypasses these paths entirely; we // only need to defend the EXDEV copy fallback. // ------------------------------------------------------------------ #[cfg(unix)] #[test] fn copy_dir_recursive_rejects_fifo_in_legacy_tree_without_hanging() { // A FIFO inside the legacy tree must NOT cause copy_dir_recursive // to block on std::fs::copy (open-for-read on a FIFO with no // writer blocks indefinitely). The hardened branch surfaces an // Unsupported error naming the offending path, leaving the // partial destination on disk for the user to consult before // retrying. let root = unique_tmp("fifo-rejected"); let src = root.join("legacy"); let dst = root.join("new"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("regular.txt"), b"normal-data").unwrap(); let fifo_path = src.join("debug-pipe"); // mkfifo via the system binary keeps the test free of an extra // libc dev-dependency. The migration only needs the FIFO to be // present on disk so file_type().is_fifo() returns true. let status = std::process::Command::new("mkfifo") .arg(&fifo_path) .status() .expect("mkfifo invocation must run on a unix test host"); assert!(status.success(), "mkfifo must succeed"); // Bound the test against the regression: if a future refactor // re-introduces the std::fs::copy fall-through, the FIFO read // would hang forever and stall CI. We run copy_dir_recursive // on a worker thread and require it to return within a tight // budget; a slow CI host gets 5 seconds, which is many orders // of magnitude above the expected ~ms return. let src_owned = src.clone(); let dst_owned = dst.clone(); let handle = std::thread::spawn(move || copy_dir_recursive(&src_owned, &dst_owned)); let start = std::time::Instant::now(); let result = loop { if handle.is_finished() { break handle.join().expect("worker thread must not panic"); } if start.elapsed() > std::time::Duration::from_secs(5) { panic!( "copy_dir_recursive hung on a FIFO inside the legacy tree; \ suspected regression in the non-regular fall-through guard" ); } std::thread::sleep(std::time::Duration::from_millis(10)); }; let err = result.expect_err("FIFO inside legacy tree must surface an error"); assert_eq!(err.kind(), std::io::ErrorKind::Unsupported); let msg = err.to_string(); assert!( msg.contains("debug-pipe"), "error must name the offending path: {msg}" ); // The partial destination may or may not contain the regular // file depending on read_dir iteration order; we don't assert // either way. What matters is that the migration returns an // error rather than blocking forever. std::fs::remove_dir_all(&root).ok(); } #[cfg(unix)] #[test] fn copy_dir_recursive_surfaces_permission_error_on_unreadable_file() { // A legacy file with mode 0000 inside the tree must cause // copy_dir_recursive to fail loud with PermissionDenied, not // silently skip the file (which would orphan user data). The // user can chmod the file and retry. use std::os::unix::fs::PermissionsExt; let root = unique_tmp("unreadable-file"); let src = root.join("legacy"); let dst = root.join("new"); std::fs::create_dir_all(&src).unwrap(); let locked = src.join("locked.db"); std::fs::write(&locked, b"sensitive").unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); // Belt-and-braces against test-running-as-root: root bypasses // DAC permissions and would silently succeed, masking the // regression. Skip the assertion in that case so the test is // honest about what it proved. let running_as_root = effective_uid() == 0; let result = copy_dir_recursive(&src, &dst); // Restore permissions before TempDir cleanup, regardless of // the test outcome, so the tempdir teardown doesn't itself // hit EACCES. std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).ok(); if running_as_root { // Document the bypass; the test still has value in CI // where the runner is non-root. assert!( result.is_ok() || result.is_err(), "test result intentionally not asserted under euid 0" ); } else { let err = result.expect_err("unreadable file must surface an error"); assert_eq!( err.kind(), std::io::ErrorKind::PermissionDenied, "expected PermissionDenied, got {err:?}" ); } std::fs::remove_dir_all(&root).ok(); } /// Read the effective uid by inspecting a freshly-created file's /// owner. Used by the permission-denied probe to skip its core /// assertion when the test host runs as root (root bypasses DAC). /// Direct over a libc dev-dependency for one helper. #[cfg(unix)] fn effective_uid() -> u32 { use std::os::unix::fs::MetadataExt; let tmp = std::env::temp_dir().join(format!("euid-probe-{}", std::process::id())); std::fs::write(&tmp, b"x").unwrap(); let uid = std::fs::metadata(&tmp).unwrap().uid(); std::fs::remove_file(&tmp).ok(); uid } #[cfg(unix)] #[test] fn copy_dir_recursive_preserves_dangling_symlink_target() { // A legacy symlink pointing at a since-deleted target must be // recreated as a dangling symlink at the destination — NOT // dereferenced (which would fail) and NOT skipped (which would // silently drop a piece of the user's directory shape). let root = unique_tmp("dangling-symlink"); let src = root.join("legacy"); let dst = root.join("new"); std::fs::create_dir_all(&src).unwrap(); std::os::unix::fs::symlink("/no/such/path/ever", src.join("orphan")).unwrap(); copy_dir_recursive(&src, &dst).expect("dangling symlink must not abort the copy"); let orphan = dst.join("orphan"); let meta = std::fs::symlink_metadata(&orphan).expect("orphan must exist as a link"); assert!( meta.file_type().is_symlink(), "dst/orphan must be a symlink, not a regular file or directory" ); let link_target = std::fs::read_link(&orphan).unwrap(); assert_eq!( link_target, std::path::PathBuf::from("/no/such/path/ever"), "symlink target must be preserved verbatim" ); std::fs::remove_dir_all(&root).ok(); } }