1use std::collections::BTreeSet;
9use std::ffi::{OsStr, OsString};
10use std::path::{Component, Path};
11
12use crate::{Attrs, EntryKind};
13
14const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
16const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
18
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct HiddenPolicy {
25 prune: bool,
26 allow: BTreeSet<OsString>,
27 fingerprint: u64,
28}
29
30impl HiddenPolicy {
31 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 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 pub fn admits(&self, name: &OsStr) -> bool {
51 !self.prune || !is_hidden(name) || self.allow.contains(name)
52 }
53
54 pub const fn is_pruning(&self) -> bool {
56 self.prune
57 }
58
59 pub fn allowed(&self) -> impl ExactSizeIterator<Item = &OsStr> {
61 self.allow.iter().map(OsString::as_os_str)
62 }
63
64 pub const fn fingerprint(&self) -> u64 {
66 self.fingerprint
67 }
68}
69
70#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72pub(crate) enum Disposition {
73 Retain,
75 ControlOnly,
78 Reject,
80}
81
82pub(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
107pub(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
130pub(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 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}