From b4e99a5f42d278ad28072c7b2b4774dd27777bfe Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:01:40 -0700 Subject: [PATCH] fix: index followed file symlinks in zlob walker (#912) zlob reports followed symlinks with the Symlink kind and lstat metadata, so the zlob walker dropped every symlink that resolves to a file even with follow_symlinks on. Stat the target and index it as a file. Closes #912 --- crates/fff-core/src/walk/mod.rs | 34 ++++++++++++++++++++ crates/fff-core/src/walk/zlob.rs | 55 +++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/crates/fff-core/src/walk/mod.rs b/crates/fff-core/src/walk/mod.rs index e163d3a61..58f620db2 100644 --- a/crates/fff-core/src/walk/mod.rs +++ b/crates/fff-core/src/walk/mod.rs @@ -124,6 +124,40 @@ mod tests { assert!(!names.iter().any(|n| n.contains("node_modules"))); } + // Stow-style file symlinks pointing outside the root must be indexed with + // follow_symlinks on, with the target's metadata (#912). + #[cfg(unix)] + #[test] + fn follows_file_symlinks_outside_root() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("dotfiles"); + let root = dir.path().join("config"); + fs::create_dir_all(outside.join("nested")).unwrap(); + fs::create_dir(&root).unwrap(); + fs::write(outside.join("sesh.toml"), "0123456789").unwrap(); + fs::write(outside.join("nested/inner.toml"), "x").unwrap(); + fs::write(root.join("projects.toml"), "x").unwrap(); + std::os::unix::fs::symlink("../dotfiles/sesh.toml", root.join("sesh.toml")).unwrap(); + std::os::unix::fs::symlink("../dotfiles/nested", root.join("linked")).unwrap(); + std::os::unix::fs::symlink("../dotfiles/missing", root.join("broken")).unwrap(); + + let counter = Arc::new(AtomicUsize::new(0)); + let out = walk_collect_files(&root, false, true, 1, &counter).unwrap(); + let mut names: Vec = out.pairs.iter().map(|(_, rel)| rel.clone()).collect(); + names.sort(); + + assert_eq!(names, ["linked/inner.toml", "projects.toml", "sesh.toml"]); + assert_eq!(counter.load(Ordering::Relaxed), names.len()); + + let (item, rel) = out.pairs.iter().find(|(_, r)| r == "sesh.toml").unwrap(); + assert_eq!(item.size, 10); + assert_eq!(&rel[item.filename_offset_in_relative_path()..], "sesh.toml"); + + let out = walk_collect_files(&root, false, false, 1, &counter).unwrap(); + let names: Vec = out.pairs.into_iter().map(|(_, rel)| rel).collect(); + assert_eq!(names, ["projects.toml"]); + } + // Only the zlob backend surfaces reusable ignore rules; they must match // the same tree the walk respected. #[cfg(feature = "zlob")] diff --git a/crates/fff-core/src/walk/zlob.rs b/crates/fff-core/src/walk/zlob.rs index 98eddf1fc..0509c0a02 100644 --- a/crates/fff-core/src/walk/zlob.rs +++ b/crates/fff-core/src/walk/zlob.rs @@ -6,6 +6,7 @@ use parking_lot::Mutex; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::SystemTime; use zlob::walk::{WalkBuilder, WalkFlags, WalkMetadata, WalkState}; const PROGRESS_STEP: usize = 13; @@ -51,7 +52,16 @@ pub(crate) fn walk_collect_files( let collected = Mutex::new((Vec::new(), Vec::new())); let outcome = match builder.run(|entry| { - if !entry.is_file() { + // Followed links keep the Symlink kind and lstat metadata, so stat + // the target to index the ones resolving to files (#912). + let target = if entry.is_file() { + None + } else if follow_symlinks && entry.is_symlink() { + match std::fs::metadata(entry.path()) { + Ok(meta) if meta.is_file() => Some(meta), + _ => return WalkState::Continue, + } + } else { // unlike ripgrep walker zlob doesnt show .git files if entry.is_dir() { let relative_path = entry.relative_path_lossy(); @@ -63,26 +73,41 @@ pub(crate) fn walk_collect_files( } return WalkState::Continue; - } - - // `basename()` returns `&str` for files only. - let basename = entry.basename().unwrap_or(""); - let is_binary = is_known_binary_extension_basename(basename); - - let size = entry.size().unwrap_or(0); - // zlob reports mtime in ns since the Unix epoch; FileItem wants secs. - let modified = entry - .modified_ns() - .map(|ns| (ns / 1_000_000_000).max(0) as u64) - .unwrap_or(0); + }; // Lossy pair: the offset must index the decoded string, not the raw // bytes, or it lands inside a U+FFFD on invalid-UTF-8 names (#799). - let basename_offset = entry.basename_offset_in_relative_lossy() as u16; + let basename_offset = entry.basename_offset_in_relative_lossy(); // zlob emits '/'-separated relative paths, which is fff's canonical // internal form on every platform — store them verbatim. let relative_path = entry.relative_path_lossy().into_owned(); - let item = FileItem::new_raw(basename_offset, size, modified, None, is_binary); + + // `basename()` returns `&str` for files only. + let basename = match target { + None => entry.basename().unwrap_or(""), + Some(_) => &relative_path[basename_offset..], + }; + let is_binary = is_known_binary_extension_basename(basename); + + let (size, modified) = match target { + Some(meta) => ( + meta.len(), + meta.modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_secs()), + ), + // zlob reports mtime in ns since the Unix epoch; FileItem wants secs. + None => ( + entry.size().unwrap_or(0), + entry + .modified_ns() + .map(|ns| (ns / 1_000_000_000).max(0) as u64) + .unwrap_or(0), + ), + }; + + let item = FileItem::new_raw(basename_offset as u16, size, modified, None, is_binary); let mut guard = collected.lock(); guard.0.push((item, relative_path));