diff --git a/CHANGELOG.md b/CHANGELOG.md index 44555fb5a..26a509df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Features +- Add support for filtering orphaned files with `--owner "-"` (equivalent to find's `-nouser`/`-nogroup`) + ## Bugfixes diff --git a/doc/fd.1 b/doc/fd.1 index df42b1724..906f8246a 100644 --- a/doc/fd.1 +++ b/doc/fd.1 @@ -378,12 +378,15 @@ Examples: .TP .BI "-o, \-\-owner " [user][:group] Filter files by their user and/or group. Format: [(user|uid)][:(group|gid)]. Either side -is optional. Precede either side with a '!' to exclude files instead. +is optional. Precede either side with a '!' to exclude files instead. Use '\-' to match +files with no valid user/group (equivalent to find's \-nouser/\-nogroup). Examples: \-\-owner john \-\-owner :students \-\-owner "!john:students" + \-\-owner "\-" + \-\-owner ":\-" .TP .BI "-C, \-\-base\-directory " path Change the current working directory of fd to the provided path. This means that search results will diff --git a/src/cli.rs b/src/cli.rs index d5174689d..c5d8b398c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -441,11 +441,14 @@ pub struct Opts { /// Filter files by their user and/or group. /// Format: [(user|uid)][:(group|gid)]. Either side is optional. /// Precede either side with a '!' to exclude files instead. + /// Use '-' to match files with no valid user/group. /// /// Examples: /// {n} --owner john /// {n} --owner :students /// {n} --owner '!john:students' + /// {n} --owner "-" + /// {n} --owner ":-" #[cfg(unix)] #[arg(long, short = 'o', value_parser = OwnerFilter::from_string, value_name = "user:group", help = "Filter by owning user and/or group", diff --git a/src/filter/owner.rs b/src/filter/owner.rs index 0f56fda71..43df5ef74 100644 --- a/src/filter/owner.rs +++ b/src/filter/owner.rs @@ -1,26 +1,45 @@ use anyhow::{Result, anyhow}; use nix::unistd::{Group, User}; -use std::fs; +use std::{cell::RefCell, collections::HashMap, fs}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +thread_local! { + static UID_CACHE: RefCell> = RefCell::new(HashMap::new()); + static GID_CACHE: RefCell> = RefCell::new(HashMap::new()); +} + +fn is_valid_uid(uid: u32) -> bool { + UID_CACHE.with(|cache| { + *cache + .borrow_mut() + .entry(uid) + .or_insert_with(|| matches!(User::from_uid(uid.into()), Ok(Some(_)))) + }) +} + +fn is_valid_gid(gid: u32) -> bool { + GID_CACHE.with(|cache| { + *cache + .borrow_mut() + .entry(gid) + .or_insert_with(|| matches!(Group::from_gid(gid.into()), Ok(Some(_)))) + }) +} + +#[derive(Clone, Copy, Debug)] pub struct OwnerFilter { uid: Check, gid: Check, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug)] enum Check { Equal(T), NotEq(T), Ignore, + Orphan(fn(T) -> bool), } impl OwnerFilter { - const IGNORE: Self = OwnerFilter { - uid: Check::Ignore, - gid: Check::Ignore, - }; - /// Parses an owner constraint /// Returns an error if the string is invalid /// Returns Ok(None) when string is acceptable but a noop (such as "" or ":") @@ -35,31 +54,39 @@ impl OwnerFilter { )); } - let uid = Check::parse(fst, |s| { - if let Ok(uid) = s.parse() { - Ok(uid) - } else { - User::from_name(s)? - .map(|user| user.uid.as_raw()) - .ok_or_else(|| anyhow!("'{}' is not a recognized user name", s)) - } - })?; - let gid = Check::parse(snd, |s| { - if let Ok(gid) = s.parse() { - Ok(gid) - } else { - Group::from_name(s)? - .map(|group| group.gid.as_raw()) - .ok_or_else(|| anyhow!("'{}' is not a recognized group name", s)) - } - })?; + let uid = Check::parse( + fst, + |s| { + if let Ok(uid) = s.parse() { + Ok(uid) + } else { + User::from_name(s)? + .map(|user| user.uid.as_raw()) + .ok_or_else(|| anyhow!("'{}' is not a recognized user name", s)) + } + }, + is_valid_uid, + )?; + let gid = Check::parse( + snd, + |s| { + if let Ok(gid) = s.parse() { + Ok(gid) + } else { + Group::from_name(s)? + .map(|group| group.gid.as_raw()) + .ok_or_else(|| anyhow!("'{}' is not a recognized group name", s)) + } + }, + is_valid_gid, + )?; Ok(OwnerFilter { uid, gid }) } /// If self is a no-op (ignore both uid and gid) then return `None`, otherwise wrap in a `Some` pub fn filter_ignore(self) -> Option { - if self == Self::IGNORE { + if matches!(self.uid, Check::Ignore) && matches!(self.gid, Check::Ignore) { None } else { Some(self) @@ -79,15 +106,17 @@ impl Check { Check::Equal(x) => v == *x, Check::NotEq(x) => v != *x, Check::Ignore => true, + Check::Orphan(validator) => !validator(v), } } - fn parse(s: Option<&str>, f: F) -> Result + fn parse(s: Option<&str>, f: F, validator: fn(T) -> bool) -> Result where F: Fn(&str) -> Result, { let (s, equality) = match s { Some("") | None => return Ok(Check::Ignore), + Some("-") => return Ok(Check::Orphan(validator)), Some(s) if s.starts_with('!') => (&s[1..], false), Some(s) => (s, true), }; @@ -123,17 +152,21 @@ mod owner_parsing { use super::Check::*; owner_tests! { - empty: "" => Ok(OwnerFilter::IGNORE), + empty: "" => Ok(OwnerFilter { uid: Ignore, gid: Ignore }), uid_only: "5" => Ok(OwnerFilter { uid: Equal(5), gid: Ignore }), uid_gid: "9:3" => Ok(OwnerFilter { uid: Equal(9), gid: Equal(3) }), gid_only: ":8" => Ok(OwnerFilter { uid: Ignore, gid: Equal(8) }), - colon_only: ":" => Ok(OwnerFilter::IGNORE), + colon_only: ":" => Ok(OwnerFilter { uid: Ignore, gid: Ignore }), trailing: "5:" => Ok(OwnerFilter { uid: Equal(5), gid: Ignore }), uid_negate: "!5" => Ok(OwnerFilter { uid: NotEq(5), gid: Ignore }), both_negate:"!4:!3" => Ok(OwnerFilter { uid: NotEq(4), gid: NotEq(3) }), uid_not_gid:"6:!8" => Ok(OwnerFilter { uid: Equal(6), gid: NotEq(8) }), + orphan_uid: "-" => Ok(OwnerFilter { uid: Orphan(_), gid: Ignore }), + orphan_gid: ":-" => Ok(OwnerFilter { uid: Ignore, gid: Orphan(_) }), + orphan_both:"-:-" => Ok(OwnerFilter { uid: Orphan(_), gid: Orphan(_) }), + more_colons:"3:5:" => Err(_), only_colons:"::" => Err(_), }