heddle_object_model/object/
reserved_name.rs1use std::fmt;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub enum MetadataName {
39 Git,
41 Heddle,
43 GitModules,
45}
46
47impl MetadataName {
48 pub fn as_str(self) -> &'static str {
50 match self {
51 MetadataName::Git => ".git",
52 MetadataName::Heddle => ".heddle",
53 MetadataName::GitModules => ".gitmodules",
54 }
55 }
56
57 fn describe(self) -> &'static str {
58 match self {
59 MetadataName::Git => ".git metadata directory",
60 MetadataName::Heddle => ".heddle metadata directory",
61 MetadataName::GitModules => ".gitmodules file, which must not be a symlink",
62 }
63 }
64
65 fn stem(self) -> &'static [u8] {
67 match self {
68 MetadataName::Git => b"git",
69 MetadataName::Heddle => b"heddle",
70 MetadataName::GitModules => b"gitmodules",
71 }
72 }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
77pub enum MetadataAlias {
78 Exact,
80 Case,
82 TrailingDotsOrSpaces,
84 BackslashSeparator,
86 NtfsStream,
88 NtfsShortName,
90 HfsIgnorable,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
96pub struct ReservedMetadataName {
97 pub name: MetadataName,
98 pub alias: MetadataAlias,
99}
100
101impl fmt::Display for ReservedMetadataName {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 let target = self.name.describe();
104 match self.alias {
105 MetadataAlias::Exact => write!(f, "is the {target}"),
106 MetadataAlias::Case => {
107 write!(f, "names the {target} on case-insensitive filesystems")
108 }
109 MetadataAlias::TrailingDotsOrSpaces => write!(
110 f,
111 "names the {target} on Windows, which strips trailing dots and spaces"
112 ),
113 MetadataAlias::BackslashSeparator => write!(
114 f,
115 "is a path into the {target} on Windows, where '\\' separates paths"
116 ),
117 MetadataAlias::NtfsStream => {
118 write!(f, "names an NTFS alternate data stream of the {target}")
119 }
120 MetadataAlias::NtfsShortName => {
121 write!(f, "is an NTFS 8.3 short name of the {target}")
122 }
123 MetadataAlias::HfsIgnorable => write!(
124 f,
125 "names the {target} on HFS+, which ignores some Unicode code points"
126 ),
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ReservedPathComponent {
134 pub index: usize,
137 pub component: String,
139 pub reason: ReservedMetadataName,
140}
141
142impl ReservedPathComponent {
143 pub fn is_own_metadata(&self) -> bool {
147 self.index == 0
148 && self.reason.alias == MetadataAlias::Exact
149 && matches!(self.reason.name, MetadataName::Git | MetadataName::Heddle)
150 }
151}
152
153impl fmt::Display for ReservedPathComponent {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(f, "'{}' {}", self.component, self.reason)
156 }
157}
158
159pub fn reserved_metadata_name(name: &[u8]) -> Option<ReservedMetadataName> {
168 [MetadataName::Git, MetadataName::Heddle]
169 .into_iter()
170 .find_map(|target| reserved_as(name, target))
171}
172
173pub fn is_reserved_metadata_name(name: impl AsRef<[u8]>) -> bool {
175 reserved_metadata_name(name.as_ref()).is_some()
176}
177
178pub fn reserved_tree_entry_name(
185 name: &[u8],
186 at_root: bool,
187 symlink: bool,
188) -> Option<ReservedMetadataName> {
189 reserved_as(name, MetadataName::Git)
190 .or_else(|| {
191 at_root
192 .then(|| reserved_as(name, MetadataName::Heddle))
193 .flatten()
194 })
195 .or_else(|| {
196 symlink
197 .then(|| reserved_as(name, MetadataName::GitModules))
198 .flatten()
199 })
200}
201
202pub fn reserved_path_component(path: &[u8], leaf_symlink: bool) -> Option<ReservedPathComponent> {
209 let mut components = path
210 .split(|&b| b == b'/' || b == b'\\')
211 .filter(|component| !component.is_empty() && *component != b".")
212 .enumerate()
213 .peekable();
214 while let Some((index, component)) = components.next() {
215 let symlink = leaf_symlink && components.peek().is_none();
216 if let Some(reason) = reserved_tree_entry_name(component, index == 0, symlink) {
217 return Some(ReservedPathComponent {
218 index,
219 component: String::from_utf8_lossy(component).into_owned(),
220 reason,
221 });
222 }
223 }
224 None
225}
226
227fn reserved_as(name: &[u8], target: MetadataName) -> Option<ReservedMetadataName> {
228 ntfs_alias(name, target)
229 .or_else(|| hfs_alias(name, target))
230 .map(|alias| ReservedMetadataName {
231 name: target,
232 alias,
233 })
234}
235
236fn ntfs_alias(name: &[u8], target: MetadataName) -> Option<MetadataAlias> {
241 let stem = target.stem();
242 let (rest, short_name) = match name
243 .strip_prefix(b".")
244 .and_then(|rest| strip_prefix_ignore_ascii_case(rest, stem))
245 {
246 Some(rest) => (rest, false),
247 None => (short_name_rest(name, target)?, true),
248 };
249 let trimmed_len = rest.iter().take_while(|&&b| b == b'.' || b == b' ').count();
250 let trailing = &rest[..trimmed_len];
251 let alias = match rest.get(trimmed_len).copied() {
252 None | Some(b'/' | b'\\' | b':') if short_name => MetadataAlias::NtfsShortName,
253 None | Some(b'/') if !trailing.is_empty() => MetadataAlias::TrailingDotsOrSpaces,
254 None | Some(b'/') if name[1..=stem.len()] == *stem => MetadataAlias::Exact,
256 None | Some(b'/') => MetadataAlias::Case,
257 Some(b':') => MetadataAlias::NtfsStream,
258 Some(b'\\') => MetadataAlias::BackslashSeparator,
259 Some(_) => return None,
260 };
261 Some(alias)
262}
263
264fn short_name_rest(name: &[u8], target: MetadataName) -> Option<&[u8]> {
266 match target {
267 MetadataName::Git => strip_prefix_ignore_ascii_case(name, b"git~1"),
269 MetadataName::Heddle | MetadataName::GitModules => {
271 numbered_short_name_rest(name, &target.stem()[..6]).or_else(|| {
272 (target == MetadataName::GitModules)
273 .then(|| hashed_short_name_rest(name, b"gi7eba"))
274 .flatten()
275 })
276 }
277 }
278}
279
280fn numbered_short_name_rest<'a>(name: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
283 let rest = strip_prefix_ignore_ascii_case(name, prefix)?.strip_prefix(b"~")?;
284 match rest.first() {
285 Some(b'1'..=b'4') => Some(&rest[1..]),
286 _ => None,
287 }
288}
289
290fn hashed_short_name_rest<'a>(name: &'a [u8], prefix: &[u8; 6]) -> Option<&'a [u8]> {
294 let mut saw_tilde = false;
295 let mut index = 0;
296 while index < 8 {
297 let byte = *name.get(index)?;
298 if saw_tilde {
299 if !byte.is_ascii_digit() {
300 return None;
301 }
302 } else if byte == b'~' {
303 index += 1;
304 if !matches!(name.get(index), Some(b'1'..=b'9')) {
305 return None;
306 }
307 saw_tilde = true;
308 } else if index >= 6 || !byte.is_ascii() || byte.to_ascii_lowercase() != prefix[index] {
309 return None;
310 }
311 index += 1;
312 }
313 Some(&name[8..])
314}
315
316fn strip_prefix_ignore_ascii_case<'a>(name: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
317 (name.len() >= prefix.len() && name[..prefix.len()].eq_ignore_ascii_case(prefix))
318 .then(|| &name[prefix.len()..])
319}
320
321fn hfs_alias(name: &[u8], target: MetadataName) -> Option<MetadataAlias> {
325 let valid = match std::str::from_utf8(name) {
326 Ok(valid) => valid,
327 Err(error) => std::str::from_utf8(&name[..error.valid_up_to()]).ok()?,
328 };
329 let mut chars = valid
330 .chars()
331 .take_while(|&c| c != '/')
332 .filter(|&c| !is_hfs_ignorable(c));
333 if chars.next() != Some('.') {
334 return None;
335 }
336 for &expected in target.stem() {
337 let c = chars.next()?;
338 if !c.is_ascii() || c.to_ascii_lowercase() as u32 != u32::from(expected) {
339 return None;
340 }
341 }
342 chars
343 .next()
344 .is_none()
345 .then_some(MetadataAlias::HfsIgnorable)
346}
347
348fn is_hfs_ignorable(c: char) -> bool {
351 matches!(
352 c,
353 '\u{200c}'
354 | '\u{200d}'
355 | '\u{200e}'
356 | '\u{200f}'
357 | '\u{202a}'
358 ..='\u{202e}'
359 | '\u{206a}'
360 ..='\u{206f}'
361 | '\u{feff}'
362 )
363}
364
365#[cfg(test)]
366#[path = "reserved_name_tests.rs"]
367mod tests;