Skip to content

scan records relative_path against the scan root, not the volume root #191

Description

@utof

Summary

ScanUseCase writes file_locations.relative_path relative to the scan root, but writes volume_mounts.mount_path as the volume mount point. Any scan where those two differ produces rows whose absolute path cannot be reconstructed.

Root cause

Scanner::walk takes the two as distinct parameters, and documents the distinction (crates/core/src/ports/scanner.rs:42):

fn walk<'a>(
    &'a self,
    root: &Path,
    volume_root: &Path,   // "used to compute each file's relative path"
) -> Result<Box<dyn Iterator<Item = DiscoveredFile> + Send + 'a>, CoreError>;

crates/app/src/scan.rs:480 passes the same value for both:

self.scanner.walk(&canonical_root, &canonical_root)?

Yet ~10 lines earlier (scan.rs:466-471) the real mount point is already resolved and persisted:

let detected = perima_fs::detect_volume(&canonical_root)?;
let vol_id = self.volumes.find_or_create(&detected.identifiers, device_id)?;
self.volumes.record_mount(vol_id, device_id, &detected.mount_point)?;

So the two halves of the reconstruction in crates/desktop/src/payloads.rs:247 disagree:

let absolute_path = mount_path.as_ref().map(|mp| {
    let mut p = std::path::PathBuf::from(mp);
    p.push(loc.relative_path.as_str());   // relative to scan root, not mount_path
    p.to_string_lossy().into_owned()
});

Reproduction

Scan a subdirectory of a mount (the normal case — the mount point is rarely the directory you want indexed):

  • mount point → /mnt/data
  • scan root → /mnt/data/Videos
  • file → /mnt/data/Videos/clip.mp4
  • relative_path recorded → clip.mp4 (should be Videos/clip.mp4)
  • reconstructed → /mnt/data/clip.mp4

Observed in practice: of 419 indexed rows, 417 resolved to nonexistent paths. Surfaced downstream as a transcription failure, which is misleading — the transcriber is fine:

Could not decode audio from source: stat input: No such file or directory (os error 2)

relativize() itself (crates/fs/src/paths.rs:24) is correct and well-tested; only its caller is wrong.

Fix

Pass the resolved mount point as volume_root. volume_info is already computed before the walk, so no reordering is needed — dry-run (which detects no volume) falls back to the scan root:

let volume_root = volume_info
    .as_ref()
    .map_or(canonical_root.as_path(), |(_, _, mount)| mount.as_path());
let discovered: Vec<DiscoveredFile> = self
    .scanner
    .walk(&canonical_root, volume_root)?

Why the tests missed it

Every scan test sets scan root == volume root (tempfile roots are their own mount), so walk(root, root) is correct in the fixture and wrong in production. The bug is invisible to any test that doesn't scan a strict subdirectory of a mount.

A regression test must scan a subdirectory and assert the recorded relative_path includes the intermediate segment.

Blocks

Blocks the location verify-sweep work (feat/location-verify-and-prune). A verify sweep stats each reconstructed absolute path and marks unreachable rows Missing — run against a catalogue built by the current scan, it would mark almost every row Missing and hand a prune button a full-catalogue delete. This must land first.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/cliTouches the perima CLI binarypriority/highHigh-priority work — block other features until this landstype/bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions