Repository: sharkdp/fd (language: rust)

Issue:
Finding by owner username/group/uid/gid
Hello,

I've been using `fd` for quite a long time, and loving it! Recently I've found myself needing to search for files owned by one user or another. I'm willing to do any implementation work required for it, and i'd like to start off a discussion and see if anyone else here has had a need for this feature.

Interface-wise, my first proposal would be to add a `--user` (as well as a `--group` switch) in the style of find, and maybe they could handle a uid/gid by default when passed a number.

Another idea, which is more flexible and less backwards-looking would be passing an `--owner` flag that can parse a `[(uid|username)][:(gid|groupname)]` input pair. As a precedent, docker's `--user` does this. Maybe it would make sense to call the flag `--user` in fd also, but i'm personally worried that it would make finding the "search by gid" option a little more obscure or unintuitive.

Lastly, I really don't know what to say right now about negative reasoning. I haven't encountered the need per se, but it feels "natural" in a way to be able to search for files "not owned by my user in this directory". Any ideas of how (or if) to do negation/exclusion by owner are welcome.

I'll start to hack a prototype together, and I'm really curious as what people think about this option.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d419b4f0e..77e6139d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
 
 ## Features
 
-
+- Add new `--owner [user][:group]` filter. See #307 (pull #581) (@alexmaco)
 
 ## Bugfixes
 
diff --git a/Cargo.lock b/Cargo.lock
index 20328c789..f605640ec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -137,6 +137,7 @@ dependencies = [
  "regex 1.3.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "regex-syntax 0.6.17 (registry+https://github.com/rust-lang/crates.io-index)",
  "tempdir 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
+ "users 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "version_check 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
@@ -410,6 +411,15 @@ name = "unicode-width"
 version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 
+[[package]]
+name = "users"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "libc 0.2.69 (registry+https://github.com/rust-lang/crates.io-index)",
+ "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "vec_map"
 version = "0.8.1"
@@ -522,6 +532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 "checksum textwrap 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
 "checksum thread_local 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14"
 "checksum unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479"
+"checksum users 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486"
 "checksum vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a"
 "checksum version_check 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce"
 "checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
diff --git a/Cargo.toml b/Cargo.toml
index 2cc08192c..7ac461af8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -51,6 +51,9 @@ anyhow = "1.0"
 version = "2.31.2"
 features = ["suggestions", "color", "wrap_help"]
 
+[target.'cfg(unix)'.dependencies]
+users = "0.10.0"
+
 [target.'cfg(all(unix, not(target_os = "redox")))'.dependencies]
 libc = "0.2"
 
diff --git a/doc/fd.1 b/doc/fd.1
index 3c31cd753..923e5d7cb 100644
--- a/doc/fd.1
+++ b/doc/fd.1
@@ -224,6 +224,15 @@ Examples:
   \-\-changed-before "2018-10-27 10:00:00"
   \-\-change-older-than 2weeks
 .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.
+
+Examples:
+  \-\-owner john
+  \-\-owner :students
+  \-\-owner "!john:students"
+.TP
 .BI "\-x, \-\-exec " command
 Execute
 .I command
diff --git a/src/app.rs b/src/app.rs
index 8c23e23f8..6faa2dd2a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -524,6 +524,26 @@ pub fn build_app() -> App<'static, 'static> {
                 ),
         );
 
+    if cfg!(unix) {
+        app = app.arg(
+            Arg::with_name("owner")
+                .long("owner")
+                .short("o")
+                .takes_value(true)
+                .value_name("user:group")
+                .help("Filter by owning user and/or group")
+                .long_help(
+                    "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.\n\
+                     Examples:\n    \
+                         --owner john\n    \
+                         --owner :students\n    \
+                         --owner '!john:students'",
+                ),
+        );
+    }
+
     // Make `--one-file-system` available only on Unix and Windows platforms, as per the
     // restrictions on the corresponding option in the `ignore` crate.
     // Provide aliases `mount` and `xdev` for people coming from `find`.
diff --git a/src/filter/mod.rs b/src/filter/mod.rs
index e513c0c89..5e45d3b1c 100644
--- a/src/filter/mod.rs
+++ b/src/filter/mod.rs
@@ -1,5 +1,11 @@
 pub use self::size::SizeFilter;
 pub use self::time::TimeFilter;
 
+#[cfg(unix)]
+pub use self::owner::OwnerFilter;
+
 mod size;
 mod time;
+
+#[cfg(unix)]
+mod owner;
diff --git a/src/filter/owner.rs b/src/filter/owner.rs
new file mode 100644
index 000000000..b69c2eff1
--- /dev/null
+++ b/src/filter/owner.rs
@@ -0,0 +1,123 @@
+use anyhow::{anyhow, Result};
+use std::fs;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct OwnerFilter {
+    uid: Check<u32>,
+    gid: Check<u32>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Check<T> {
+    Equal(T),
+    NotEq(T),
+    Ignore,
+}
+
+impl OwnerFilter {
+    /// 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 ":")
+    pub fn from_string(input: &str) -> Result<Option<Self>> {
+        let mut it = input.split(':');
+        let (fst, snd) = (it.next(), it.next());
+
+        if it.next().is_some() {
+            return Err(anyhow!(
+                "more than one ':' present in owner string '{}'. See 'fd --help'.",
+                input
+            ));
+        }
+
+        let uid = Check::parse(fst, |s| {
+            s.parse()
+                .ok()
+                .or_else(|| users::get_user_by_name(s).map(|user| user.uid()))
+                .ok_or_else(|| anyhow!("'{}' is not a recognized user name", s))
+        })?;
+        let gid = Check::parse(snd, |s| {
+            s.parse()
+                .ok()
+                .or_else(|| users::get_group_by_name(s).map(|group| group.gid()))
+                .ok_or_else(|| anyhow!("'{}' is not a recognized group name", s))
+        })?;
+
+        if let (Check::Ignore, Check::Ignore) = (uid, gid) {
+            Ok(None)
+        } else {
+            Ok(Some(OwnerFilter { uid, gid }))
+        }
+    }
+
+    pub fn matches(&self, md: &fs::Metadata) -> bool {
+        use std::os::unix::fs::MetadataExt;
+
+        self.uid.check(md.uid()) && self.gid.check(md.gid())
+    }
+}
+
+impl<T: PartialEq> Check<T> {
+    fn check(&self, v: T) -> bool {
+        match self {
+            Check::Equal(x) => v == *x,
+            Check::NotEq(x) => v != *x,
+            Check::Ignore => true,
+        }
+    }
+
+    fn parse<F>(s: Option<&str>, f: F) -> Result<Self>
+    where
+        F: Fn(&str) -> Result<T>,
+    {
+        let (s, equality) = match s {
+            Some("") | None => return Ok(Check::Ignore),
+            Some(s) if s.starts_with('!') => (&s[1..], false),
+            Some(s) => (s, true),
+        };
+
+        f(s).map(|x| {
+            if equality {
+                Check::Equal(x)
+            } else {
+                Check::NotEq(x)
+            }
+        })
+    }
+}
+
+#[cfg(test)]
+mod owner_parsing {
+    use super::OwnerFilter;
+
+    macro_rules! owner_tests {
+        ($($name:ident: $value:expr => $result:pat,)*) => {
+            $(
+                #[test]
+                fn $name() {
+                    let o = OwnerFilter::from_string($value);
+                    match o {
+                        $result => {},
+                        _ => panic!("{:?} does not match {}", o, stringify!($result)),
+                    }
+                }
+            )*
+        };
+    }
+
+    use super::Check::*;
+    owner_tests! {
+        empty:      ""      => Ok(None),
+        uid_only:   "5"     => Ok(Some(OwnerFilter { uid: Equal(5), gid: Ignore     })),
+        uid_gid:    "9:3"   => Ok(Some(OwnerFilter { uid: Equal(9), gid: Equal(3)   })),
+        gid_only:   ":8"    => Ok(Some(OwnerFilter { uid: Ignore,   gid: Equal(8)   })),
+        colon_only: ":"     => Ok(None),
+        trailing:   "5:"    => Ok(Some(OwnerFilter { uid: Equal(5), gid: Ignore     })),
+
+        uid_negate: "!5"    => Ok(Some(OwnerFilter { uid: NotEq(5), gid: Ignore     })),
+        both_negate:"!4:!3" => Ok(Some(OwnerFilter { uid: NotEq(4), gid: NotEq(3)   })),
+        uid_not_gid:"6:!8"  => Ok(Some(OwnerFilter { uid: Equal(6), gid: NotEq(8)   })),
+
+        more_colons:"3:5:"  => Err(_),
+        only_colons:"::"    => Err(_),
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 377a69835..96c887ff2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -25,6 +25,8 @@ use regex::bytes::{RegexBuilder, RegexSetBuilder};
 use crate::exec::CommandTemplate;
 use crate::exit_codes::ExitCode;
 use crate::filetypes::FileTypes;
+#[cfg(unix)]
+use crate::filter::OwnerFilter;
 use crate::filter::{SizeFilter, TimeFilter};
 use crate::options::Options;
 use crate::regex_helper::pattern_has_uppercase_char;
@@ -275,6 +277,13 @@ fn run() -> Result<ExitCode> {
         }
     }
 
+    #[cfg(unix)]
+    let owner_constraint = if let Some(s) = matches.value_of("owner") {
+        OwnerFilter::from_string(s)?
+    } else {
+        None
+    };
+
     let config = Options {
         case_sensitive,
         search_full_path: matches.is_present("full-path"),
@@ -358,6 +367,8 @@ fn run() -> Result<ExitCode> {
             .unwrap_or_else(|| vec![]),
         size_constraints: size_limits,
         time_constraints,
+        #[cfg(unix)]
+        owner_constraint,
         show_filesystem_errors: matches.is_present("show-errors"),
         path_separator,
         max_results: matches
diff --git a/src/options.rs b/src/options.rs
index 3f52516a6..19906db3b 100644
--- a/src/options.rs
+++ b/src/options.rs
@@ -5,6 +5,8 @@ use regex::bytes::RegexSet;
 
 use crate::exec::CommandTemplate;
 use crate::filetypes::FileTypes;
+#[cfg(unix)]
+use crate::filter::OwnerFilter;
 use crate::filter::{SizeFilter, TimeFilter};
 
 /// Configuration options for *fd*.
@@ -82,6 +84,10 @@ pub struct Options {
     /// Constraints on last modification time of files
     pub time_constraints: Vec<TimeFilter>,
 
+    #[cfg(unix)]
+    /// User/group ownership constraint
+    pub owner_constraint: Option<OwnerFilter>,
+
     /// Whether or not to display filesystem errors
     pub show_filesystem_errors: bool,
 
diff --git a/src/walk.rs b/src/walk.rs
index 93f20dec0..56c0bc05a 100644
--- a/src/walk.rs
+++ b/src/walk.rs
@@ -411,6 +411,19 @@ fn spawn_senders(
                 }
             }
 
+            #[cfg(unix)]
+            {
+                if let Some(ref owner_constraint) = config.owner_constraint {
+                    if let Ok(ref metadata) = entry_path.metadata() {
+                        if !owner_constraint.matches(&metadata) {
+                            return ignore::WalkState::Continue;
+                        }
+                    } else {
+                        return ignore::WalkState::Continue;
+                    }
+                }
+            }
+
             // Filter out unwanted sizes if it is a file and we have been given size constraints.
             if !config.size_constraints.is_empty() {
                 if entry_path.is_file() {


Test patch: