diff --git a/src/dir_walker.rs b/src/dir_walker.rs index c245d6ad..c2dfcfba 100644 --- a/src/dir_walker.rs +++ b/src/dir_walker.rs @@ -3,6 +3,7 @@ use std::fs; use std::io::Error; use std::sync::Arc; use std::sync::Mutex; +use std::sync::OnceLock; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use crate::node::Node; @@ -26,7 +27,7 @@ use crate::node::build_node; use std::fs::DirEntry; use crate::node::FileTime; -use crate::platform::get_metadata; +use crate::platform::{MetadataTuple, get_metadata, tuple_from_metadata}; #[derive(Debug)] pub enum Operator { @@ -50,6 +51,12 @@ pub struct WalkData<'a> { pub follow_links: bool, pub progress_data: Arc, pub errors: Arc>, + // True iff any of the filter-style WalkData fields (ignore_directories, + // allowed_filesystems, filter_*_time, filter_regex, invert_filter_regex) + // would do work in `ignore_file`. Computed once in main.rs so the hot + // path in `process_entry` can skip the function call entirely when no + // filter flags are set. + pub has_any_filter: bool, } // Per-directory bookkeeping used during the parallel walk. Each directory gets @@ -67,6 +74,13 @@ struct PendingDir { // means this directory and all descendants are done. pending: AtomicUsize, children: Mutex>, + // Cached stat for this directory, set once at `walk_dir` entry and + // consumed at `finalize_chain`. Avoids a second stat per directory + // (one for the is_dir/is_file branching, one to build the Node). + // We cache the parsed `MetadataTuple` rather than `std::fs::Metadata` + // Just the info we need, ~120 B/dir smaller, and `Copy`. + // `None` means the stat failed (broken symlink, raced deletion, ...). + cached_metadata: OnceLock>, } pub fn walk_it(dirs: HashSet, walk_data: &WalkData) -> Vec { @@ -79,10 +93,12 @@ pub fn walk_it(dirs: HashSet, walk_data: &WalkData) -> Vec { for d in dirs { walk_data.progress_data.clear_state(&d); - let root_is_symlink = walk_data.follow_links - && fs::symlink_metadata(&d) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); + // A root passed on the command line that *is* a symlink-to-dir gets + // followed regardless of `follow_links`. This preserves the existing + // behavior. + let root_is_symlink = fs::symlink_metadata(&d) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); // Synthetic outer parent above the root. Lets `finalize_chain` build // the root's Node via the same code path as every other directory: it @@ -97,6 +113,7 @@ pub fn walk_it(dirs: HashSet, walk_data: &WalkData) -> Vec { parent: None, pending: AtomicUsize::new(1), children: Mutex::new(Vec::new()), + cached_metadata: OnceLock::new(), }); let root = Arc::new(PendingDir { dir: d, @@ -107,6 +124,7 @@ pub fn walk_it(dirs: HashSet, walk_data: &WalkData) -> Vec { // finalize_chain until the root's own scan is done. pending: AtomicUsize::new(1), children: Mutex::new(Vec::new()), + cached_metadata: OnceLock::new(), }); // Single scope per root: all descendant work runs as flat tasks inside @@ -193,14 +211,19 @@ fn is_ignored_path(path: &Path, walk_data: &WalkData) -> bool { return true; } - // Entry is inside an ignored absolute path - // Absolute paths should be canonicalized before being added to `WalkData.ignore_directories` + // Entry is inside an ignored absolute path. + // Absolute paths should be canonicalized before being added to + // `WalkData.ignore_directories`. Canonicalize `path` at most once + // (and only if there is at least one absolute ignore path), instead + // of re-canonicalizing inside the loop per ignored entry. + let mut absolute_entry_path: Option = None; for ignored_path in walk_data.ignore_directories.iter() { if !ignored_path.is_absolute() { continue; } - let absolute_entry_path = std::fs::canonicalize(path).unwrap_or_default(); - if absolute_entry_path.starts_with(ignored_path) { + let canon = absolute_entry_path + .get_or_insert_with(|| std::fs::canonicalize(path).unwrap_or_default()); + if canon.starts_with(ignored_path) { return true; } } @@ -208,54 +231,79 @@ fn is_ignored_path(path: &Path, walk_data: &WalkData) -> bool { false } -fn ignore_file(entry: &DirEntry, walk_data: &WalkData) -> bool { - if is_ignored_path(&entry.path(), walk_data) { +// Predicate for whether `ignore_file`'s filter checks would consult any +// `MetadataTuple` field (dev or m/a/c times). Path-only filters (regex, +// `--ignore-directory`) don't need a stat. +fn filter_needs_metadata(walk_data: &WalkData) -> bool { + !walk_data.allowed_filesystems.is_empty() + || walk_data.filter_accessed_time.is_some() + || walk_data.filter_modified_time.is_some() + || walk_data.filter_changed_time.is_some() +} + +// `metadata` is the entry's pre-fetched stat tuple (or `None` if nothing +// here would have needed it). The caller fetches it once and threads it +// through to `build_node` afterwards, so we never stat the same file +// twice on a filter-active walk. +fn ignore_file( + entry: &DirEntry, + path: &Path, + file_type: std::fs::FileType, + metadata: Option<&MetadataTuple>, + walk_data: &WalkData, +) -> bool { + // `is_ignored_path` is a no-op when no ignore dirs are configured, but the + // guard still pays off: it skips the HashSet hash+probe on every entry. + if !walk_data.ignore_directories.is_empty() && is_ignored_path(path, walk_data) { return true; } let is_dot_file = entry.file_name().to_str().unwrap_or("").starts_with('.'); - let follow_links = walk_data.follow_links && entry.file_type().is_ok_and(|ft| ft.is_symlink()); - if !walk_data.allowed_filesystems.is_empty() { - let size_inode_device = get_metadata(entry.path(), false, follow_links); - if let Some((_size, Some((_id, dev)), _gunk)) = size_inode_device - && !walk_data.allowed_filesystems.contains(&dev) - { - return true; - } + if !walk_data.allowed_filesystems.is_empty() + && let Some((_size, Some((_id, dev)), _gunk)) = metadata + && !walk_data.allowed_filesystems.contains(dev) + { + return true; } - if walk_data.filter_accessed_time.is_some() + + let has_time_filter = walk_data.filter_accessed_time.is_some() || walk_data.filter_modified_time.is_some() - || walk_data.filter_changed_time.is_some() + || walk_data.filter_changed_time.is_some(); + + // `file_type` from the d_type-based DirEntry::file_type already tells + // us whether this is a regular file. For symlinks we still need one + // `path.is_file()` syscall (metadata follows the link) to match the + // previous behavior so do it at most once and cache it. + let is_file_for_filter = file_type.is_file() || (file_type.is_symlink() && path.is_file()); + + if has_time_filter + && let Some((_, _, (modified_time, accessed_time, changed_time))) = metadata + && is_file_for_filter + && [ + (&walk_data.filter_modified_time, *modified_time), + (&walk_data.filter_accessed_time, *accessed_time), + (&walk_data.filter_changed_time, *changed_time), + ] + .iter() + .any(|(filter_time, actual_time)| { + is_filtered_out_due_to_file_time(filter_time, *actual_time) + }) { - let size_inode_device = get_metadata(entry.path(), false, follow_links); - if let Some((_, _, (modified_time, accessed_time, changed_time))) = size_inode_device - && entry.path().is_file() - && [ - (&walk_data.filter_modified_time, modified_time), - (&walk_data.filter_accessed_time, accessed_time), - (&walk_data.filter_changed_time, changed_time), - ] - .iter() - .any(|(filter_time, actual_time)| { - is_filtered_out_due_to_file_time(filter_time, *actual_time) - }) - { - return true; - } + return true; } // Keeping `walk_data.filter_regex.is_empty()` is important for performance reasons, it stops unnecessary work if !walk_data.filter_regex.is_empty() - && entry.path().is_file() - && is_filtered_out_due_to_regex(walk_data.filter_regex, &entry.path()) + && is_file_for_filter + && is_filtered_out_due_to_regex(walk_data.filter_regex, path) { return true; } if !walk_data.invert_filter_regex.is_empty() - && entry.path().is_file() - && is_filtered_out_due_to_invert_regex(walk_data.invert_filter_regex, &entry.path()) + && is_file_for_filter + && is_filtered_out_due_to_invert_regex(walk_data.invert_filter_regex, path) { return true; } @@ -268,7 +316,22 @@ fn walk_dir<'scope>( pending: Arc, walk_data: &'scope WalkData<'scope>, ) { - if pending.dir.is_dir() { + let md_result = if pending.is_symlink { + fs::metadata(&pending.dir) + } else { + fs::symlink_metadata(&pending.dir) + }; + let (is_dir_path, is_file_path, tuple) = match &md_result { + Ok(m) => ( + m.is_dir(), + m.is_file(), + tuple_from_metadata(m, walk_data.use_apparent_size), + ), + Err(_) => (false, false, None), + }; + let _ = pending.cached_metadata.set(tuple); + + if is_dir_path { // EINTR is the only retryable error. Looping iteratively (rather than // recursing on retry, like the old code) keeps stack depth O(1). loop { @@ -336,7 +399,7 @@ fn walk_dir<'scope>( } break; } - } else if !pending.dir.is_file() { + } else if !is_file_path { let mut editable_error = walk_data.errors.lock().unwrap(); let bad_file = pending.dir.as_os_str().to_string_lossy().into(); editable_error.file_not_found.insert(bad_file); @@ -355,7 +418,36 @@ fn process_entry<'scope>( entry: &DirEntry, walk_data: &'scope WalkData<'scope>, ) -> Option { - if ignore_file(entry, walk_data) { + // Compute path + file_type once per entry and thread them through. + // `entry.path()` allocates a PathBuf; `entry.file_type()` can require a + // stat on filesystems without d_type support. Previously each was called + // up to 3 times per entry. + let path = entry.path(); + let file_type = entry.file_type().ok()?; + // Fetch metadata at most once per entry. Without filters, the + // per-file stat lives inside `build_node` as before. With filters, + // we used to stat twice — once in `ignore_file` for the filter + // check, once in `build_node` to actually build the Node. Now we + // fetch once and thread the tuple through. + // + // Use the user's `use_apparent_size` flag at fetch time so the + // tuple is already in the form `build_node` wants. `ignore_file` + // discards the size field, so this is harmless for the filter + // logic but means the tuple can be reused unchanged below. + let mut prefetched: Option = None; + // Fast path: no filters means `ignore_file` has nothing to do. On a + // default walk this avoids a function call, a HashSet probe, and an + // OsString allocation for `file_name` per entry. We still need to honour + // `ignore_hidden` separately when no other filters are set. + if walk_data.has_any_filter { + if filter_needs_metadata(walk_data) { + let follow_links = walk_data.follow_links && file_type.is_symlink(); + prefetched = get_metadata(&path, walk_data.use_apparent_size, follow_links); + } + if ignore_file(entry, &path, file_type, prefetched.as_ref(), walk_data) { + return None; + } + } else if walk_data.ignore_hidden && entry.file_name().to_str().unwrap_or("").starts_with('.') { return None; } let data = entry.file_type().ok()?; @@ -376,14 +468,65 @@ fn process_entry<'scope>( parent: Some(pending.clone()), pending: AtomicUsize::new(1), children: Mutex::new(Vec::new()), + cached_metadata: OnceLock::new(), }); scope.spawn(move |s| walk_dir(s, child, walk_data)); return None; } + // Under `-f` / `--filecount` without metadata-needing filters, + // the file's `MetadataTuple` is mostly thrown away, so we create + // a fake `MetadataTuple` to avoid the syscall. + // + // `node_from_tuple` sets size to 1 unconditionally and the time + // fields are only consulted when `-M` / `-A` / `-y` are active. + // The only field actually consumed downstream is `inode_device` + // for `clean_inodes` dedup, and both halves are available + // without a syscall: + // + // * `inode` comes from `getdents64`'s `d_ino` (already returned + // from the `read_dir` that gave us this entry; surfaced via + // `DirEntry::ino()` on unix). On Linux's getdents64, d_ino + // matches statx's stx_ino — confirmed by the kernel's filldir + // callback, which copies the inode straight from the dentry. + // + // * `dev` is the parent directory's dev. Cross-mount transitions + // can only happen at directory boundaries, and each such + // boundary is a fresh `walk_dir` invocation that re-stats the + // mount point — so within a single dir's child list, every + // non-directory entry shares its dev with the parent. + // + // Synthesise the tuple instead of statting. Gated three ways: + // (a) prefetched is None — no filter wanted metadata. This + // transitively guarantees no time filter (`-M` / `-A` / `-y`) + // is set, because `filter_needs_metadata()` returns true under + // any of those. With no time filter, the synthesised `times=0` + // are inert downstream: `is_filtered_out_due_to_file_time(&None, + // _)` short-circuits to false in `node_from_tuple`. + // (b) `-f` is on — otherwise size/times do matter for display. + // (c) `-L` is off — under follow_links, a symlink-to-file would + // have been dedup'd against its target via stat-follow; d_ino + // gives the symlink's own inode, a different key. Falling back + // to the stat path under `-L` preserves previous behavior. + // Unix-gated: on Windows there's no cheap `d_ino`-equivalent in + // `DirEntry`, so the existing stat path keeps running there. + #[cfg(target_family = "unix")] + if prefetched.is_none() && walk_data.by_filecount && !walk_data.follow_links { + use std::os::unix::fs::DirEntryExt; + let parent_dev = pending + .cached_metadata + .get() + .copied() + .flatten() + .and_then(|t| t.1.map(|(_, dev)| dev)) + .unwrap_or(0); + prefetched = Some((0, Some((entry.ino(), parent_dev)), (0, 0, 0))); + } + let node = build_node( entry.path(), vec![], + prefetched, is_symlink, data.is_file(), pending.depth, @@ -442,9 +585,11 @@ fn finalize_chain(mut pending: Arc, walk_data: &WalkData) { }; (parent, std::mem::take(&mut *children_guard)) }; + let cached = pending.cached_metadata.get().copied().flatten(); node_to_push = build_node( pending.dir.clone(), children, + cached, pending.is_symlink, false, pending.depth, @@ -521,6 +666,7 @@ mod tests { follow_links: false, progress_data: indicator.data.clone(), errors: Arc::new(Mutex::new(RuntimeErrors::default())), + has_any_filter: true, } } diff --git a/src/main.rs b/src/main.rs index 7a0b10f2..850c7ba4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -244,6 +244,16 @@ fn main() { let filter_accessed_time = config.get_accessed_time_operator(&options); let filter_changed_time = config.get_changed_time_operator(&options); + // Precompute whether any filter flag is active so the walker's hot path + // can skip `ignore_file` entirely on a default walk. + let has_any_filter = !ignored_full_path.is_empty() + || !allowed_filesystems.is_empty() + || filter_modified_time.is_some() + || filter_accessed_time.is_some() + || filter_changed_time.is_some() + || !filter_regexs.is_empty() + || !invert_filter_regexs.is_empty(); + let walk_data = WalkData { ignore_directories: ignored_full_path, filter_regex: &filter_regexs, @@ -259,6 +269,7 @@ fn main() { follow_links, progress_data: indicator.data.clone(), errors: errors_for_rayon, + has_any_filter, }; let threads_to_use = config.get_threads(&options); diff --git a/src/node.rs b/src/node.rs index 7af8e0a6..d32c8aa5 100644 --- a/src/node.rs +++ b/src/node.rs @@ -1,5 +1,5 @@ use crate::dir_walker::WalkData; -use crate::platform::get_metadata; +use crate::platform::{MetadataTuple, get_metadata}; use crate::utils::is_filtered_out_due_to_file_time; use crate::utils::is_filtered_out_due_to_invert_regex; use crate::utils::is_filtered_out_due_to_regex; @@ -33,61 +33,80 @@ impl From for FileTime { } } +/// Build a Node for a directory or file. +/// +/// If `cached` is `Some`, reuse the already-extracted metadata tuple to +/// avoid a stat. `walk_dir` stats each directory once at entry and threads +/// the result through here via `finalize_chain`. Files pass `None` since +/// `process_entry` doesn't pre-stat them. #[allow(clippy::too_many_arguments)] pub fn build_node( dir: PathBuf, children: Vec, + cached: Option, is_symlink: bool, is_file: bool, depth: usize, walk_data: &WalkData, ) -> Option { - let use_apparent_size = walk_data.use_apparent_size; + let data = match cached { + Some(t) => t, + None => get_metadata( + &dir, + walk_data.use_apparent_size, + walk_data.follow_links && is_symlink, + )?, + }; + Some(node_from_tuple( + dir, children, data, is_file, depth, walk_data, + )) +} + +fn node_from_tuple( + dir: PathBuf, + children: Vec, + data: MetadataTuple, + is_file: bool, + depth: usize, + walk_data: &WalkData, +) -> Node { let by_filecount = walk_data.by_filecount; let by_filetime = &walk_data.by_filetime; + let inode_device = data.1; - get_metadata( - &dir, - use_apparent_size, - walk_data.follow_links && is_symlink, - ) - .map(|data| { - let inode_device = data.1; - - let size = if is_filtered_out_due_to_regex(walk_data.filter_regex, &dir) - || is_filtered_out_due_to_invert_regex(walk_data.invert_filter_regex, &dir) - || by_filecount && !is_file - || [ - (&walk_data.filter_modified_time, data.2.0), - (&walk_data.filter_accessed_time, data.2.1), - (&walk_data.filter_changed_time, data.2.2), - ] - .iter() - .any(|(filter_time, actual_time)| { - is_filtered_out_due_to_file_time(filter_time, *actual_time) - }) { - 0 - } else if by_filecount { - 1 - } else if by_filetime.is_some() { - match by_filetime { - Some(FileTime::Modified) => data.2.0.unsigned_abs(), - Some(FileTime::Accessed) => data.2.1.unsigned_abs(), - Some(FileTime::Changed) => data.2.2.unsigned_abs(), - None => unreachable!(), - } - } else { - data.0 - }; - - Node { - name: dir, - size, - children, - inode_device, - depth, + let size = if is_filtered_out_due_to_regex(walk_data.filter_regex, &dir) + || is_filtered_out_due_to_invert_regex(walk_data.invert_filter_regex, &dir) + || by_filecount && !is_file + || [ + (&walk_data.filter_modified_time, data.2.0), + (&walk_data.filter_accessed_time, data.2.1), + (&walk_data.filter_changed_time, data.2.2), + ] + .iter() + .any(|(filter_time, actual_time)| { + is_filtered_out_due_to_file_time(filter_time, *actual_time) + }) { + 0 + } else if by_filecount { + 1 + } else if by_filetime.is_some() { + match by_filetime { + Some(FileTime::Modified) => data.2.0.unsigned_abs(), + Some(FileTime::Accessed) => data.2.1.unsigned_abs(), + Some(FileTime::Changed) => data.2.2.unsigned_abs(), + None => unreachable!(), } - }) + } else { + data.0 + }; + + Node { + name: dir, + size, + children, + inode_device, + depth, + } } impl PartialEq for Node { diff --git a/src/platform.rs b/src/platform.rs index 5deb68b6..c1476011 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -13,69 +13,79 @@ fn get_block_size() -> u64 { type InodeAndDevice = (u64, u64); type FileTime = (i64, i64, i64); -#[cfg(target_family = "windows")] -fn filetime_to_unix_seconds(filetime: u64) -> i64 { - const TICKS_PER_SECOND: i128 = 10_000_000; - const UNIX_EPOCH_FILETIME: i128 = 116_444_736_000_000_000; - - ((i128::from(filetime) - UNIX_EPOCH_FILETIME).div_euclid(TICKS_PER_SECOND)) as i64 -} +/// The parsed stat fields the walker consumes. +pub type MetadataTuple = (u64, Option, FileTime); #[cfg(target_family = "unix")] pub fn get_metadata>( path: P, use_apparent_size: bool, follow_links: bool, -) -> Option<(u64, Option, FileTime)> { - use std::os::unix::fs::MetadataExt; +) -> Option { let metadata = if follow_links { path.as_ref().metadata() } else { path.as_ref().symlink_metadata() }; match metadata { - Ok(md) => { - let file_size = md.len(); - if use_apparent_size { - Some(( - file_size, - Some((md.ino(), md.dev())), - (md.mtime(), md.atime(), md.ctime()), - )) - } else { - // On NTFS mounts, the reported block count can be unexpectedly large. - // To avoid overestimating disk usage, cap the allocated size to what the - // file should occupy based on the file system I/O block size (blksize). - // Related: https://github.com/bootandy/dust/issues/295 - let blksize = md.blksize(); - let target_size = file_size.div_ceil(blksize) * blksize; - let reported_size = md.blocks() * get_block_size(); - - // File systems can pre-allocate more space for a file than what would be necessary - let pre_allocation_buffer = blksize * 65536; - let max_size = target_size + pre_allocation_buffer; - let allocated_size = if reported_size > max_size { - target_size - } else { - reported_size - }; - Some(( - allocated_size, - Some((md.ino(), md.dev())), - (md.mtime(), md.atime(), md.ctime()), - )) - } - } + Ok(md) => tuple_from_metadata(&md, use_apparent_size), Err(_e) => None, } } +/// Extract the data tuple from an already-fetched `Metadata`, no syscall. +#[cfg(target_family = "unix")] +pub fn tuple_from_metadata( + md: &std::fs::Metadata, + use_apparent_size: bool, +) -> Option { + use std::os::unix::fs::MetadataExt; + let file_size = md.len(); + if use_apparent_size { + Some(( + file_size, + Some((md.ino(), md.dev())), + (md.mtime(), md.atime(), md.ctime()), + )) + } else { + // On NTFS mounts, the reported block count can be unexpectedly large. + // To avoid overestimating disk usage, cap the allocated size to what the + // file should occupy based on the file system I/O block size (blksize). + // Related: https://github.com/bootandy/dust/issues/295 + let blksize = md.blksize(); + let target_size = file_size.div_ceil(blksize) * blksize; + let reported_size = md.blocks() * get_block_size(); + + // File systems can pre-allocate more space for a file than what would be necessary + let pre_allocation_buffer = blksize * 65536; + let max_size = target_size + pre_allocation_buffer; + let allocated_size = if reported_size > max_size { + target_size + } else { + reported_size + }; + Some(( + allocated_size, + Some((md.ino(), md.dev())), + (md.mtime(), md.atime(), md.ctime()), + )) + } +} + +#[cfg(target_family = "windows")] +fn filetime_to_unix_seconds(filetime: u64) -> i64 { + const TICKS_PER_SECOND: i128 = 10_000_000; + const UNIX_EPOCH_FILETIME: i128 = 116_444_736_000_000_000; + + ((i128::from(filetime) - UNIX_EPOCH_FILETIME).div_euclid(TICKS_PER_SECOND)) as i64 +} + #[cfg(target_family = "windows")] pub fn get_metadata>( path: P, use_apparent_size: bool, follow_links: bool, -) -> Option<(u64, Option, FileTime)> { +) -> Option { // On windows opening the file to get size, file ID and volume can be very // expensive because 1) it causes a few system calls, and more importantly 2) it can cause // windows defender to scan the file. @@ -139,10 +149,7 @@ pub fn get_metadata>( Ok(Handle::from_file(file)) } - fn get_metadata_expensive( - path: &Path, - use_apparent_size: bool, - ) -> Option<(u64, Option, FileTime)> { + fn get_metadata_expensive(path: &Path, use_apparent_size: bool) -> Option { use winapi_util::file::information; let h = handle_from_path_limited(path).ok()?; @@ -172,7 +179,6 @@ pub fn get_metadata>( } } - use std::os::windows::fs::MetadataExt; let path = path.as_ref(); let metadata = if follow_links { path.metadata() @@ -180,46 +186,58 @@ pub fn get_metadata>( path.symlink_metadata() }; match metadata { - Ok(ref md) => { - const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x20; - const FILE_ATTRIBUTE_READONLY: u32 = 0x01; - const FILE_ATTRIBUTE_HIDDEN: u32 = 0x02; - const FILE_ATTRIBUTE_SYSTEM: u32 = 0x04; - const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; - const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; - const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x00000200; - const FILE_ATTRIBUTE_PINNED: u32 = 0x00080000; - const FILE_ATTRIBUTE_UNPINNED: u32 = 0x00100000; - const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x00040000; - const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x00400000; - const FILE_ATTRIBUTE_OFFLINE: u32 = 0x00001000; - // normally FILE_ATTRIBUTE_SPARSE_FILE would be enough, however Windows sometimes likes to mask it out. see: https://stackoverflow.com/q/54560454 - const IS_PROBABLY_ONEDRIVE: u32 = FILE_ATTRIBUTE_SPARSE_FILE - | FILE_ATTRIBUTE_PINNED - | FILE_ATTRIBUTE_UNPINNED - | FILE_ATTRIBUTE_RECALL_ON_OPEN - | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS - | FILE_ATTRIBUTE_OFFLINE; - let attr_filtered = md.file_attributes() - & !(FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM); - if ((attr_filtered & FILE_ATTRIBUTE_ARCHIVE) != 0 - || (attr_filtered & FILE_ATTRIBUTE_DIRECTORY) != 0 - || md.file_attributes() == FILE_ATTRIBUTE_NORMAL) - && !((attr_filtered & IS_PROBABLY_ONEDRIVE != 0) && use_apparent_size) - { - Some(( - md.len(), - None, - ( - filetime_to_unix_seconds(md.last_write_time()), - filetime_to_unix_seconds(md.last_access_time()), - filetime_to_unix_seconds(md.creation_time()), - ), - )) - } else { - get_metadata_expensive(path, use_apparent_size) - } - } + Ok(ref md) => tuple_from_metadata(md, use_apparent_size) + .or_else(|| get_metadata_expensive(path, use_apparent_size)), _ => get_metadata_expensive(path, use_apparent_size), } } + +/// Extract the data tuple from an already-fetched `Metadata` on Windows. +/// Returns `None` when the file needs the expensive (handle-open) path — +/// the caller must fall back. Directories and normal files always return +/// `Some` (that's the cheap branch the current code already takes). +#[cfg(target_family = "windows")] +pub fn tuple_from_metadata( + md: &std::fs::Metadata, + use_apparent_size: bool, +) -> Option { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x20; + const FILE_ATTRIBUTE_READONLY: u32 = 0x01; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x02; + const FILE_ATTRIBUTE_SYSTEM: u32 = 0x04; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x00000200; + const FILE_ATTRIBUTE_PINNED: u32 = 0x00080000; + const FILE_ATTRIBUTE_UNPINNED: u32 = 0x00100000; + const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x00040000; + const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x00400000; + const FILE_ATTRIBUTE_OFFLINE: u32 = 0x00001000; + // normally FILE_ATTRIBUTE_SPARSE_FILE would be enough, however Windows sometimes likes to mask it out. see: https://stackoverflow.com/q/54560454 + const IS_PROBABLY_ONEDRIVE: u32 = FILE_ATTRIBUTE_SPARSE_FILE + | FILE_ATTRIBUTE_PINNED + | FILE_ATTRIBUTE_UNPINNED + | FILE_ATTRIBUTE_RECALL_ON_OPEN + | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS + | FILE_ATTRIBUTE_OFFLINE; + let attr_filtered = md.file_attributes() + & !(FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_SYSTEM); + if ((attr_filtered & FILE_ATTRIBUTE_ARCHIVE) != 0 + || (attr_filtered & FILE_ATTRIBUTE_DIRECTORY) != 0 + || md.file_attributes() == FILE_ATTRIBUTE_NORMAL) + && !((attr_filtered & IS_PROBABLY_ONEDRIVE != 0) && use_apparent_size) + { + Some(( + md.len(), + None, + ( + md.last_write_time() as i64, + md.last_access_time() as i64, + md.creation_time() as i64, + ), + )) + } else { + None + } +} diff --git a/tests/tests_symlinks.rs b/tests/tests_symlinks.rs index 2ceb43d6..454944ec 100644 --- a/tests/tests_symlinks.rs +++ b/tests/tests_symlinks.rs @@ -111,6 +111,33 @@ pub fn test_hard_sym_link_no_dup_multi_arg() { assert!(has_file_only || has_link_only); } +// Regression: dust passed a symlink-to-dir as its root path (no `-L`) +// must walk into the target dir, matching `du`'s `Path::is_dir()`-style +// behavior. +#[cfg_attr(target_os = "windows", ignore)] +#[test] +pub fn test_root_symlink_to_dir_no_follow() { + let dir = Builder::new().tempdir().unwrap(); + let target = dir.path().join("target"); + std::fs::create_dir(&target).unwrap(); + let mut f = File::create(target.join("notes.txt")).unwrap(); + writeln!(f, "hello").unwrap(); + + let link = dir.path().join("link"); + let link_s = link_it(link.clone(), target.to_str().unwrap(), true); + + let mut cmd = cargo_bin_cmd!("dust"); + let output = cmd.args(["-p", "-c", "-w", "999", &link_s]).unwrap().stdout; + let output = str::from_utf8(&output).unwrap(); + let notes_line = format!("── {}/notes.txt", link_s); + assert!( + output.contains(¬es_line), + "expected `{}` in output, got:\n{}", + notes_line, + output + ); +} + #[cfg_attr(target_os = "windows", ignore)] #[test] pub fn test_recursive_sym_link() {