Skip to main content

fdu_core/
admission.rs

1//! Fixed rules that decide which filesystem facts belong in an index.
2//!
3//! Admission is scope, not query selection. A rejected row and its descendants are not
4//! retained, while a control-only `.gitignore` remains an exact engine input without
5//! becoming a visible row. Every filesystem producer calls these predicates before it
6//! constructs an observation.
7
8use std::collections::BTreeSet;
9use std::ffi::{OsStr, OsString};
10use std::path::{Component, Path};
11
12use crate::{Attrs, EntryKind};
13
14/// FNV-1a offset used by the stable hidden-policy identity.
15const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
16/// FNV-1a prime used by the stable hidden-policy identity.
17const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
18
19/// Whether leading-dot path components are retained.
20///
21/// The allowlist contains exact component names. Exact names avoid adding a second glob
22/// language beside query globs and `.gitignore` rules.
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct HiddenPolicy {
25    prune: bool,
26    allow: BTreeSet<OsString>,
27    fingerprint: u64,
28}
29
30impl HiddenPolicy {
31    /// Admit every component.
32    pub fn keep_all() -> &'static Self {
33        static KEEP_ALL: std::sync::LazyLock<HiddenPolicy> =
34            std::sync::LazyLock::new(HiddenPolicy::default);
35        &KEEP_ALL
36    }
37
38    /// Prune hidden components except for the exact names in `allow`.
39    pub fn prune_hidden<I, S>(allow: I) -> Self
40    where
41        I: IntoIterator<Item = S>,
42        S: Into<OsString>,
43    {
44        let allow = allow.into_iter().map(Into::into).collect();
45        let fingerprint = hidden_fingerprint(&allow);
46        Self { prune: true, allow, fingerprint }
47    }
48
49    /// Whether one path component is inside this scope.
50    pub fn admits(&self, name: &OsStr) -> bool {
51        !self.prune || !is_hidden(name) || self.allow.contains(name)
52    }
53
54    /// Whether the policy removes any hidden components.
55    pub const fn is_pruning(&self) -> bool {
56        self.prune
57    }
58
59    /// Exact allowlisted component names in deterministic order.
60    pub fn allowed(&self) -> impl ExactSizeIterator<Item = &OsStr> {
61        self.allow.iter().map(OsString::as_os_str)
62    }
63
64    /// Stable identity derived from the normalized policy.
65    pub const fn fingerprint(&self) -> u64 {
66        self.fingerprint
67    }
68}
69
70/// What a producer should retain for one observed filesystem entry.
71#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72pub(crate) enum Disposition {
73    /// Retain the ordinary row and any control signal it carries.
74    Retain,
75    /// Retain only the `.gitignore` signal: the entry spells the control name, in any
76    /// case, and the directory's control is read as a visible one would be.
77    ControlOnly,
78    /// Retain neither the row nor a control signal.
79    Reject,
80}
81
82/// Decide one direct child after its native kind has been observed.
83///
84/// A hidden-pruned `.GITIGNORE` stays a control signal like `.gitignore`: on a
85/// case-insensitive volume it is the directory's control file, and whether it is one is
86/// the control read's question, not admission's (`crate::control::ControlSpelling`).
87pub(crate) fn decide(
88    name: &OsStr,
89    kind: EntryKind,
90    hidden: &HiddenPolicy,
91    exclude_special: bool,
92) -> Disposition {
93    if !hidden.admits(name) {
94        return if crate::control::control_spelling(name).is_some() {
95            Disposition::ControlOnly
96        } else {
97            Disposition::Reject
98        };
99    }
100    if exclude_special && kind == EntryKind::Other {
101        Disposition::Reject
102    } else {
103        Disposition::Retain
104    }
105}
106
107/// Decide a complete relative path, rejecting a pruned ancestor before its final entry.
108pub(crate) fn decide_path(
109    path: &Path,
110    kind: EntryKind,
111    hidden: &HiddenPolicy,
112    exclude_special: bool,
113) -> Disposition {
114    let mut components = path.components().peekable();
115    while let Some(component) = components.next() {
116        let Component::Normal(name) = component else {
117            return Disposition::Reject;
118        };
119        if components.peek().is_some() {
120            if !hidden.admits(name) {
121                return Disposition::Reject;
122            }
123        } else {
124            return decide(name, kind, hidden, exclude_special);
125        }
126    }
127    Disposition::Retain
128}
129
130/// Whether an admitted row may be traversed as a directory.
131pub(crate) fn should_descend(
132    kind: EntryKind,
133    attrs: Attrs,
134    parent_depth: usize,
135    root_dev: u64,
136    max_depth: Option<usize>,
137    one_filesystem: bool,
138) -> bool {
139    let child_depth = parent_depth.saturating_add(1);
140    let within_depth = max_depth.is_none_or(|maximum| child_depth < maximum);
141    // Zero is an unavailable device on either side (a locked entry, or a root that could
142    // only be listed), so it cannot prove a boundary crossing.
143    let within_filesystem =
144        !one_filesystem || root_dev == 0 || attrs.dev == 0 || attrs.dev == root_dev;
145    kind.is_dir() && within_depth && within_filesystem
146}
147
148fn is_hidden(name: &OsStr) -> bool {
149    let bytes = name.as_encoded_bytes();
150    bytes.first() == Some(&b'.') && bytes != b"." && bytes != b".."
151}
152
153fn hidden_fingerprint(allow: &BTreeSet<OsString>) -> u64 {
154    let mut hash = FNV_OFFSET_BASIS;
155    let mut mix = |bytes: &[u8]| {
156        for byte in bytes {
157            hash ^= u64::from(*byte);
158            hash = hash.wrapping_mul(FNV_PRIME);
159        }
160    };
161    mix(b"hidden:prune\x1f");
162    for name in allow {
163        mix(name.as_encoded_bytes());
164        mix(b"\x1f");
165    }
166    if hash == 0 { 1 } else { hash }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn an_unavailable_device_never_proves_a_filesystem_boundary() {
175        let dir_on = |dev| Attrs { dev, ..Attrs::default() };
176        let descends =
177            |attrs, root_dev| should_descend(EntryKind::Dir, attrs, 0, root_dev, None, true);
178        assert!(descends(dir_on(7), 7), "same device");
179        assert!(!descends(dir_on(8), 7), "another device is a boundary");
180        assert!(descends(dir_on(0), 7), "a locked child has no device");
181        assert!(descends(dir_on(8), 0), "a root that could only be listed has no device");
182        assert!(should_descend(EntryKind::Dir, dir_on(8), 0, 7, None, false), "not bounded");
183    }
184
185    #[test]
186    fn hidden_allowlists_are_exact_normalized_scope() {
187        let first = HiddenPolicy::prune_hidden([".github", ".cargo"]);
188        let reordered = HiddenPolicy::prune_hidden([".cargo", ".github"]);
189        let narrower = HiddenPolicy::prune_hidden([".github"]);
190
191        assert_eq!(first, reordered);
192        assert_eq!(first.fingerprint(), reordered.fingerprint());
193        assert_ne!(first.fingerprint(), narrower.fingerprint());
194        assert!(first.admits(OsStr::new(".github")));
195        assert!(!first.admits(OsStr::new(".git")));
196        assert!(first.admits(OsStr::new("src")));
197    }
198
199    #[test]
200    fn hidden_controls_remain_signals_but_hidden_ancestors_do_not() {
201        let hidden = HiddenPolicy::prune_hidden::<[&str; 0], &str>([]);
202
203        assert_eq!(
204            decide_path(Path::new(".gitignore"), EntryKind::File, &hidden, true),
205            Disposition::ControlOnly
206        );
207        assert_eq!(
208            decide_path(Path::new(".git/.gitignore"), EntryKind::File, &hidden, true),
209            Disposition::Reject
210        );
211        assert_eq!(
212            decide_path(Path::new("socket"), EntryKind::Other, &hidden, true),
213            Disposition::Reject
214        );
215    }
216
217    #[cfg(unix)]
218    #[test]
219    fn unix_non_utf8_hidden_names_keep_exact_identity() {
220        use std::os::unix::ffi::OsStringExt;
221
222        let allowed = OsString::from_vec(vec![b'.', 0x80]);
223        let other = OsString::from_vec(vec![b'.', 0x81]);
224        let policy = HiddenPolicy::prune_hidden([allowed.clone()]);
225
226        assert!(policy.admits(&allowed));
227        assert!(!policy.admits(&other));
228        assert_ne!(policy.fingerprint(), HiddenPolicy::prune_hidden([other]).fingerprint());
229    }
230
231    #[cfg(windows)]
232    #[test]
233    fn windows_surrogates_and_separators_do_not_bypass_hidden_scope() {
234        use std::os::windows::ffi::OsStringExt;
235
236        let allowed = OsString::from_wide(&[u16::from(b'.'), 0xd800]);
237        let other = OsString::from_wide(&[u16::from(b'.'), 0xd801]);
238        let policy = HiddenPolicy::prune_hidden([allowed.clone()]);
239
240        assert!(policy.admits(&allowed));
241        assert!(!policy.admits(&other));
242        assert_ne!(policy.fingerprint(), HiddenPolicy::prune_hidden([other]).fingerprint());
243        assert_eq!(
244            decide_path(Path::new(r".hidden\child"), EntryKind::File, &policy, false),
245            Disposition::Reject
246        );
247    }
248}