agent: code-atomiser-fix — paths.rs multi-legacy-candidate migration + copy_dir_recursive symlink loop

Two reversibility defects in `crates/core/src/paths.rs`:

Defect A (multi-legacy-candidate orphan):
`resolve_app_data_dir` and `legacy_and_target_paths` short-circuited
on the first legacy candidate, allowing two reachable orphan scenarios
on Linux. With both `~/.magnotia` and `~/.local/share/magnotia` the
shim migrated only the dot-home variant, leaving the XDG legacy
invisible forever. With a stray `~/.lumotia` alongside a freshly
migrated `~/.local/share/lumotia`, the resolver kept returning the
dot-home path, orphaning the XDG target.

`legacy_and_target_paths` now returns `Vec<(legacy, target)>`,
probing every legacy variant the platform supports. The migration
driver in `src-tauri/src/lib.rs` loops over the Vec and emits
per-candidate tracing. A new `resolve_app_data_dir_strict` +
`check_target_ambiguity` API refuses to start when more than one
target candidate exists post-migration, surfacing both paths to the
user via the setup hook instead of silently picking one.

Regression tests: `migrate_handles_both_dot_home_and_xdg`,
`resolve_app_data_dir_refuses_on_multiple_targets`.

Defect B (copy_dir_recursive symlink loop on EXDEV migration):
`entry.metadata()` follows symlinks, so a directory symlink reported
is_dir==true and recursed unconditionally. A self-referential or
ancestor-targeting directory symlink would loop until the disk
filled. Switched to `entry.file_type()` (symlink-aware), re-ordered
branches so `is_symlink()` is checked first, and routed all
symlinks through symlink-creation (Unix + Windows) rather than
recursive copy.

Regression tests:
`copy_dir_recursive_does_not_loop_on_self_referential_dir_symlink`,
`copy_dir_recursive_preserves_directory_symlinks`.

14/14 paths tests green. Full workspace cargo test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 14:29:28 +01:00
parent ab5f6ab995
commit 6ca94cbff0
2 changed files with 549 additions and 99 deletions

View File

@@ -234,16 +234,28 @@ pub fn run() {
// settings behind a fresh empty lumotia dir.
let t_migrate = Instant::now();
match migrate_legacy_data_dir() {
Ok(MigrationStatus::Migrated { from, to, renamed_db }) => tracing::info!(
target: "lumotia_startup",
elapsed_ms = t_migrate.elapsed().as_millis(),
from = %from.display(),
to = %to.display(),
renamed_db,
"migrated legacy magnotia data dir to lumotia"
),
Ok(MigrationStatus::TargetAlreadyExists { .. }) => {}
Ok(MigrationStatus::NoLegacyFound) => {}
Ok(statuses) => {
// Drive every legacy candidate independently: on Linux a
// user may have both `~/.magnotia` and
// `~/.local/share/magnotia`, and migrating only one
// would orphan the other forever.
for status in &statuses {
match status {
MigrationStatus::Migrated { from, to, renamed_db } => {
tracing::info!(
target: "lumotia_startup",
elapsed_ms = t_migrate.elapsed().as_millis(),
from = %from.display(),
to = %to.display(),
renamed_db = *renamed_db,
"migrated legacy magnotia data dir to lumotia"
);
}
MigrationStatus::TargetAlreadyExists { .. } => {}
MigrationStatus::NoLegacyFound => {}
}
}
}
Err(e) => {
tracing::error!(
target: "lumotia_startup",
@@ -254,6 +266,19 @@ pub fn run() {
}
}
// After migration, refuse to start if more than one lumotia
// target candidate exists on disk (e.g. both `~/.lumotia` AND
// `~/.local/share/lumotia`). Silently picking one would point
// the app at the wrong half of a split data directory.
if let Err(amb) = check_target_ambiguity() {
tracing::error!(
target: "lumotia_startup",
error = %amb,
"ambiguous lumotia data directory — refusing to start"
);
return Err(Box::new(amb) as Box<dyn std::error::Error>);
}
// Initialise database and startup settings in one runtime entry.
let db_path = database_path();
let (db, init_script) = tauri::async_runtime::block_on(async {