Phase A of dogfood verification for the Magnotia -> Lumotia rebrand
cascade. The existing in-crate unit tests prove the migration copies
bytes correctly; this commit closes the gaps an atomiser-grade review
would flag.
Phase A.1 — end-to-end integration test (crates/storage/tests/legacy_db_migration.rs):
Seeds a real on-disk magnotia.db via lumotia_storage::init (which runs
every schema migration head-to-tail), inserts a transcript via the
public API, drops the pool, runs migrate_legacy_data_dir_with_pairs,
then re-opens the migrated lumotia.db and asserts the transcript is
queryable. Three scenarios covered:
1. Legacy-only -> migrate -> reopen -> row survives. Also verifies a
non-DB companion file is carried along by the directory rename.
2. Idempotency: first boot migrates, user writes new data, second
boot is a no-op and BOTH rows survive.
3. Both-paths-present: refuses to merge, target's empty DB is
preserved, legacy retained on disk as a backup.
Wires the test surface by renaming the previously-private
migrate_legacy_data_dir_inner to pub migrate_legacy_data_dir_with_pairs
(mirroring migrate_tauri_app_data_dir_with_paths in the sibling
tauri_app_data_migration module).
Phase A.2a — copy_dir_recursive hardening (crates/core/src/paths.rs):
Pre-existing footgun: the fall-through branch called std::fs::copy()
on any DirEntry that was not a symlink or a directory. On Unix that
includes FIFOs, sockets, and char/block device nodes. Opening a FIFO
for read with no writer attached blocks forever — a stale debug FIFO
in the user's ~/.magnotia tree would silently hang first launch.
The branch now explicitly distinguishes is_file() (real regular file
-> copy) from anything else (-> Err with ErrorKind::Unsupported,
naming the offending path). Migration becomes re-runnable once the
user cleans up the offending node. Same-filesystem rename via
std::fs::rename is atomic and unaffected; only the EXDEV fallback path
touches the new guard.
Phase A.2b — three adversarial probes (crates/core/src/paths.rs tests):
- FIFO inside the legacy tree: copy_dir_recursive must return an
Unsupported error WITHOUT hanging. Test bounded by a 5s wall clock
+ a worker thread so a regression to the old fall-through would
surface as a panic, not a stalled CI job.
- Unreadable file (mode 0000): copy_dir_recursive must surface
PermissionDenied, not silently skip. Skips its core assertion under
euid 0 (root bypasses DAC permissions, would mask the regression).
- Dangling symlink (target nonexistent): symlink is recreated at
destination with link target preserved verbatim; the migration
does NOT try to dereference and does NOT abort the rest of the copy.
Verification:
- cargo fmt --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 409 passed, 0 failed (up from 405 pre-commit;
3 storage integration tests + 3 paths adversarial + 1 net carry-over)
1165 lines
45 KiB
Rust
1165 lines
45 KiB
Rust
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<PathBuf>,
|
|
}
|
|
|
|
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::<Vec<_>>()
|
|
.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<PathBuf, TargetAmbiguityError> {
|
|
let candidates = target_data_dir_candidates();
|
|
let existing: Vec<PathBuf> = 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<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() {
|
|
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<Vec<MigrationStatus>, 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<Vec<MigrationStatus>, 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<MigrationStatus, std::io::Error> {
|
|
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<bool, std::io::Error> {
|
|
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<MigrationStatus, std::io::Error> {
|
|
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();
|
|
}
|
|
}
|