Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
93 changes: 63 additions & 30 deletions src/filter/owner.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<u32, bool>> = RefCell::new(HashMap::new());
static GID_CACHE: RefCell<HashMap<u32, bool>> = 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<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 +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<Self> {
if self == Self::IGNORE {
if matches!(self.uid, Check::Ignore) && matches!(self.gid, Check::Ignore) {
None
} else {
Some(self)
Expand All @@ -79,15 +106,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 +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(_),
}
Expand Down