Files
lightcraft/crates/engine/src/walk.rs
T
Claude 790a341776 Part of #374: launch imports run off the UI thread; folder walks are bounded and never take a drive root or home implicitly
Root cause (in 0.2.1 and still on main): files and folders named on the
command line were imported from the eframe app-creator closure with
`app.run("library.import", …)`, which runs the engine command synchronously:
`import::expand` walked the folder recursively (one `is_dir()` open per entry,
no depth or count bound, following links) and probed every file before the
first frame could be drawn. An argument that resolves to a drive root (`.` or
`..` with a launcher's working folder at `C:\`, `/` or `\` on Windows) or to
the home folder therefore walked the whole drive with the window stuck as a
black "not responding" box. The library-problem banner's pending import had
the same synchronous call. Every other recursive walk (Local's Include
Subfolders, Find Missing Photos, preset and LUT folders, MCP) was also
unbounded, which is the "Local folders with subfolders: not responding"
pattern of filmcraft#23, and a link loop recursed until the stack overflowed.

- engine `walk::files_in`: one bounded, iterative walker (32 folder levels,
  1,000,000 entries; `Walked::truncated` says it stopped), folder or file
  taken from the directory listing (no open per entry), links and junctions
  to a folder followed once. `import::expand` (+ `expand_within`), preset and
  LUT folder expansion and MCP `expand_paths` use it.
- engine `import::launch_path`: an empty launch argument is ignored; a
  relative one is resolved against the working folder; a drive or file-system
  root becomes its memory card's DCIM folder or is refused, and so is the home
  folder (a notice says why and to use File > Import Photos).
- desktop: launch arguments go through it and are imported with
  `import::start_paths` (the import worker), like dropped files; so does the
  library-problem banner's pending import.

Not verified on Windows (no machine here). The report says PhotoCraft 0.5.0
and `--memory` hang the same way with no arguments; nothing in LightCraft
walks the disk at startup without arguments (strace of a headless demo run
with HOME=/ and cwd /: no recursive listing), so that part may be outside
this code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 21:19:42 +00:00

184 lines
7.4 KiB
Rust

//! Bounded folder walks (issue #374): every recursive listing of the disk — import, Local's
//! Include Subfolders, Find Missing Photos, preset and LUT folders, MCP — goes through
//! [`files_in`], which stops at a depth and a number of directory entries instead of walking a
//! whole drive, follows a symbolic link or junction to a folder at most once (no loops), and
//! decides what is a folder from the directory listing itself (no extra open per entry, which
//! is what makes a large walk slow on Windows).
//!
//! It never chooses where to walk: callers pass a folder the user named. Nothing in the app walks
//! at startup or on the UI thread.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// How far a walk may go.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Folder levels below the starting folder that are listed (its own entries are level 1).
pub max_depth: usize,
/// Directory entries looked at in all (files and folders, hidden ones included).
pub max_entries: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits { max_depth: MAX_DEPTH, max_entries: MAX_ENTRIES }
}
}
/// Folder levels a walk descends at most; photo trees are a handful of levels deep.
pub const MAX_DEPTH: usize = 32;
/// Directory entries a walk looks at at most: far more than any photo folder holds, far fewer
/// than a whole system drive.
pub const MAX_ENTRIES: usize = 1_000_000;
/// What a walk found.
#[derive(Debug, Default, PartialEq)]
pub struct Walked {
/// The files `keep` accepted, in path order, folder by folder (depth first).
pub files: Vec<PathBuf>,
/// The walk stopped at a [`Limits`] bound: there may be more files.
pub truncated: bool,
/// Directory entries looked at.
pub entries: usize,
}
fn hidden(p: &Path) -> bool {
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
let mut out = Walked::default();
// canonical folders reached through a link, so a link loop is walked at most once
let mut linked: HashSet<PathBuf> = HashSet::new();
if let Ok(c) = std::fs::canonicalize(root) {
linked.insert(c);
}
// (path, depth of the folder it is in, is a folder); popped in path order
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
while let Some((path, depth, is_dir)) = stack.pop() {
if !is_dir {
if keep(&path) {
out.files.push(path);
}
continue;
}
if skip.is_some_and(|s| path == s) {
continue;
}
if depth >= limits.max_depth {
out.truncated = true;
continue;
}
let Ok(rd) = std::fs::read_dir(&path) else { continue };
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for e in rd.flatten() {
if out.entries >= limits.max_entries {
out.truncated = true;
break;
}
out.entries = out.entries.saturating_add(1);
let p = e.path();
if hidden(&p) {
continue;
}
let Ok(ft) = e.file_type() else { continue };
let dir = if ft.is_symlink() {
// a link (or a Windows junction): a folder is entered once, a file is a file
match std::fs::metadata(&p) {
Ok(m) if m.is_dir() => {
let Ok(c) = std::fs::canonicalize(&p) else { continue };
if !linked.insert(c) {
continue;
}
true
}
_ => false,
}
} else {
ft.is_dir()
};
children.push((p, dir));
}
children.sort();
let below = depth.saturating_add(1);
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-walk-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
let d = tmp("order");
for f in ["b/2.jpg", "a/1.jpg", "a/x/0.jpg", "c.jpg", ".hidden/h.jpg", "a/.h.jpg", "lib/l.jpg", "a/n.txt"] {
let p = d.join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, b"x").unwrap();
}
let lib = d.join("lib");
let w = files_in(&d, Some(&lib), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
let rel: Vec<String> = w.files.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect();
assert_eq!(rel, ["a/1.jpg", "a/x/0.jpg", "b/2.jpg", "c.jpg"]);
assert!(!w.truncated);
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #374: a walk never runs on without end — a tree deeper than the limit, or with more
/// entries than the limit, stops and says so.
#[test]
fn deep_and_wide_trees_stop_at_the_limits() {
let d = tmp("bounds");
let mut deep = d.join("deep");
for i in 0..12 {
deep = deep.join(format!("l{i}"));
}
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("bottom.jpg"), b"x").unwrap();
let wide = d.join("wide");
std::fs::create_dir_all(&wide).unwrap();
for i in 0..50 {
std::fs::write(wide.join(format!("{i:02}.jpg")), b"x").unwrap();
}
let all = |_: &Path| true;
let w = files_in(&d.join("deep"), None, Limits { max_depth: 5, max_entries: 1000 }, all);
assert!(w.files.is_empty() && w.truncated, "{w:?}");
let w = files_in(&d.join("deep"), None, Limits { max_depth: 13, max_entries: 1000 }, all);
assert_eq!((w.files.len(), w.truncated), (1, false), "{w:?}");
let w = files_in(&wide, None, Limits { max_depth: 5, max_entries: 20 }, all);
assert_eq!((w.files.len(), w.entries, w.truncated), (20, 20, true));
let w = files_in(&wide, None, Limits::default(), all);
assert_eq!((w.files.len(), w.truncated), (50, false));
let _ = std::fs::remove_dir_all(&d);
}
/// A link back up the tree (a loop) is followed at most once: the walk ends.
#[cfg(unix)]
#[test]
fn link_loops_end() {
let d = tmp("loop");
std::fs::create_dir_all(d.join("a/b")).unwrap();
std::fs::write(d.join("a/b/p.jpg"), b"x").unwrap();
std::os::unix::fs::symlink(&d, d.join("a/b/up")).unwrap();
std::os::unix::fs::symlink(d.join("a"), d.join("alias")).unwrap();
let w = files_in(&d, None, Limits::default(), |_| true);
assert!(!w.truncated, "{w:?}");
assert!(w.files.len() <= 2, "{w:?}");
assert!(w.files.iter().any(|p| p.ends_with("a/b/p.jpg")), "{w:?}");
let _ = std::fs::remove_dir_all(&d);
}
}