agent: lumotia — Phase A.1+A.2 rebrand migration tests + copy_dir_recursive hardening
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)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2892,6 +2892,7 @@ dependencies = [
|
|||||||
"lumotia-core",
|
"lumotia-core",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -115,11 +115,7 @@ fn resolve_app_data_dir() -> PathBuf {
|
|||||||
/// relying on the panic that backs the infallible `resolve_app_data_dir`.
|
/// relying on the panic that backs the infallible `resolve_app_data_dir`.
|
||||||
pub fn resolve_app_data_dir_strict() -> Result<PathBuf, TargetAmbiguityError> {
|
pub fn resolve_app_data_dir_strict() -> Result<PathBuf, TargetAmbiguityError> {
|
||||||
let candidates = target_data_dir_candidates();
|
let candidates = target_data_dir_candidates();
|
||||||
let existing: Vec<PathBuf> = candidates
|
let existing: Vec<PathBuf> = candidates.iter().filter(|p| p.exists()).cloned().collect();
|
||||||
.iter()
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if existing.len() > 1 {
|
if existing.len() > 1 {
|
||||||
return Err(TargetAmbiguityError {
|
return Err(TargetAmbiguityError {
|
||||||
candidates: existing,
|
candidates: existing,
|
||||||
@@ -382,17 +378,19 @@ fn legacy_and_target_paths() -> Vec<(PathBuf, PathBuf)> {
|
|||||||
/// target (same convention) and rename `magnotia.db` -> `lumotia.db`
|
/// target (same convention) and rename `magnotia.db` -> `lumotia.db`
|
||||||
/// inside it if found.
|
/// inside it if found.
|
||||||
pub fn migrate_legacy_data_dir() -> Result<Vec<MigrationStatus>, std::io::Error> {
|
pub fn migrate_legacy_data_dir() -> Result<Vec<MigrationStatus>, std::io::Error> {
|
||||||
migrate_legacy_data_dir_inner(legacy_and_target_paths())
|
migrate_legacy_data_dir_with_pairs(legacy_and_target_paths())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-friendly inner shape: takes the list of (legacy, target) pairs
|
/// Driver that takes the list of (legacy, target) pairs explicitly so
|
||||||
/// explicitly so tests don't depend on platform-specific HOME /
|
/// callers can substitute synthetic paths. Production path goes through
|
||||||
/// LOCALAPPDATA / XDG env vars.
|
/// [`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
|
/// An empty input is shorthand for "no legacy on disk" and yields a
|
||||||
/// single [`MigrationStatus::NoLegacyFound`] entry so callers can still
|
/// single [`MigrationStatus::NoLegacyFound`] entry so callers can still
|
||||||
/// rely on a non-empty result to drive their logging.
|
/// rely on a non-empty result to drive their logging.
|
||||||
fn migrate_legacy_data_dir_inner(
|
pub fn migrate_legacy_data_dir_with_pairs(
|
||||||
pairs: Vec<(PathBuf, PathBuf)>,
|
pairs: Vec<(PathBuf, PathBuf)>,
|
||||||
) -> Result<Vec<MigrationStatus>, std::io::Error> {
|
) -> Result<Vec<MigrationStatus>, std::io::Error> {
|
||||||
if pairs.is_empty() {
|
if pairs.is_empty() {
|
||||||
@@ -517,8 +515,26 @@ pub fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), std::io::Error>
|
|||||||
}
|
}
|
||||||
} else if file_type.is_dir() {
|
} else if file_type.is_dir() {
|
||||||
copy_dir_recursive(&entry_path, &target_path)?;
|
copy_dir_recursive(&entry_path, &target_path)?;
|
||||||
} else {
|
} else if file_type.is_file() {
|
||||||
std::fs::copy(&entry_path, &target_path)?;
|
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(())
|
Ok(())
|
||||||
@@ -582,10 +598,8 @@ mod tests {
|
|||||||
/// Helper: drive the migration with a single (legacy, target) pair
|
/// Helper: drive the migration with a single (legacy, target) pair
|
||||||
/// and return the (only) status it produced. Keeps existing tests
|
/// and return the (only) status it produced. Keeps existing tests
|
||||||
/// readable after the Option -> Vec API change.
|
/// readable after the Option -> Vec API change.
|
||||||
fn migrate_one_pair_inner(
|
fn migrate_one_pair_inner(pair: (PathBuf, PathBuf)) -> Result<MigrationStatus, std::io::Error> {
|
||||||
pair: (PathBuf, PathBuf),
|
let mut statuses = migrate_legacy_data_dir_with_pairs(vec![pair])?;
|
||||||
) -> Result<MigrationStatus, std::io::Error> {
|
|
||||||
let mut statuses = migrate_legacy_data_dir_inner(vec![pair])?;
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
statuses.len(),
|
statuses.len(),
|
||||||
1,
|
1,
|
||||||
@@ -603,8 +617,7 @@ mod tests {
|
|||||||
std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap();
|
std::fs::write(legacy.join("magnotia.db"), b"sqlite-stub").unwrap();
|
||||||
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
MigrationStatus::Migrated {
|
MigrationStatus::Migrated {
|
||||||
@@ -640,8 +653,7 @@ mod tests {
|
|||||||
std::fs::write(target.join("lumotia.db"), b"new-data").unwrap();
|
std::fs::write(target.join("lumotia.db"), b"new-data").unwrap();
|
||||||
std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap();
|
std::fs::write(legacy.join("magnotia.db"), b"legacy-data").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -662,7 +674,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migrate_with_neither_present_returns_no_legacy() {
|
fn migrate_with_neither_present_returns_no_legacy() {
|
||||||
let result = migrate_legacy_data_dir_inner(Vec::new()).expect("migrate ok");
|
let result = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("migrate ok");
|
||||||
|
|
||||||
assert_eq!(result, vec![MigrationStatus::NoLegacyFound]);
|
assert_eq!(result, vec![MigrationStatus::NoLegacyFound]);
|
||||||
}
|
}
|
||||||
@@ -675,8 +687,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&legacy).unwrap();
|
std::fs::create_dir_all(&legacy).unwrap();
|
||||||
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
std::fs::write(legacy.join("recordings.placeholder"), b"x").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
MigrationStatus::Migrated { renamed_db, .. } => {
|
MigrationStatus::Migrated { renamed_db, .. } => {
|
||||||
@@ -700,8 +711,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&legacy).unwrap();
|
std::fs::create_dir_all(&legacy).unwrap();
|
||||||
std::fs::write(legacy.join("magnotia.db"), b"data").unwrap();
|
std::fs::write(legacy.join("magnotia.db"), b"data").unwrap();
|
||||||
|
|
||||||
let result =
|
let result = migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
||||||
migrate_one_pair_inner((legacy.clone(), target.clone())).expect("migrate ok");
|
|
||||||
|
|
||||||
assert!(matches!(result, MigrationStatus::Migrated { .. }));
|
assert!(matches!(result, MigrationStatus::Migrated { .. }));
|
||||||
assert!(target.exists());
|
assert!(target.exists());
|
||||||
@@ -721,16 +731,15 @@ mod tests {
|
|||||||
std::fs::create_dir_all(src.join("recordings/2026-05")).unwrap();
|
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::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("magnotia.db"), b"sqlite-bytes").unwrap();
|
||||||
std::fs::write(
|
std::fs::write(src.join("recordings/2026-05/clip-001.wav"), b"wav-bytes").unwrap();
|
||||||
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();
|
std::fs::write(src.join("models/whisper-base-en/manifest.json"), b"{}").unwrap();
|
||||||
|
|
||||||
copy_dir_recursive(&src, &dst).expect("copy ok");
|
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("magnotia.db")).unwrap(),
|
||||||
|
b"sqlite-bytes"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(),
|
std::fs::read(dst.join("recordings/2026-05/clip-001.wav")).unwrap(),
|
||||||
b"wav-bytes"
|
b"wav-bytes"
|
||||||
@@ -806,7 +815,7 @@ mod tests {
|
|||||||
std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap();
|
std::fs::write(dot_legacy.join("marker"), b"dot-home").unwrap();
|
||||||
std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap();
|
std::fs::write(xdg_legacy.join("marker"), b"xdg").unwrap();
|
||||||
|
|
||||||
let statuses = migrate_legacy_data_dir_inner(vec![
|
let statuses = migrate_legacy_data_dir_with_pairs(vec![
|
||||||
(dot_legacy.clone(), dot_target.clone()),
|
(dot_legacy.clone(), dot_target.clone()),
|
||||||
(xdg_legacy.clone(), xdg_target.clone()),
|
(xdg_legacy.clone(), xdg_target.clone()),
|
||||||
])
|
])
|
||||||
@@ -975,7 +984,7 @@ mod tests {
|
|||||||
let dst_link_entry = entries
|
let dst_link_entry = entries
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| e.as_ref().ok())
|
.find_map(|e| e.as_ref().ok())
|
||||||
.filter(|e| e.file_name() == std::ffi::OsString::from("link"));
|
.filter(|e| e.file_name() == "link");
|
||||||
if let Some(e) = dst_link_entry {
|
if let Some(e) = dst_link_entry {
|
||||||
assert!(
|
assert!(
|
||||||
e.file_type().unwrap().is_symlink(),
|
e.file_type().unwrap().is_symlink(),
|
||||||
@@ -985,4 +994,171 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&root).ok();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,12 @@ thiserror = "1"
|
|||||||
|
|
||||||
# UUIDs for profile + profile_terms ids (v7 random).
|
# UUIDs for profile + profile_terms ids (v7 random).
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Real-file tempdirs for integration tests that need an on-disk DB (the
|
||||||
|
# in-memory `sqlite::memory:` connection in src/database.rs unit tests
|
||||||
|
# doesn't cover the rename-then-reopen path the rebrand migration exercises).
|
||||||
|
tempfile = "3"
|
||||||
|
# `rt-multi-thread` is required for the `#[tokio::test(flavor = "multi_thread")]`
|
||||||
|
# variants that drive blocking lumotia_core::paths migrations from async tests.
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros"] }
|
||||||
|
|||||||
282
crates/storage/tests/legacy_db_migration.rs
Normal file
282
crates/storage/tests/legacy_db_migration.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
//! End-to-end migration test for the Magnotia -> Lumotia rebrand.
|
||||||
|
//!
|
||||||
|
//! The in-crate unit tests in `crates/core/src/paths.rs` prove that the
|
||||||
|
//! migration copies bytes and renames files correctly, but they don't
|
||||||
|
//! verify that the resulting `lumotia.db` is *openable* by
|
||||||
|
//! `lumotia-storage`. A byte-perfect copy with a torn SQLite header,
|
||||||
|
//! a stale WAL pointer, or a row count off by one would still pass
|
||||||
|
//! those tests. This integration test closes that gap by:
|
||||||
|
//!
|
||||||
|
//! 1. Seeding a real on-disk `magnotia/magnotia.db` via the public
|
||||||
|
//! `lumotia_storage::init` API (which runs every schema migration
|
||||||
|
//! head-to-tail). A real transcript row is inserted via
|
||||||
|
//! `insert_transcript`, then the pool is dropped to flush + close.
|
||||||
|
//! 2. Running the rebrand migration via
|
||||||
|
//! `lumotia_core::paths::migrate_legacy_data_dir_with_pairs` with
|
||||||
|
//! the synthesised (legacy, target) pair.
|
||||||
|
//! 3. Re-opening the migrated `lumotia/lumotia.db` via `init`
|
||||||
|
//! (which re-runs migrations — they must be no-ops against the
|
||||||
|
//! already-migrated schema) and querying for the inserted
|
||||||
|
//! transcript by id.
|
||||||
|
//!
|
||||||
|
//! If any of the three steps fails — rename, reopen, or query — the
|
||||||
|
//! migration is unsafe to ship even though the unit tests pass.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use lumotia_core::paths::{migrate_legacy_data_dir_with_pairs, MigrationStatus};
|
||||||
|
use lumotia_storage::{
|
||||||
|
get_transcript, init, insert_transcript, list_transcripts, InsertTranscriptParams,
|
||||||
|
DEFAULT_PROFILE_ID,
|
||||||
|
};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const SEEDED_TRANSCRIPT_ID: &str = "t-rebrand-survivor";
|
||||||
|
const SEEDED_TRANSCRIPT_TEXT: &str =
|
||||||
|
"Migration sentinel row. If this read fails, the rebrand orphaned user data.";
|
||||||
|
const SEEDED_TRANSCRIPT_TITLE: &str = "rebrand-survivor";
|
||||||
|
|
||||||
|
/// Drop the pool and yield long enough for sqlx's background reaper to
|
||||||
|
/// close its connections. The integration test re-opens the migrated DB
|
||||||
|
/// immediately afterwards, so any sqlx-held file descriptor on Windows
|
||||||
|
/// would block the rename. tokio's `yield_now` is a single scheduler
|
||||||
|
/// hop, not a sleep — enough to flush the pool's tokio task queue.
|
||||||
|
async fn drop_and_yield(pool: sqlx::SqlitePool) {
|
||||||
|
pool.close().await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the canonical seed-row params. Centralised so the assertions
|
||||||
|
/// in each test can reference the same expected values without drift.
|
||||||
|
fn seed_params() -> InsertTranscriptParams<'static> {
|
||||||
|
InsertTranscriptParams {
|
||||||
|
id: SEEDED_TRANSCRIPT_ID,
|
||||||
|
text: SEEDED_TRANSCRIPT_TEXT,
|
||||||
|
source: "microphone",
|
||||||
|
profile_id: DEFAULT_PROFILE_ID,
|
||||||
|
title: Some(SEEDED_TRANSCRIPT_TITLE),
|
||||||
|
audio_path: None,
|
||||||
|
duration: 2.5,
|
||||||
|
engine: Some("whisper"),
|
||||||
|
model_id: Some("whisper-tiny-en"),
|
||||||
|
inference_ms: Some(420),
|
||||||
|
sample_rate: Some(16_000),
|
||||||
|
audio_channels: Some(1),
|
||||||
|
format_mode: Some("plain"),
|
||||||
|
remove_fillers: false,
|
||||||
|
british_english: true,
|
||||||
|
anti_hallucination: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed `<legacy>/magnotia.db` with the live schema head and one
|
||||||
|
/// transcript row. Returns nothing; the asserts live in the test body.
|
||||||
|
async fn seed_legacy_db(legacy_dir: &Path) {
|
||||||
|
std::fs::create_dir_all(legacy_dir).expect("create legacy dir");
|
||||||
|
let legacy_db = legacy_dir.join("magnotia.db");
|
||||||
|
|
||||||
|
let pool = init(&legacy_db).await.expect("init legacy magnotia.db");
|
||||||
|
insert_transcript(&pool, &seed_params())
|
||||||
|
.await
|
||||||
|
.expect("insert seed transcript into legacy db");
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
legacy_db.exists(),
|
||||||
|
"legacy magnotia.db should be on disk after pool drop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn legacy_db_survives_rebrand_and_remains_queryable() {
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let legacy = tmp.path().join("magnotia");
|
||||||
|
let target = tmp.path().join("lumotia");
|
||||||
|
|
||||||
|
seed_legacy_db(&legacy).await;
|
||||||
|
|
||||||
|
// Bonus: drop a non-DB file inside the legacy tree to confirm the
|
||||||
|
// migration sweeps the whole directory, not just the .db file.
|
||||||
|
let companion = legacy
|
||||||
|
.join("recordings")
|
||||||
|
.join("2026-05-13")
|
||||||
|
.join("clip.wav");
|
||||||
|
std::fs::create_dir_all(companion.parent().unwrap()).expect("nested dir");
|
||||||
|
std::fs::write(&companion, b"fake-wav-bytes").expect("write companion file");
|
||||||
|
|
||||||
|
// Migrate. Blocking I/O is fine inside the multi-thread flavour.
|
||||||
|
let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())])
|
||||||
|
.expect("migration must not error");
|
||||||
|
|
||||||
|
assert_eq!(statuses.len(), 1, "single pair must yield single status");
|
||||||
|
match &statuses[0] {
|
||||||
|
MigrationStatus::Migrated {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
renamed_db,
|
||||||
|
} => {
|
||||||
|
assert_eq!(from, &legacy);
|
||||||
|
assert_eq!(to, &target);
|
||||||
|
assert!(renamed_db, "magnotia.db must have been renamed");
|
||||||
|
}
|
||||||
|
other => panic!("expected Migrated, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!legacy.exists(), "legacy dir should be gone after rename");
|
||||||
|
assert!(target.exists(), "target dir should exist after rename");
|
||||||
|
let migrated_db = target.join("lumotia.db");
|
||||||
|
assert!(migrated_db.exists(), "lumotia.db must be at new path");
|
||||||
|
assert!(
|
||||||
|
!target.join("magnotia.db").exists(),
|
||||||
|
"old db filename must not survive at new path"
|
||||||
|
);
|
||||||
|
let migrated_companion = target
|
||||||
|
.join("recordings")
|
||||||
|
.join("2026-05-13")
|
||||||
|
.join("clip.wav");
|
||||||
|
assert!(
|
||||||
|
migrated_companion.exists(),
|
||||||
|
"non-DB files inside legacy must be carried along"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&migrated_companion).expect("read migrated companion"),
|
||||||
|
b"fake-wav-bytes",
|
||||||
|
"non-DB file contents must survive verbatim"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-open via the same public API a fresh app boot would use. This
|
||||||
|
// also re-runs `run_migrations`, which must be idempotent against
|
||||||
|
// the already-migrated schema.
|
||||||
|
let pool = init(&migrated_db)
|
||||||
|
.await
|
||||||
|
.expect("init migrated lumotia.db must succeed");
|
||||||
|
|
||||||
|
let found = get_transcript(&pool, SEEDED_TRANSCRIPT_ID)
|
||||||
|
.await
|
||||||
|
.expect("get_transcript must not error against migrated db")
|
||||||
|
.expect("seeded transcript must survive the migration");
|
||||||
|
|
||||||
|
assert_eq!(found.id, SEEDED_TRANSCRIPT_ID);
|
||||||
|
assert_eq!(found.text, SEEDED_TRANSCRIPT_TEXT);
|
||||||
|
assert_eq!(found.title.as_deref(), Some(SEEDED_TRANSCRIPT_TITLE));
|
||||||
|
|
||||||
|
// And the list view sees exactly one row, ruling out duplicates or
|
||||||
|
// schema-rebuild-from-empty (which would yield zero).
|
||||||
|
let listed = list_transcripts(&pool, 100)
|
||||||
|
.await
|
||||||
|
.expect("list_transcripts must not error against migrated db");
|
||||||
|
assert_eq!(
|
||||||
|
listed.len(),
|
||||||
|
1,
|
||||||
|
"exactly one transcript should be visible post-migration"
|
||||||
|
);
|
||||||
|
assert_eq!(listed[0].id, SEEDED_TRANSCRIPT_ID);
|
||||||
|
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn rerun_after_migration_is_idempotent_and_preserves_user_state() {
|
||||||
|
// First boot: migrate. Second boot: legacy preserved? Per the design
|
||||||
|
// the legacy DOES NOT preserve (rename moves it; see paths.rs
|
||||||
|
// rename_or_copy_tree). So the second run sees no legacy and yields
|
||||||
|
// NoLegacyFound. This test exists to nail down that contract — if a
|
||||||
|
// future change starts copying-rather-than-renaming, the test will
|
||||||
|
// catch the subsequent loss of idempotency.
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let legacy = tmp.path().join("magnotia");
|
||||||
|
let target = tmp.path().join("lumotia");
|
||||||
|
|
||||||
|
seed_legacy_db(&legacy).await;
|
||||||
|
|
||||||
|
let first = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())])
|
||||||
|
.expect("first migration");
|
||||||
|
assert!(matches!(first[0], MigrationStatus::Migrated { .. }));
|
||||||
|
|
||||||
|
// User immediately starts using the app: writes new data to the
|
||||||
|
// migrated DB. The follow-up reboot must NOT clobber this.
|
||||||
|
let pool = init(&target.join("lumotia.db"))
|
||||||
|
.await
|
||||||
|
.expect("post-migrate init");
|
||||||
|
insert_transcript(
|
||||||
|
&pool,
|
||||||
|
&InsertTranscriptParams {
|
||||||
|
id: "t-post-migration",
|
||||||
|
text: "Written after the rebrand boot.",
|
||||||
|
..seed_params()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("insert new row post-migration");
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
// Second boot: no legacy on disk, nothing to do. Note empty input
|
||||||
|
// yields a single NoLegacyFound entry by contract.
|
||||||
|
let second = migrate_legacy_data_dir_with_pairs(Vec::new()).expect("second migration");
|
||||||
|
assert_eq!(second, vec![MigrationStatus::NoLegacyFound]);
|
||||||
|
|
||||||
|
// Open and confirm both rows present + user's post-migration write
|
||||||
|
// intact.
|
||||||
|
let pool = init(&target.join("lumotia.db"))
|
||||||
|
.await
|
||||||
|
.expect("reopen after second boot");
|
||||||
|
let listed = list_transcripts(&pool, 100).await.expect("list");
|
||||||
|
let ids: Vec<_> = listed.iter().map(|r| r.id.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
ids.contains(&SEEDED_TRANSCRIPT_ID),
|
||||||
|
"pre-migration row should survive second boot: {ids:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ids.contains(&"t-post-migration"),
|
||||||
|
"post-migration row should survive second boot: {ids:?}"
|
||||||
|
);
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn both_paths_present_refuses_to_overwrite_user_data() {
|
||||||
|
// Reproduces the "stale legacy + freshly-installed lumotia" scenario:
|
||||||
|
// user reinstalls after deleting their data dir manually, or runs an
|
||||||
|
// older + newer build side-by-side. The migration must NOT clobber
|
||||||
|
// the lumotia-side DB even if the legacy contains a richer one —
|
||||||
|
// user authority on what's authoritative.
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let legacy = tmp.path().join("magnotia");
|
||||||
|
let target = tmp.path().join("lumotia");
|
||||||
|
|
||||||
|
// Both DBs exist; the legacy has the seeded row, the target is
|
||||||
|
// freshly initialised with no rows.
|
||||||
|
seed_legacy_db(&legacy).await;
|
||||||
|
let pool = init(&target.join("lumotia.db")).await.expect("seed target");
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
let statuses = migrate_legacy_data_dir_with_pairs(vec![(legacy.clone(), target.clone())])
|
||||||
|
.expect("migration must not error in both-exist case");
|
||||||
|
|
||||||
|
assert_eq!(statuses.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
statuses[0],
|
||||||
|
MigrationStatus::TargetAlreadyExists {
|
||||||
|
target: target.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Critical: target's empty DB must be untouched.
|
||||||
|
let pool = init(&target.join("lumotia.db"))
|
||||||
|
.await
|
||||||
|
.expect("reopen target post-decision");
|
||||||
|
let listed = list_transcripts(&pool, 100).await.expect("list");
|
||||||
|
assert_eq!(
|
||||||
|
listed.len(),
|
||||||
|
0,
|
||||||
|
"target's user-installed empty DB must not have been merged with legacy"
|
||||||
|
);
|
||||||
|
drop_and_yield(pool).await;
|
||||||
|
|
||||||
|
// And the legacy DB must still be on disk for manual recovery —
|
||||||
|
// we don't quietly delete user data the migration refused to copy.
|
||||||
|
assert!(
|
||||||
|
legacy.join("magnotia.db").exists(),
|
||||||
|
"legacy DB must be preserved as a backup when target exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user