use std::collections::BTreeSet;
use std::ffi::{OsStr, OsString};
use std::path::{Component, Path};
use crate::{Attrs, EntryKind};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HiddenPolicy {
prune: bool,
allow: BTreeSet<OsString>,
fingerprint: u64,
}
impl HiddenPolicy {
pub fn keep_all() -> &'static Self {
static KEEP_ALL: std::sync::LazyLock<HiddenPolicy> =
std::sync::LazyLock::new(HiddenPolicy::default);
&KEEP_ALL
}
pub fn prune_hidden<I, S>(allow: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let allow = allow.into_iter().map(Into::into).collect();
let fingerprint = hidden_fingerprint(&allow);
Self { prune: true, allow, fingerprint }
}
pub fn admits(&self, name: &OsStr) -> bool {
!self.prune || !is_hidden(name) || self.allow.contains(name)
}
pub const fn is_pruning(&self) -> bool {
self.prune
}
pub fn allowed(&self) -> impl ExactSizeIterator<Item = &OsStr> {
self.allow.iter().map(OsString::as_os_str)
}
pub const fn fingerprint(&self) -> u64 {
self.fingerprint
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Disposition {
Retain,
ControlOnly,
Reject,
}
pub(crate) fn decide(
name: &OsStr,
kind: EntryKind,
hidden: &HiddenPolicy,
exclude_special: bool,
) -> Disposition {
if !hidden.admits(name) {
return if crate::control::control_spelling(name).is_some() {
Disposition::ControlOnly
} else {
Disposition::Reject
};
}
if exclude_special && kind == EntryKind::Other {
Disposition::Reject
} else {
Disposition::Retain
}
}
pub(crate) fn decide_path(
path: &Path,
kind: EntryKind,
hidden: &HiddenPolicy,
exclude_special: bool,
) -> Disposition {
let mut components = path.components().peekable();
while let Some(component) = components.next() {
let Component::Normal(name) = component else {
return Disposition::Reject;
};
if components.peek().is_some() {
if !hidden.admits(name) {
return Disposition::Reject;
}
} else {
return decide(name, kind, hidden, exclude_special);
}
}
Disposition::Retain
}
pub(crate) fn should_descend(
kind: EntryKind,
attrs: Attrs,
parent_depth: usize,
root_dev: u64,
max_depth: Option<usize>,
one_filesystem: bool,
) -> bool {
let child_depth = parent_depth.saturating_add(1);
let within_depth = max_depth.is_none_or(|maximum| child_depth < maximum);
let within_filesystem =
!one_filesystem || root_dev == 0 || attrs.dev == 0 || attrs.dev == root_dev;
kind.is_dir() && within_depth && within_filesystem
}
fn is_hidden(name: &OsStr) -> bool {
let bytes = name.as_encoded_bytes();
bytes.first() == Some(&b'.') && bytes != b"." && bytes != b".."
}
fn hidden_fingerprint(allow: &BTreeSet<OsString>) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
let mut mix = |bytes: &[u8]| {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
};
mix(b"hidden:prune\x1f");
for name in allow {
mix(name.as_encoded_bytes());
mix(b"\x1f");
}
if hash == 0 { 1 } else { hash }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unavailable_device_never_proves_a_filesystem_boundary() {
let dir_on = |dev| Attrs { dev, ..Attrs::default() };
let descends =
|attrs, root_dev| should_descend(EntryKind::Dir, attrs, 0, root_dev, None, true);
assert!(descends(dir_on(7), 7), "same device");
assert!(!descends(dir_on(8), 7), "another device is a boundary");
assert!(descends(dir_on(0), 7), "a locked child has no device");
assert!(descends(dir_on(8), 0), "a root that could only be listed has no device");
assert!(should_descend(EntryKind::Dir, dir_on(8), 0, 7, None, false), "not bounded");
}
#[test]
fn hidden_allowlists_are_exact_normalized_scope() {
let first = HiddenPolicy::prune_hidden([".github", ".cargo"]);
let reordered = HiddenPolicy::prune_hidden([".cargo", ".github"]);
let narrower = HiddenPolicy::prune_hidden([".github"]);
assert_eq!(first, reordered);
assert_eq!(first.fingerprint(), reordered.fingerprint());
assert_ne!(first.fingerprint(), narrower.fingerprint());
assert!(first.admits(OsStr::new(".github")));
assert!(!first.admits(OsStr::new(".git")));
assert!(first.admits(OsStr::new("src")));
}
#[test]
fn hidden_controls_remain_signals_but_hidden_ancestors_do_not() {
let hidden = HiddenPolicy::prune_hidden::<[&str; 0], &str>([]);
assert_eq!(
decide_path(Path::new(".gitignore"), EntryKind::File, &hidden, true),
Disposition::ControlOnly
);
assert_eq!(
decide_path(Path::new(".git/.gitignore"), EntryKind::File, &hidden, true),
Disposition::Reject
);
assert_eq!(
decide_path(Path::new("socket"), EntryKind::Other, &hidden, true),
Disposition::Reject
);
}
#[cfg(unix)]
#[test]
fn unix_non_utf8_hidden_names_keep_exact_identity() {
use std::os::unix::ffi::OsStringExt;
let allowed = OsString::from_vec(vec![b'.', 0x80]);
let other = OsString::from_vec(vec![b'.', 0x81]);
let policy = HiddenPolicy::prune_hidden([allowed.clone()]);
assert!(policy.admits(&allowed));
assert!(!policy.admits(&other));
assert_ne!(policy.fingerprint(), HiddenPolicy::prune_hidden([other]).fingerprint());
}
#[cfg(windows)]
#[test]
fn windows_surrogates_and_separators_do_not_bypass_hidden_scope() {
use std::os::windows::ffi::OsStringExt;
let allowed = OsString::from_wide(&[u16::from(b'.'), 0xd800]);
let other = OsString::from_wide(&[u16::from(b'.'), 0xd801]);
let policy = HiddenPolicy::prune_hidden([allowed.clone()]);
assert!(policy.admits(&allowed));
assert!(!policy.admits(&other));
assert_ne!(policy.fingerprint(), HiddenPolicy::prune_hidden([other]).fingerprint());
assert_eq!(
decide_path(Path::new(r".hidden\child"), EntryKind::File, &policy, false),
Disposition::Reject
);
}
}