use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MetadataName {
Git,
Heddle,
GitModules,
}
impl MetadataName {
pub fn as_str(self) -> &'static str {
match self {
MetadataName::Git => ".git",
MetadataName::Heddle => ".heddle",
MetadataName::GitModules => ".gitmodules",
}
}
fn describe(self) -> &'static str {
match self {
MetadataName::Git => ".git metadata directory",
MetadataName::Heddle => ".heddle metadata directory",
MetadataName::GitModules => ".gitmodules file, which must not be a symlink",
}
}
fn stem(self) -> &'static [u8] {
match self {
MetadataName::Git => b"git",
MetadataName::Heddle => b"heddle",
MetadataName::GitModules => b"gitmodules",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MetadataAlias {
Exact,
Case,
TrailingDotsOrSpaces,
BackslashSeparator,
NtfsStream,
NtfsShortName,
HfsIgnorable,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ReservedMetadataName {
pub name: MetadataName,
pub alias: MetadataAlias,
}
impl fmt::Display for ReservedMetadataName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let target = self.name.describe();
match self.alias {
MetadataAlias::Exact => write!(f, "is the {target}"),
MetadataAlias::Case => {
write!(f, "names the {target} on case-insensitive filesystems")
}
MetadataAlias::TrailingDotsOrSpaces => write!(
f,
"names the {target} on Windows, which strips trailing dots and spaces"
),
MetadataAlias::BackslashSeparator => write!(
f,
"is a path into the {target} on Windows, where '\\' separates paths"
),
MetadataAlias::NtfsStream => {
write!(f, "names an NTFS alternate data stream of the {target}")
}
MetadataAlias::NtfsShortName => {
write!(f, "is an NTFS 8.3 short name of the {target}")
}
MetadataAlias::HfsIgnorable => write!(
f,
"names the {target} on HFS+, which ignores some Unicode code points"
),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReservedPathComponent {
pub index: usize,
pub component: String,
pub reason: ReservedMetadataName,
}
impl ReservedPathComponent {
pub fn is_own_metadata(&self) -> bool {
self.index == 0
&& self.reason.alias == MetadataAlias::Exact
&& matches!(self.reason.name, MetadataName::Git | MetadataName::Heddle)
}
}
impl fmt::Display for ReservedPathComponent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "'{}' {}", self.component, self.reason)
}
}
pub fn reserved_metadata_name(name: &[u8]) -> Option<ReservedMetadataName> {
[MetadataName::Git, MetadataName::Heddle]
.into_iter()
.find_map(|target| reserved_as(name, target))
}
pub fn is_reserved_metadata_name(name: impl AsRef<[u8]>) -> bool {
reserved_metadata_name(name.as_ref()).is_some()
}
pub fn reserved_tree_entry_name(
name: &[u8],
at_root: bool,
symlink: bool,
) -> Option<ReservedMetadataName> {
reserved_as(name, MetadataName::Git)
.or_else(|| {
at_root
.then(|| reserved_as(name, MetadataName::Heddle))
.flatten()
})
.or_else(|| {
symlink
.then(|| reserved_as(name, MetadataName::GitModules))
.flatten()
})
}
pub fn reserved_path_component(path: &[u8], leaf_symlink: bool) -> Option<ReservedPathComponent> {
let mut components = path
.split(|&b| b == b'/' || b == b'\\')
.filter(|component| !component.is_empty() && *component != b".")
.enumerate()
.peekable();
while let Some((index, component)) = components.next() {
let symlink = leaf_symlink && components.peek().is_none();
if let Some(reason) = reserved_tree_entry_name(component, index == 0, symlink) {
return Some(ReservedPathComponent {
index,
component: String::from_utf8_lossy(component).into_owned(),
reason,
});
}
}
None
}
fn reserved_as(name: &[u8], target: MetadataName) -> Option<ReservedMetadataName> {
ntfs_alias(name, target)
.or_else(|| hfs_alias(name, target))
.map(|alias| ReservedMetadataName {
name: target,
alias,
})
}
fn ntfs_alias(name: &[u8], target: MetadataName) -> Option<MetadataAlias> {
let stem = target.stem();
let (rest, short_name) = match name
.strip_prefix(b".")
.and_then(|rest| strip_prefix_ignore_ascii_case(rest, stem))
{
Some(rest) => (rest, false),
None => (short_name_rest(name, target)?, true),
};
let trimmed_len = rest.iter().take_while(|&&b| b == b'.' || b == b' ').count();
let trailing = &rest[..trimmed_len];
let alias = match rest.get(trimmed_len).copied() {
None | Some(b'/' | b'\\' | b':') if short_name => MetadataAlias::NtfsShortName,
None | Some(b'/') if !trailing.is_empty() => MetadataAlias::TrailingDotsOrSpaces,
None | Some(b'/') if name[1..=stem.len()] == *stem => MetadataAlias::Exact,
None | Some(b'/') => MetadataAlias::Case,
Some(b':') => MetadataAlias::NtfsStream,
Some(b'\\') => MetadataAlias::BackslashSeparator,
Some(_) => return None,
};
Some(alias)
}
fn short_name_rest(name: &[u8], target: MetadataName) -> Option<&[u8]> {
match target {
MetadataName::Git => strip_prefix_ignore_ascii_case(name, b"git~1"),
MetadataName::Heddle | MetadataName::GitModules => {
numbered_short_name_rest(name, &target.stem()[..6]).or_else(|| {
(target == MetadataName::GitModules)
.then(|| hashed_short_name_rest(name, b"gi7eba"))
.flatten()
})
}
}
}
fn numbered_short_name_rest<'a>(name: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
let rest = strip_prefix_ignore_ascii_case(name, prefix)?.strip_prefix(b"~")?;
match rest.first() {
Some(b'1'..=b'4') => Some(&rest[1..]),
_ => None,
}
}
fn hashed_short_name_rest<'a>(name: &'a [u8], prefix: &[u8; 6]) -> Option<&'a [u8]> {
let mut saw_tilde = false;
let mut index = 0;
while index < 8 {
let byte = *name.get(index)?;
if saw_tilde {
if !byte.is_ascii_digit() {
return None;
}
} else if byte == b'~' {
index += 1;
if !matches!(name.get(index), Some(b'1'..=b'9')) {
return None;
}
saw_tilde = true;
} else if index >= 6 || !byte.is_ascii() || byte.to_ascii_lowercase() != prefix[index] {
return None;
}
index += 1;
}
Some(&name[8..])
}
fn strip_prefix_ignore_ascii_case<'a>(name: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
(name.len() >= prefix.len() && name[..prefix.len()].eq_ignore_ascii_case(prefix))
.then(|| &name[prefix.len()..])
}
fn hfs_alias(name: &[u8], target: MetadataName) -> Option<MetadataAlias> {
let valid = match std::str::from_utf8(name) {
Ok(valid) => valid,
Err(error) => std::str::from_utf8(&name[..error.valid_up_to()]).ok()?,
};
let mut chars = valid
.chars()
.take_while(|&c| c != '/')
.filter(|&c| !is_hfs_ignorable(c));
if chars.next() != Some('.') {
return None;
}
for &expected in target.stem() {
let c = chars.next()?;
if !c.is_ascii() || c.to_ascii_lowercase() as u32 != u32::from(expected) {
return None;
}
}
chars
.next()
.is_none()
.then_some(MetadataAlias::HfsIgnorable)
}
fn is_hfs_ignorable(c: char) -> bool {
matches!(
c,
'\u{200c}'
| '\u{200d}'
| '\u{200e}'
| '\u{200f}'
| '\u{202a}'
..='\u{202e}'
| '\u{206a}'
..='\u{206f}'
| '\u{feff}'
)
}
#[cfg(test)]
#[path = "reserved_name_tests.rs"]
mod tests;