diff --git a/crates/dua-lib/src/lib.rs b/crates/dua-lib/src/lib.rs index a48bba8a..5cd660c5 100644 --- a/crates/dua-lib/src/lib.rs +++ b/crates/dua-lib/src/lib.rs @@ -29,7 +29,7 @@ use crossbeam::{ }; use std::{ collections::HashMap, - io, + fs, io, num::NonZeroU32, path::{Path, PathBuf}, sync::{ @@ -41,7 +41,7 @@ use std::{ }; #[cfg(any(not(any(windows, target_os = "macos")), test))] -use std::{ffi::OsString, fs}; +use std::ffi::OsString; #[cfg(not(any(windows, target_os = "macos")))] pub use std::fs::{FileType, Metadata}; @@ -60,11 +60,66 @@ pub use macos::{Entry, FileType, Metadata}; #[cfg(target_os = "macos")] use macos::ReadDir as NativeReadDir; +#[cfg(target_os = "macos")] +use std::fs::read_dir as read_dir_types; + #[cfg(windows)] pub use windows::{Entry, FileType, Metadata}; #[cfg(windows)] -use windows::ReadDir as NativeReadDir; +use windows::{ReadDir as NativeReadDir, read_dir_types}; + +#[cfg(any(windows, target_os = "macos"))] +enum ReadDir { + Metadata(NativeReadDir), + FileTypes { + entries: fs::ReadDir, + parent_path: Arc, + depth: usize, + }, +} + +#[cfg(any(windows, target_os = "macos"))] +impl ReadDir { + fn open(path: Arc, depth: usize, options: Options) -> io::Result { + if options.skip_metadata { + Ok(Self::FileTypes { + entries: read_dir_types(&path)?, + parent_path: path, + depth, + }) + } else { + NativeReadDir::open(path, depth, options).map(Self::Metadata) + } + } +} + +#[cfg(any(windows, target_os = "macos"))] +impl Iterator for ReadDir { + type Item = io::Result; + + fn next(&mut self) -> Option { + match self { + Self::Metadata(reader) => reader.next(), + Self::FileTypes { + entries, + parent_path, + depth, + } => entries.next().map(|entry| { + let entry = entry?; + Ok(Entry { + depth: *depth, + file_name: entry.file_name(), + file_type: FileType::from_std(entry.file_type()?), + metadata: None, + parent_path: Arc::clone(parent_path), + directory_id: None, + parent_directory_id: None, + }) + }), + } + } +} /// Decides whether to traverse an entry's children for a given root index. /// Returning `false` prunes descendants but still emits the entry itself. @@ -86,14 +141,29 @@ pub enum Order { ParentFirst, } -/// Platform-specific filesystem metadata requested during traversal. +/// Filesystem metadata requested during traversal. #[derive(Clone, Copy, Debug, Default)] pub struct Options { + /// Collect only entry types, leaving [`Entry::metadata`] as `None`. + /// + /// Directory enumeration supplies types when available. Explicit roots and filesystems + /// without directory-entry types may still require a metadata lookup. This also disables + /// APFS clone metadata collection. + pub skip_metadata: bool, /// Collect APFS clone identity and data-fork allocation metadata. #[cfg(target_os = "macos")] pub apfs_clone_metadata: bool, } +impl Options { + /// Collect only entry types, leaving [`Entry::metadata`] as `None`. + #[must_use] + pub fn skip_metadata(mut self) -> Self { + self.skip_metadata = true; + self + } +} + /// Dense identifier of a directory within one filesystem walk. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct DirectoryId(NonZeroU32); @@ -124,8 +194,8 @@ pub struct Entry { pub file_name: OsString, /// Filesystem entry type without following symbolic links. pub file_type: FileType, - /// Entry metadata, or the error encountered while reading it. - pub metadata: io::Result, + /// Requested metadata or its read error; `None` when [`Options::skip_metadata`] is set. + pub metadata: Option>, /// Path containing this entry. pub parent_path: Arc, /// Dense identifier of this directory within the current walk, or `None` for non-directories. @@ -209,7 +279,6 @@ struct PoolShared { /// A counter reaching zero emits that root's [`Event::RootFinished`]. jobs_per_root: HashMap, order: Order, - #[cfg(any(windows, target_os = "macos"))] options: Options, /// Handles used to wake workers, indexed by worker number. unparkers: Vec, @@ -257,7 +326,8 @@ pub struct Walk { finished: bool, } -/// Read a directory using native bulk enumeration and return entries with metadata already collected. +/// Read a directory using native enumeration, collecting metadata unless +/// [`Options::skip_metadata`] is set. /// /// Entries have depth zero so they can be passed directly to [`walk_root_entries`] without /// querying their paths again. Directory-open errors are returned immediately; later enumeration @@ -267,7 +337,7 @@ pub fn read_dir( path: &Path, options: Options, ) -> io::Result>> { - NativeReadDir::open(Arc::from(path), 0, options) + ReadDir::open(Arc::from(path), 0, options) } /// Walk `root` without following symlinks. @@ -425,6 +495,7 @@ pub fn walk_roots( /// errors are yielded for their corresponding root, and each root retains its index and completion /// event just as it does with [`walk_roots`]. Supplied entries are re-rooted at depth zero before /// the predicate runs, and their descendants start at depth one. +/// The supplied entries retain their metadata; `options` controls metadata for descendants. /// /// # Panics /// @@ -563,13 +634,13 @@ impl Entry { } /// Create an entry from a filesystem path. - pub fn from_path(path: &Path, _options: Options) -> io::Result { + pub fn from_path(path: &Path, options: Options) -> io::Result { let metadata = fs::symlink_metadata(path)?; Ok(Self { depth: 0, file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(), file_type: metadata.file_type(), - metadata: Ok(metadata), + metadata: (!options.skip_metadata).then_some(Ok(metadata)), parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))), directory_id: None, parent_directory_id: None, @@ -580,12 +651,13 @@ impl Entry { depth: usize, parent_path: Arc, entry: fs::DirEntry, + options: Options, ) -> io::Result { Ok(Self { depth, file_name: entry.file_name(), file_type: entry.file_type()?, - metadata: entry.metadata(), + metadata: (!options.skip_metadata).then(|| entry.metadata()), parent_path, directory_id: None, parent_directory_id: None, @@ -601,8 +673,6 @@ fn start_pool( options: Options, next_directory_id: usize, ) -> Pool { - #[cfg(not(any(windows, target_os = "macos")))] - let _ = options; let workers: Vec<_> = (0..threads).map(|_| Worker::new_lifo()).collect(); let parkers: Vec<_> = (0..threads).map(|_| Parker::new()).collect(); let (event_tx, event_rx) = sync_channel(threads * 2); @@ -615,7 +685,6 @@ fn start_pool( active_roots: AtomicUsize::new(0), jobs_per_root, order, - #[cfg(any(windows, target_os = "macos"))] options, unparkers: parkers .iter() @@ -666,7 +735,7 @@ fn begin_walks( } } let has_job = if let Ok(entry) = &entry - && entry.metadata.is_ok() + && entry.metadata.as_ref().is_none_or(Result::is_ok) && entry.file_type.is_dir() && descend(root_idx, entry) { @@ -833,6 +902,7 @@ fn run_job(job: Job, worker: &Worker, shared: &PoolShared) { /// are emitted directly; the directory-read job completes after all chunks are queued. /// This adds parallelism within wide directories when metadata calls dominate. Both traversal /// orders already process separate directories concurrently, so typical trees may see no speedup. +/// Type-only walks convert entries inline instead, avoiding metadata-job overhead. #[cfg(not(any(windows, target_os = "macos")))] fn read_dir_completion( root_idx: usize, @@ -842,6 +912,10 @@ fn read_dir_completion( worker: &Worker, shared: &PoolShared, ) { + if shared.options.skip_metadata { + read_dir_inline(root_idx, path, directory_id, entry_depth, worker, shared); + return; + } let dir_entries = match fs::read_dir(&path) { Ok(entries) => entries, Err(err) => { @@ -912,16 +986,6 @@ fn read_dir_completion( finish_pending(root_idx, shared); } -/// Open the platform-native reader with any traversal-specific metadata enabled. -#[cfg(any(windows, target_os = "macos"))] -fn native_read_dir( - path: Arc, - depth: usize, - shared: &PoolShared, -) -> io::Result { - NativeReadDir::open(path, depth, shared.options) -} - /// Read a directory for completion-order traversal. /// /// Unlike the generic implementation, native readers collect metadata while enumerating, @@ -936,7 +1000,7 @@ fn read_dir_completion( worker: &Worker, shared: &PoolShared, ) { - let dir_entries = match native_read_dir(path, depth, shared) { + let dir_entries = match ReadDir::open(path, depth, shared.options) { Ok(entries) => entries, Err(err) => { if shared @@ -1024,7 +1088,7 @@ fn read_dir_parent_first( worker: &Worker, shared: &PoolShared, ) { - let dir_entries = match native_read_dir(path, depth, shared) { + let dir_entries = match ReadDir::open(path, depth, shared.options) { Ok(entries) => entries, Err(err) => { finish_directory(root_idx, Err(err), Vec::new(), worker, shared); @@ -1068,21 +1132,23 @@ fn stat_entries_completion( let entries = entries .into_iter() .map(|entry| { - Entry::from_dir_entry(depth, Arc::clone(&path), entry).map(|mut entry| { - assign_directory_ids(&mut entry, directory_id, shared); - if entry.file_type.is_dir() && (shared.descend)(root_idx, &entry) { - jobs.push(Job::ReadDir { - root_idx, - path: Arc::from(entry.path()), - directory_id: entry - .directory_id - .expect("directories receive an identifier") - .index(), - entry_depth: entry.depth + 1, - }); - } - entry - }) + Entry::from_dir_entry(depth, Arc::clone(&path), entry, shared.options).map( + |mut entry| { + assign_directory_ids(&mut entry, directory_id, shared); + if entry.file_type.is_dir() && (shared.descend)(root_idx, &entry) { + jobs.push(Job::ReadDir { + root_idx, + path: Arc::from(entry.path()), + directory_id: entry + .directory_id + .expect("directories receive an identifier") + .index(), + entry_depth: entry.depth + 1, + }); + } + entry + }, + ) }) .collect(); add_pending(root_idx, jobs.len(), shared); @@ -1142,7 +1208,9 @@ fn read_dir_inline( let entries = dir_entries .map(|entry| { entry - .and_then(|entry| Entry::from_dir_entry(depth, Arc::clone(&path), entry)) + .and_then(|entry| { + Entry::from_dir_entry(depth, Arc::clone(&path), entry, shared.options) + }) .map(|mut entry| { assign_directory_ids(&mut entry, directory_id, shared); if entry.file_type.is_dir() && (shared.descend)(root_idx, &entry) { @@ -1544,7 +1612,7 @@ mod tests { "nothing left after the Finished event" ); - root.metadata = Err(io::Error::from(io::ErrorKind::PermissionDenied)); + root.metadata = Some(Err(io::Error::from(io::ErrorKind::PermissionDenied))); let mut events = walk_root_entries( [(7, Ok(root))], 2, @@ -1557,6 +1625,7 @@ mod tests { }; let error = root .metadata + .unwrap() .err() .expect("the inaccessible root must retain its metadata error"); assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); @@ -1599,7 +1668,7 @@ mod tests { ); }; assert_eq!(entry.path(), path); - assert_eq!(entry.metadata.unwrap().len(), expected_len); + assert_eq!(entry.metadata.unwrap().unwrap().len(), expected_len); assert_eq!( events .next() diff --git a/crates/dua-lib/src/macos/mod.rs b/crates/dua-lib/src/macos/mod.rs index f619ea7d..05ad2018 100644 --- a/crates/dua-lib/src/macos/mod.rs +++ b/crates/dua-lib/src/macos/mod.rs @@ -34,8 +34,8 @@ pub struct Entry { pub file_name: OsString, /// Filesystem entry type without following symbolic links. pub file_type: FileType, - /// Metadata returned while enumerating this entry, or an entry-specific I/O error. - pub metadata: io::Result, + /// Requested metadata or its read error; `None` when [`crate::Options::skip_metadata`] is set. + pub metadata: Option>, /// Path containing this entry. pub parent_path: Arc, /// Dense identifier of this directory within the current walk, or `None` for non-directories. @@ -48,18 +48,21 @@ impl Entry { /// Create an entry for an explicitly requested root without following symbolic links. pub fn from_path(path: &Path, options: crate::Options) -> io::Result { let metadata = fs::symlink_metadata(path)?; - let data_fork = - if options.apfs_clone_metadata && metadata.is_file() && metadata.blocks() != 0 { - clone_attributes_at(path, &metadata) - } else { - None - }; - let metadata = Metadata::from_std(&metadata, data_fork); + let file_type = FileType::from_std(metadata.file_type()); + let metadata = (!options.skip_metadata).then(|| { + let data_fork = + if options.apfs_clone_metadata && metadata.is_file() && metadata.blocks() != 0 { + clone_attributes_at(path, &metadata) + } else { + None + }; + Ok(Metadata::from_std(&metadata, data_fork)) + }); Ok(Self { depth: 0, file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(), - file_type: metadata.file_type, - metadata: Ok(metadata), + file_type, + metadata, parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))), directory_id: None, parent_directory_id: None, @@ -80,7 +83,7 @@ pub struct FileType { } impl FileType { - fn from_std(file_type: fs::FileType) -> Self { + pub(crate) fn from_std(file_type: fs::FileType) -> Self { let kind = if file_type.is_dir() { VDIR } else if file_type.is_file() { @@ -346,7 +349,7 @@ impl ReadDir { depth: self.depth, file_name, file_type, - metadata, + metadata: Some(metadata), parent_path: Arc::clone(&self.parent_path), directory_id: None, parent_directory_id: None, @@ -413,7 +416,7 @@ impl ReadDir { depth: self.depth, file_name, file_type, - metadata, + metadata: Some(metadata), parent_path: Arc::clone(&self.parent_path), directory_id: None, parent_directory_id: None, diff --git a/crates/dua-lib/src/macos/tests.rs b/crates/dua-lib/src/macos/tests.rs index c6ad95d8..ff434c38 100644 --- a/crates/dua-lib/src/macos/tests.rs +++ b/crates/dua-lib/src/macos/tests.rs @@ -5,6 +5,7 @@ use std::os::unix::fs::PermissionsExt as _; fn options(apfs_clone_metadata: bool) -> Options { Options { apfs_clone_metadata, + skip_metadata: false, } } @@ -53,12 +54,41 @@ fn fallback_reader_enumerates_entries_with_metadata() { "the native directory is empty, so this entry must come from the injected fallback reader" ); assert_eq!( - entry.metadata.unwrap().len(), + entry.metadata.unwrap().unwrap().len(), 7, "the fallback reader must collect per-entry metadata" ); } +#[test] +fn type_only_reads_do_not_require_metadata_access() { + let directory = tempfile::tempdir().unwrap(); + let restricted = directory.path().join("restricted"); + fs::create_dir(&restricted).unwrap(); + fs::write(restricted.join("file"), b"content").unwrap(); + fs::create_dir(restricted.join("child")).unwrap(); + fs::set_permissions(&restricted, fs::Permissions::from_mode(0o400)).unwrap(); + + let entries = crate::read_dir( + &restricted, + Options { + skip_metadata: true, + ..options(true) + }, + ) + .unwrap() + .collect::>(); + fs::set_permissions(&restricted, fs::Permissions::from_mode(0o700)).unwrap(); + + assert_eq!(entries.len(), 2); + for entry in entries { + let entry = entry.unwrap(); + assert!(entry.metadata.is_none()); + assert_eq!(entry.file_type.is_dir(), entry.file_name == "child"); + assert_eq!(entry.file_type.is_file(), entry.file_name == "file"); + } +} + #[test] fn bulk_metadata_matches_std_for_regular_files_resource_forks_and_symlinks() { let directory = tempfile::tempdir().unwrap(); @@ -80,6 +110,7 @@ fn bulk_metadata_matches_std_for_regular_files_resource_forks_and_symlinks() { .expect("the resource-fork fixture must be enumerated") .unwrap() .metadata + .unwrap() .unwrap(); assert_eq!( ordinary_metadata.data_allocated_size(), @@ -98,7 +129,7 @@ fn bulk_metadata_matches_std_for_regular_files_resource_forks_and_symlinks() { ); for entry in entries { let direct = fs::symlink_metadata(entry.path()).unwrap(); - let metadata = entry.metadata.unwrap(); + let metadata = entry.metadata.unwrap().unwrap(); assert_eq!( metadata.len(), direct.len(), @@ -166,7 +197,7 @@ fn bulk_metadata_preserves_fractional_pre_epoch_timestamps() { "the filesystem must preserve the fractional pre-epoch timestamp used by this test" ); assert_eq!( - entry.metadata.unwrap().modified().unwrap(), + entry.metadata.unwrap().unwrap().modified().unwrap(), direct, "bulk metadata must preserve fractional pre-epoch timestamps" ); @@ -201,6 +232,7 @@ fn readable_directory_without_search_permission_preserves_entry_errors() { ); let error = entry .metadata + .unwrap() .err() .expect("metadata must retain the directory search-permission error"); assert_eq!( @@ -246,12 +278,13 @@ fn bulk_metadata_identifies_clones_and_hard_links() { }) .unwrap() .unwrap(); - assert_eq!(ordinary.metadata.unwrap().clone_id(), None); + assert_eq!(ordinary.metadata.unwrap().unwrap().clone_id(), None); assert_eq!( Entry::from_path(&original, options(false)) .unwrap() .metadata .unwrap() + .unwrap() .clone_id(), None ); @@ -260,7 +293,7 @@ fn bulk_metadata_identifies_clones_and_hard_links() { .unwrap() .map(|entry| { let entry = entry.unwrap(); - (entry.file_name, entry.metadata.unwrap()) + (entry.file_name, entry.metadata.unwrap().unwrap()) }) .collect::>(); let original = &entries[std::ffi::OsStr::new("original")]; @@ -295,6 +328,7 @@ fn bulk_metadata_identifies_clones_and_hard_links() { let root = Entry::from_path(&clone, options(true)) .unwrap() .metadata + .unwrap() .unwrap(); assert_eq!( root.clone_id(), @@ -314,7 +348,7 @@ fn bulk_device_numbers_match_std_on_devfs() { let expected = fs::symlink_metadata(directory.join("null")).unwrap(); assert_eq!( - entry.metadata.unwrap().dev(), + entry.metadata.unwrap().unwrap().dev(), expected.dev(), "bulk metadata must report the devfs device number returned by stat" ); diff --git a/crates/dua-lib/src/windows.rs b/crates/dua-lib/src/windows.rs index 2e0ce7be..0c69447c 100644 --- a/crates/dua-lib/src/windows.rs +++ b/crates/dua-lib/src/windows.rs @@ -2,7 +2,7 @@ use std::{ ffi::OsString, - io, + fs, io, os::windows::ffi::{OsStrExt, OsStringExt}, path::{Component, Path, PathBuf}, sync::Arc, @@ -34,8 +34,8 @@ pub struct Entry { pub file_name: OsString, /// Filesystem entry type without following symbolic links. pub file_type: FileType, - /// Metadata obtained with directory enumeration. - pub metadata: io::Result, + /// Requested metadata or its read error; `None` when [`crate::Options::skip_metadata`] is set. + pub metadata: Option>, /// Path containing this entry. pub parent_path: Arc, /// Dense identifier of this directory within the current walk, or `None` for non-directories. @@ -46,12 +46,23 @@ pub struct Entry { impl Entry { /// Create an entry from a filesystem path. - pub fn from_path(path: &Path, _options: crate::Options) -> io::Result { + pub fn from_path(path: &Path, options: crate::Options) -> io::Result { let handle = OwnedHandle::open( path, FILE_READ_ATTRIBUTES | SYNCHRONIZE, FILE_FLAG_OPEN_REPARSE_POINT, )?; + if options.skip_metadata { + return Ok(Self { + depth: 0, + file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(), + file_type: handle.file_type()?, + metadata: None, + parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))), + directory_id: None, + parent_directory_id: None, + }); + } let mut info = BY_HANDLE_FILE_INFORMATION::default(); // SAFETY: `handle` is owned and valid, and `info` is writable for the call's duration. if unsafe { GetFileInformationByHandle(handle.0, &raw mut info) } == 0 { @@ -70,25 +81,11 @@ impl Entry { { return Err(io::Error::last_os_error()); } - let reparse_tag = if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - let mut tag = FILE_ATTRIBUTE_TAG_INFO::default(); - // SAFETY: the output pointer and byte count describe the initialized `tag` value. - if unsafe { - GetFileInformationByHandleEx( - handle.0, - FileAttributeTagInfo, - (&raw mut tag).cast(), - size_of::() as u32, - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - tag.ReparseTag + let file_type = if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + handle.file_type()? } else { - 0 + FileType::from_attributes(info.dwFileAttributes, 0) }; - let file_type = FileType::from_attributes(info.dwFileAttributes, reparse_tag); let file_id = u64::from(info.nFileIndexHigh) << 32 | u64::from(info.nFileIndexLow); let metadata = Metadata { len: u64::from(info.nFileSizeHigh) << 32 | u64::from(info.nFileSizeLow), @@ -104,7 +101,7 @@ impl Entry { depth: 0, file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(), file_type, - metadata: Ok(metadata), + metadata: Some(Ok(metadata)), parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))), directory_id: None, parent_directory_id: None, @@ -126,6 +123,13 @@ pub struct FileType { } impl FileType { + pub(crate) fn from_std(file_type: fs::FileType) -> Self { + Self { + is_dir: file_type.is_dir(), + is_symlink: file_type.is_symlink(), + } + } + fn from_attributes(attributes: u32, reparse_tag: u32) -> Self { let is_symlink = attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 && reparse_tag & NAME_SURROGATE_BIT != 0; @@ -191,6 +195,13 @@ impl Metadata { } } +pub(crate) fn read_dir_types(path: &Path) -> io::Result { + let verbatim = absolute_verbatim_path(path)?; + fs::read_dir(PathBuf::from(OsString::from_wide( + &verbatim[..verbatim.len() - 1], + ))) +} + pub(crate) struct ReadDir { handle: OwnedHandle, buffer: Vec, @@ -321,13 +332,13 @@ impl ReadDir { depth: self.depth, file_name, file_type, - metadata: Ok(Metadata { + metadata: Some(Ok(Metadata { len: info.EndOfFile.max(0).cast_unsigned(), allocated_size: info.AllocationSize.max(0).cast_unsigned(), modified: filetime_to_system_time(info.LastWriteTime.max(0).cast_unsigned()), volume_serial: self.volume_serial, file_id, - }), + })), parent_path: Arc::clone(&self.parent_path), directory_id: None, parent_directory_id: None, @@ -373,6 +384,26 @@ impl Iterator for ReadDir { struct OwnedHandle(HANDLE); impl OwnedHandle { + fn file_type(&self) -> io::Result { + let mut tag = FILE_ATTRIBUTE_TAG_INFO::default(); + // SAFETY: the owned handle is valid, and the output pointer and length describe `tag`. + if unsafe { + GetFileInformationByHandleEx( + self.0, + FileAttributeTagInfo, + (&raw mut tag).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(FileType::from_attributes( + tag.FileAttributes, + tag.ReparseTag, + )) + } + fn open(path: &Path, access: u32, extra_flags: u32) -> io::Result { let path = absolute_verbatim_path(path)?; // SAFETY: `path` is null-terminated and all remaining pointer arguments follow the @@ -521,12 +552,13 @@ mod tests { let path = dir.path().join("file"); std::fs::write(&path, b"content").unwrap(); let mut entries = - ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()).unwrap(); + crate::ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()).unwrap(); let entry = entries.find_map(Result::ok).unwrap(); - let metadata = entry.metadata.unwrap(); + let metadata = entry.metadata.unwrap().unwrap(); let direct = Entry::from_path(&path, crate::Options::default()) .unwrap() .metadata + .unwrap() .unwrap(); assert_eq!(metadata.len(), 7); assert!(metadata.allocated_size() >= metadata.len()); @@ -543,14 +575,15 @@ mod tests { std::fs::create_dir(&path).unwrap(); std::fs::write(path.join("file"), b"content").unwrap(); - let entry = ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()) + let entry = crate::ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()) .unwrap() .find_map(Result::ok) .unwrap(); - let metadata = entry.metadata.unwrap(); + let metadata = entry.metadata.unwrap().unwrap(); let direct = Entry::from_path(&path, crate::Options::default()) .unwrap() .metadata + .unwrap() .unwrap(); assert_eq!(metadata.len(), direct.len()); assert_eq!(metadata.allocated_size(), direct.allocated_size()); @@ -569,9 +602,21 @@ mod tests { Err(err) => panic!("directory symlink can be created: {err}"), } - let entry = Entry::from_path(&link, crate::Options::default()).unwrap(); - assert!(entry.file_type.is_symlink()); - assert!(!entry.file_type.is_dir()); + for skip_metadata in [false, true] { + let options = crate::Options { skip_metadata }; + let entry = Entry::from_path(&link, options).unwrap(); + assert!(entry.file_type.is_symlink()); + assert!(!entry.file_type.is_dir()); + + let entry = crate::ReadDir::open(Arc::from(dir.path()), 1, options) + .unwrap() + .map(Result::unwrap) + .find(|entry| entry.file_name == "link") + .unwrap(); + assert!(entry.file_type.is_symlink()); + assert!(!entry.file_type.is_dir()); + assert_eq!(entry.metadata.is_none(), skip_metadata); + } } #[test] @@ -581,9 +626,17 @@ mod tests { std::fs::write(&original, b"content").unwrap(); std::fs::hard_link(&original, dir.path().join("link")).unwrap(); - let ids = ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()) + let ids = crate::ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()) .unwrap() - .map(|entry| entry.unwrap().metadata.unwrap().hard_link_id().unwrap()) + .map(|entry| { + entry + .unwrap() + .metadata + .unwrap() + .unwrap() + .hard_link_id() + .unwrap() + }) .collect::>(); assert_eq!(ids.len(), 2); assert_eq!(ids[0], ids[1]); @@ -597,7 +650,7 @@ mod tests { std::fs::write(dir.path().join(name), []).unwrap(); } - let count = ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()) + let count = crate::ReadDir::open(Arc::from(dir.path()), 1, crate::Options::default()) .unwrap() .map(Result::unwrap) .count(); @@ -614,11 +667,17 @@ mod tests { } std::fs::write(path.join("file"), b"content").unwrap(); - let entries = ReadDir::open(Arc::from(path), 1, crate::Options::default()) + for skip_metadata in [false, true] { + let entries = crate::ReadDir::open( + Arc::from(path.as_path()), + 1, + crate::Options { skip_metadata }, + ) .unwrap() .map(|entry| entry.unwrap().file_name) .collect::>(); - assert_eq!(entries, [OsString::from("file")]); + assert_eq!(entries, [OsString::from("file")]); + } } #[test] @@ -636,11 +695,17 @@ mod tests { .unwrap() .ends_with(&"child.\0".encode_utf16().collect::>()) ); - let entries = ReadDir::open(Arc::from(ordinary_child), 1, crate::Options::default()) + for skip_metadata in [false, true] { + let entries = crate::ReadDir::open( + Arc::from(ordinary_child.as_path()), + 1, + crate::Options { skip_metadata }, + ) .unwrap() .map(|entry| entry.unwrap().file_name) .collect::>(); - assert_eq!(entries, [OsString::from("file")]); + assert_eq!(entries, [OsString::from("file")]); + } } #[test] @@ -649,10 +714,16 @@ mod tests { std::fs::write(dir.path().join("file"), b"content").unwrap(); let path = dir.path().join("missing").join(".."); - let entries = ReadDir::open(Arc::from(path), 1, crate::Options::default()) + for skip_metadata in [false, true] { + let entries = crate::ReadDir::open( + Arc::from(path.as_path()), + 1, + crate::Options { skip_metadata }, + ) .unwrap() .map(|entry| entry.unwrap().file_name) .collect::>(); - assert_eq!(entries, [OsString::from("file")]); + assert_eq!(entries, [OsString::from("file")]); + } } } diff --git a/crates/dua-lib/tests/dua.rs b/crates/dua-lib/tests/dua.rs index 98c97d0c..36fe075e 100644 --- a/crates/dua-lib/tests/dua.rs +++ b/crates/dua-lib/tests/dua.rs @@ -76,3 +76,71 @@ fn duplicate_root_indices_are_rejected() { |_, _| true, ); } + +#[test] +fn type_only_walks_keep_types_without_collecting_metadata() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("nested/child")).unwrap(); + fs::write(dir.path().join("nested/file"), b"content").unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink("nested", dir.path().join("link")).unwrap(); + std::os::unix::fs::symlink("missing", dir.path().join("broken")).unwrap(); + } + let options = dua_core::Options::default().skip_metadata(); + for order in [Order::ParentFirst, Order::Completion] { + let expected = walk(dir.path(), 2, order, dua_core::Options::default(), |_| true) + .map(|entry| { + let entry = entry.unwrap(); + ( + entry.path(), + ( + entry.depth, + entry.file_type.is_dir(), + entry.file_type.is_symlink(), + ), + ) + }) + .collect::>(); + let mut walker = walk(dir.path(), 2, order, options, |_| true); + for pass in 0..2 { + if pass != 0 { + assert!(walker.restart()); + } + let entries = walker.by_ref().collect::, _>>().unwrap(); + let actual = entries + .into_iter() + .map(|entry| { + assert!( + entry.metadata.is_none(), + "metadata was not requested: {:?}", + entry.path() + ); + ( + entry.path(), + ( + entry.depth, + entry.file_type.is_dir(), + entry.file_type.is_symlink(), + ), + ) + }) + .collect::>(); + assert_eq!(actual, expected); + } + + let mut paths = BTreeSet::new(); + let events = walk_roots([(7, dir.path().to_owned())], 2, order, options, |_, _| true); + for (root, event) in events { + assert_eq!(root, 7); + match event { + RootEvent::Entry(entry) => { + let entry = entry.unwrap(); + assert!(entry.metadata.is_none()); + paths.insert(entry.path()); + } + RootEvent::Finished => assert_eq!(paths, expected.keys().cloned().collect()), + } + } + } +} diff --git a/src/aggregate.rs b/src/aggregate.rs index c02e3409..a6cf3a19 100644 --- a/src/aggregate.rs +++ b/src/aggregate.rs @@ -303,7 +303,7 @@ fn aggregate_inner( #[cfg(target_os = "macos")] let root_device_id = prepared_entry .as_ref() - .and_then(|entry| entry.metadata.as_ref().ok()) + .and_then(|entry| entry.metadata.as_ref()?.as_ref().ok()) .map_or_else(|| crossdev::init(&path), |metadata| Ok(metadata.dev())); #[cfg(not(target_os = "macos"))] let root_device_id = crossdev::init(&path); @@ -360,7 +360,7 @@ fn aggregate_inner( || entry.file_type.is_symlink() && entry.path().is_file(); } let file_size = u128::from(match &entry.metadata { - Ok(m) + Some(Ok(m)) if (walk_options.count_hard_links || inodes.add(&entry, m)) && (walk_options.cross_filesystems || crossdev::is_same_device(device_ids[root_idx], m)) => @@ -383,8 +383,8 @@ fn aggregate_inner( } } } - Ok(_) => 0, - Err(_) => { + Some(Ok(_)) => 0, + Some(Err(_)) | None => { aggregate.errors += 1; 0 } @@ -916,6 +916,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions { + skip_metadata: false, apfs_clone_metadata: true, }, }, @@ -937,6 +938,7 @@ mod tests { let entries = dua_core::read_dir( directory.path(), dua_core::Options { + skip_metadata: false, apfs_clone_metadata: true, }, ) @@ -955,6 +957,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions { + skip_metadata: false, apfs_clone_metadata: true, }, }, @@ -1173,7 +1176,7 @@ mod tests { std::fs::write(&path, b"content").unwrap(); let entry = crate::walk::Entry::from_path(&path, crate::TraversalOptions::default()).unwrap(); - let metadata = entry.metadata.as_ref().unwrap(); + let metadata = entry.metadata.as_ref().unwrap().as_ref().unwrap(); let expected = metadata.allocated_size(); std::fs::remove_file(path).unwrap(); assert_eq!( @@ -1190,7 +1193,7 @@ mod tests { std::fs::write(dir.path().join("file"), b"content").unwrap(); let entry = crate::walk::Entry::from_path(dir.path(), crate::TraversalOptions::default()).unwrap(); - let metadata = entry.metadata.as_ref().unwrap(); + let metadata = entry.metadata.as_ref().unwrap().as_ref().unwrap(); assert_eq!(size_on_disk(&entry, metadata).unwrap(), 0); } } diff --git a/src/common.rs b/src/common.rs index 587d89a5..35da621f 100644 --- a/src/common.rs +++ b/src/common.rs @@ -346,9 +346,13 @@ impl WalkOptions { let is_excluded_while_walking = Arc::clone(&is_excluded); let descend = move |root_idx: usize, entry: &walk::Entry| { (cross_filesystems - || entry.metadata.as_ref().map_or(true, |metadata| { - crossdev::is_same_device(device_ids[root_idx], metadata) - })) + || entry + .metadata + .as_ref() + .and_then(|metadata| metadata.as_ref().ok()) + .is_none_or(|metadata| { + crossdev::is_same_device(device_ids[root_idx], metadata) + })) && (entry.depth == 0 || !ignore_directory(&entry.path(), &ignore_dirs, &cwd)) && !is_excluded_while_walking(root_idx, entry) }; diff --git a/src/interactive/app/handlers.rs b/src/interactive/app/handlers.rs index b1937cf4..7c39d615 100644 --- a/src/interactive/app/handlers.rs +++ b/src/interactive/app/handlers.rs @@ -49,7 +49,7 @@ enum AnnotationKind { /// [`EntryDeletionStats`] describes the lower-level removal of one selected entry. struct DeletionStats { entries: usize, - bytes: u128, + bytes: Option, errors: usize, elapsed: Duration, } @@ -58,6 +58,7 @@ struct DeletionStats { #[derive(Default)] struct EntryDeletionStats { entries: usize, + /// Scanned size, assigned only after the selected entry was completely removed. bytes: u128, errors: usize, } @@ -379,7 +380,7 @@ impl AppState { self.language.ui_text().notification_deletion, DeletionStats { entries: entries_deleted, - bytes: bytes_deleted, + bytes: (errors == 0).then_some(bytes_deleted), elapsed: start.elapsed(), errors, }, @@ -422,7 +423,7 @@ impl AppState { self.language.ui_text().notification_trash, DeletionStats { entries: entries_trashed, - bytes: bytes_trashed, + bytes: Some(bytes_trashed), elapsed: start.elapsed(), errors, }, @@ -706,28 +707,26 @@ fn io_err_to_usize(err: io::Error) -> usize { /// sees an empty directory. fn delete_directory_recursively(path: PathBuf, threads: usize) -> EntryDeletionStats { let mut stats = EntryDeletionStats::default(); - let mut dirs: Vec<(PathBuf, u128, usize)> = Vec::new(); - let mut files: Vec<(PathBuf, u128)> = Vec::new(); + let mut dirs: Vec<(PathBuf, usize)> = Vec::new(); + let mut files: Vec = Vec::new(); for entry in dua_core::walk( &path, threads, dua_core::Order::Completion, - dua_core::Options::default(), + dua_core::Options::default().skip_metadata(), |_| true, ) { match entry { Ok(entry) => { let entry_path = entry.path(); - let bytes = - u128::from(entry.metadata.as_ref().map_or(0, |metadata| metadata.len())); if entry.file_type.is_dir() { // Real directory (symlinks to dirs report is_symlink, not // is_dir, when follow_links is false): remove after children. - dirs.push((entry_path, bytes, entry.depth)); + dirs.push((entry_path, entry.depth)); } else { // Regular file or symlink — remove without following. - files.push((entry_path, bytes)); + files.push(entry_path); } } Err(_) => stats.errors += 1, @@ -740,10 +739,12 @@ fn delete_directory_recursively(path: PathBuf, threads: usize) -> EntryDeletionS .map(|_| { scope.spawn(|| { let mut total = EntryDeletionStats::default(); - while let Some((path, bytes)) = - files.get(next_file.fetch_add(1, Ordering::Relaxed)) - { - record_removal(fs::remove_file(path), *bytes, &mut total); + while let Some(path) = files.get(next_file.fetch_add(1, Ordering::Relaxed)) { + let result = fs::remove_file(path); + // Windows directory symlinks and junctions require RemoveDirectory. + #[cfg(windows)] + let result = result.or_else(|_| fs::remove_dir(path)); + record_removal(result, &mut total); } total }) @@ -755,21 +756,18 @@ fn delete_directory_recursively(path: PathBuf, threads: usize) -> EntryDeletionS .map(|handle| handle.join().expect("deletion worker does not panic")) .fold(EntryDeletionStats::default(), |mut total, stats| { total.entries += stats.entries; - total.bytes += stats.bytes; total.errors += stats.errors; total }) }); stats.entries += file_stats.entries; - stats.bytes += file_stats.bytes; stats.errors += file_stats.errors; // Remove directories deepest-first so parents are empty when removed. - dirs.sort_by(|a, b| a.2.cmp(&b.2).reverse()); - for (dir, bytes, _) in dirs { + dirs.sort_by(|(_, a_depth), (_, b_depth)| a_depth.cmp(b_depth).reverse()); + for (dir, _) in dirs { record_removal( fs::remove_dir(&dir).or_else(|_| fs::remove_file(dir)), - bytes, &mut stats, ); } @@ -777,12 +775,9 @@ fn delete_directory_recursively(path: PathBuf, threads: usize) -> EntryDeletionS stats } -fn record_removal(result: io::Result<()>, bytes: u128, stats: &mut EntryDeletionStats) { +fn record_removal(result: io::Result<()>, stats: &mut EntryDeletionStats) { match result { - Ok(()) => { - stats.entries += 1; - stats.bytes += bytes; - } + Ok(()) => stats.entries += 1, Err(err) => stats.errors += io_err_to_usize(err), } } @@ -794,15 +789,13 @@ mod deletion_notification_tests { #[test] fn retains_partial_success_statistics_alongside_errors() { let mut stats = EntryDeletionStats::default(); - record_removal(Ok(()), 42, &mut stats); + record_removal(Ok(()), &mut stats); record_removal( Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")), - 100, &mut stats, ); assert_eq!(stats.entries, 1); - assert_eq!(stats.bytes, 42); assert_eq!(stats.errors, 1); } } @@ -841,25 +834,38 @@ mod delete_directory_recursively_tests { assert!(!root.exists()); } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn removes_symlink_without_following_it() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - fs::create_dir(&target).unwrap(); - fs::write(target.join("keep.txt"), b"keep").unwrap(); - - let link = dir.path().join("link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); + for delete_parent in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("keep.txt"), b"keep").unwrap(); + + let root = dir.path().join("root"); + fs::create_dir(&root).unwrap(); + let link = root.join("link"); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &link).unwrap(); + #[cfg(windows)] + match std::os::windows::fs::symlink_dir(&target, &link) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => return, + Err(err) => panic!("directory symlink can be created: {err}"), + } - let stats = delete_directory_recursively(link.clone(), 1); + let stats = + delete_directory_recursively(if delete_parent { root } else { link.clone() }, 2); - assert_eq!(stats.errors, 0); - assert!(!link.exists(), "the symlink itself should be gone"); - assert!( - target.join("keep.txt").exists(), - "the symlink target must not be deleted" - ); + assert_eq!(stats.errors, 0); + assert_eq!(stats.entries, if delete_parent { 2 } else { 1 }); + assert!(!link.exists(), "the symlink itself should be gone"); + assert!( + target.join("keep.txt").exists(), + "the symlink target must not be deleted" + ); + } } #[test] diff --git a/src/interactive/app/notification.rs b/src/interactive/app/notification.rs index f745318b..ae310820 100644 --- a/src/interactive/app/notification.rs +++ b/src/interactive/app/notification.rs @@ -25,7 +25,7 @@ pub fn deletion_finished( language: Language, action: &str, entries: usize, - bytes: u128, + bytes: Option, elapsed: Duration, errors: usize, format: ByteFormat, @@ -33,7 +33,7 @@ pub fn deletion_finished( language.notification_summary( action, u64::try_from(entries).unwrap_or(u64::MAX), - &format.display(bytes).to_string(), + &bytes.map_or_else(|| "?".into(), |bytes| format.display(bytes).to_string()), &duration(elapsed), u64::try_from(errors).unwrap_or(u64::MAX), ) @@ -78,6 +78,22 @@ fn duration(duration: Duration) -> String { mod tests { use super::*; + #[test] + fn partial_deletion_reports_unknown_bytes() { + assert_eq!( + deletion_finished( + Language::English, + "Deletion", + 2, + None, + Duration::from_secs(1), + 1, + ByteFormat::Metric + ), + "Deletion finished: 2 entries, ? in 1.0s, 1 errors" + ); + } + #[test] fn emits_sanitized_osc_777_notification() { let mut output = Vec::new(); diff --git a/src/main.rs b/src/main.rs index a4b51bc4..8f874ae7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -683,6 +683,7 @@ fn walk_options_from(traversal: &options::ScanArgs) -> Result ignore_dirs: canonicalize_ignore_dirs(&traversal.ignore_dirs), ignore_patterns: dua::IgnorePatterns::from_files(&traversal.ignore_from)?, metadata_options: dua::TraversalOptions { + skip_metadata: false, #[cfg(target_os = "macos")] apfs_clone_metadata: traversal.deduplicate_apfs_clones && !traversal.apparent_size, }, @@ -722,10 +723,14 @@ fn extract_aggregate_inputs_maybe_set_cwd( #[cfg(target_os = "macos")] if let Some(cwd_device) = cwd_device { - let same_device = entry.metadata.as_ref().map_or_else( - |_| device_id(&entry.path()).map_or(true, |device| device == cwd_device), - |metadata| metadata.dev() == cwd_device, - ); + let same_device = entry + .metadata + .as_ref() + .and_then(|metadata| metadata.as_ref().ok()) + .map_or_else( + || device_id(&entry.path()).map_or(true, |device| device == cwd_device), + |metadata| metadata.dev() == cwd_device, + ); if !same_device { continue; } diff --git a/src/traverse.rs b/src/traverse.rs index a82c4131..3dc89199 100644 --- a/src/traverse.rs +++ b/src/traverse.rs @@ -1016,7 +1016,7 @@ impl BackgroundTraversal { let mut mtime: SystemTime = UNIX_EPOCH; let mut has_mtime = false; data.is_dir = entry.file_type.is_dir(); - if let Ok(m) = &entry.metadata { + if let Some(Ok(m)) = &entry.metadata { if self.walk_options.count_hard_links || self.inodes.add(&entry, m) && (self.walk_options.cross_filesystems @@ -1419,6 +1419,7 @@ mod tests { ignore_dirs: std::collections::BTreeSet::default(), ignore_patterns: None, metadata_options: crate::TraversalOptions { + skip_metadata: false, apfs_clone_metadata: deduplicate, }, },