Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Features

- Add support for filtering orphaned files with `--owner "-"` (equivalent to find's `-nouser`/`-nogroup`)


## Bugfixes

Expand Down
5 changes: 4 additions & 1 deletion doc/fd.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 68 additions & 29 deletions src/filter/owner.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
use anyhow::{Result, anyhow};
use nix::unistd::{Group, User};
use std::collections::HashSet;
use std::fs;
use std::sync::{LazyLock, Mutex};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
static VALID_UIDS: LazyLock<Mutex<HashSet<u32>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not be performant. I'd use a thread-local instead. You should also use something like a HashMap<u32, bool> so that you can cache both negative and positive results.

static VALID_GIDS: LazyLock<Mutex<HashSet<u32>>> = LazyLock::new(|| Mutex::new(HashSet::new()));

fn is_valid_uid(uid: u32) -> bool {
let mut cache = VALID_UIDS.lock().unwrap();
if cache.contains(&uid) {
return true;
}
let valid = matches!(User::from_uid(uid.into()), Ok(Some(_)));
if valid {
cache.insert(uid);
}
valid
}

fn is_valid_gid(gid: u32) -> bool {
let mut cache = VALID_GIDS.lock().unwrap();
if cache.contains(&gid) {
return true;
}
let valid = matches!(Group::from_gid(gid.into()), Ok(Some(_)));
if valid {
cache.insert(gid);
}
valid
}

#[derive(Clone, Copy, Debug)]
pub struct OwnerFilter {
uid: Check<u32>,
gid: Check<u32>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug)]
enum Check<T> {
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 ":")
Expand All @@ -35,31 +60,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<Self> {
if self == Self::IGNORE {
if matches!(self.uid, Check::Ignore) && matches!(self.gid, Check::Ignore) {
None
} else {
Some(self)
Expand All @@ -79,15 +112,17 @@ impl<T: PartialEq> Check<T> {
Check::Equal(x) => v == *x,
Check::NotEq(x) => v != *x,
Check::Ignore => true,
Check::Orphan(validator) => !validator(v),
}
}

fn parse<F>(s: Option<&str>, f: F) -> Result<Self>
fn parse<F>(s: Option<&str>, f: F, validator: fn(T) -> bool) -> Result<Self>
where
F: Fn(&str) -> Result<T>,
{
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),
};
Expand Down Expand Up @@ -123,17 +158,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(_),
}
Expand Down