use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use unicode_normalization::UnicodeNormalization as _;
use crate::Repository;
use crate::checkout::Collision;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Protection {
ntfs: bool,
hfs: bool,
}
impl Protection {
pub(crate) fn from_config(repo: &Repository) -> Result<Self, &'static str> {
let config = repo.config();
let ntfs = config
.boolean("core", None, "protectntfs")
.map_err(|_| "core.protectNTFS")?;
let hfs = config
.boolean("core", None, "protecthfs")
.map_err(|_| "core.protectHFS")?;
Ok(Self {
ntfs: ntfs.unwrap_or(true),
hfs: hfs.unwrap_or(cfg!(target_os = "macos")),
})
}
pub(crate) fn refuses(self, path: &[u8]) -> bool {
path.split(|byte| *byte == b'/')
.any(|component| self.refuses_component(component))
}
fn refuses_component(self, component: &[u8]) -> bool {
if component.eq_ignore_ascii_case(b".git") {
return true;
}
if self.ntfs {
let base = component
.split(|byte| *byte == b':')
.next()
.unwrap_or(component);
let end = base
.iter()
.rposition(|byte| !matches!(byte, b'.' | b' '))
.map_or(0, |last| last + 1);
let base = &base[..end];
if base.eq_ignore_ascii_case(b".git") || base.eq_ignore_ascii_case(b"git~1") {
return true;
}
}
self.hfs && without_hfs_ignorables(component).eq_ignore_ascii_case(b".git")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct NamePolicy {
case: bool,
normalization: bool,
}
pub(crate) struct Resolved<V> {
pub(crate) disk: BTreeMap<Vec<u8>, V>,
pub(crate) sources: BTreeMap<Vec<u8>, Vec<u8>>,
pub(crate) collisions: Vec<Collision>,
}
impl NamePolicy {
#[cfg(test)]
pub(crate) const EXACT: Self = Self {
case: false,
normalization: false,
};
pub(crate) fn from_config(repo: &Repository) -> Result<Self, &'static str> {
let config = repo.config();
let case = config
.boolean("core", None, "ignorecase")
.map_err(|_| "core.ignoreCase")?;
Ok(Self {
case: case == Some(true) || probe_case_folding(repo),
normalization: cfg!(target_os = "macos"),
})
}
pub(crate) fn key(self, component: &[u8]) -> Vec<u8> {
if !self.case && !self.normalization {
return component.to_vec();
}
let Ok(text) = std::str::from_utf8(component) else {
return if self.case {
component.to_ascii_lowercase()
} else {
component.to_vec()
};
};
let mut key: String = if self.normalization {
text.nfd().filter(|c| !is_hfs_ignorable(*c)).collect()
} else {
text.to_owned()
};
if self.case {
key = key
.chars()
.flat_map(char::to_uppercase)
.flat_map(char::to_lowercase)
.collect();
if self.normalization {
key = key.nfd().collect();
}
}
key.into_bytes()
}
pub(crate) fn resolve<V: Copy>(
self,
leaves: &BTreeMap<Vec<u8>, V>,
) -> Result<Resolved<V>, Vec<u8>> {
let mut directories: BTreeMap<(Vec<u8>, Vec<u8>), Name<()>> = BTreeMap::new();
let mut files: BTreeMap<(Vec<u8>, Vec<u8>), Name<V>> = BTreeMap::new();
let mut collisions = Vec::new();
for (path, value) in leaves {
let mut disk = Vec::new();
let mut end = 0;
let mut components = path.split(|byte| *byte == b'/').peekable();
while let Some(component) = components.next() {
end += component.len();
let planned = &path[..end];
let slot = (disk.clone(), self.key(component));
let on_disk = join(&disk, component);
if components.peek().is_none() {
if directories.contains_key(&slot) {
return Err(path.clone());
}
match files.entry(slot) {
Entry::Vacant(vacant) => {
vacant.insert(Name::new(planned, on_disk, *value));
}
Entry::Occupied(mut occupied) => {
let name = occupied.get_mut();
let shadowed = std::mem::replace(&mut name.planned, path.clone());
name.disk = on_disk;
name.value = *value;
name.shadow(shadowed, &mut collisions);
}
}
break;
}
if files.contains_key(&slot) {
return Err(planned.to_vec());
}
let name = directories
.entry(slot)
.or_insert_with(|| Name::new(planned, on_disk, ()));
if name.planned != planned {
name.shadow(planned.to_vec(), &mut collisions);
}
disk.clone_from(&name.disk);
end += 1;
}
}
let mut disk = BTreeMap::new();
let mut sources = BTreeMap::new();
for name in files.into_values() {
if name.planned != name.disk {
sources.insert(name.disk.clone(), name.planned);
}
disk.insert(name.disk, name.value);
}
collisions.sort_by(|a: &Collision, b| a.written.cmp(&b.written));
Ok(Resolved {
disk,
sources,
collisions,
})
}
}
struct Name<V> {
planned: Vec<u8>,
disk: Vec<u8>,
value: V,
collision: Option<usize>,
}
impl<V> Name<V> {
fn new(planned: &[u8], disk: Vec<u8>, value: V) -> Self {
Self {
planned: planned.to_vec(),
disk,
value,
collision: None,
}
}
fn shadow(&mut self, shadowed: Vec<u8>, collisions: &mut Vec<Collision>) {
let index = *self.collision.get_or_insert_with(|| {
collisions.push(Collision::default());
collisions.len() - 1
});
let collision = &mut collisions[index];
collision.written.clone_from(&self.planned);
if !collision.shadowed.contains(&shadowed) {
collision.shadowed.push(shadowed);
}
}
}
fn probe_case_folding(repo: &Repository) -> bool {
use std::os::unix::fs::MetadataExt as _;
let Some(root) = repo.worktree() else {
return true;
};
match (
std::fs::symlink_metadata(root.join(".git")),
std::fs::symlink_metadata(root.join(".GIT")),
) {
(Ok(_), Err(error)) if error.kind() == std::io::ErrorKind::NotFound => false,
(Ok(actual), Ok(alias)) => actual.dev() == alias.dev() && actual.ino() == alias.ino(),
_ => true,
}
}
fn join(parent: &[u8], name: &[u8]) -> Vec<u8> {
let mut path = parent.to_vec();
if !path.is_empty() {
path.push(b'/');
}
path.extend_from_slice(name);
path
}
fn is_hfs_ignorable(c: char) -> bool {
matches!(
c,
'\u{200c}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{206a}'..='\u{206f}' | '\u{feff}'
)
}
fn without_hfs_ignorables(name: &[u8]) -> Vec<u8> {
let Ok(text) = std::str::from_utf8(name) else {
return name.to_vec();
};
text.chars()
.filter(|c| !is_hfs_ignorable(*c))
.collect::<String>()
.into_bytes()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use rstest::rstest;
use super::{NamePolicy, Protection};
use crate::checkout::Collision;
const FOLDING: NamePolicy = NamePolicy {
case: true,
normalization: true,
};
const NORMALIZING: NamePolicy = NamePolicy {
case: false,
normalization: true,
};
#[rstest]
#[case::ascii_case(FOLDING, "README", "readme", true)]
#[case::composed(FOLDING, "caf\u{e9}", "cafe\u{301}", true)]
#[case::unicode_case(FOLDING, "\u{c9}t\u{e9}", "\u{e9}t\u{e9}", true)]
#[case::kelvin(FOLDING, "\u{212a}", "k", true)]
#[case::ignorable(FOLDING, ".g\u{200c}it", ".git", true)]
#[case::distinct(FOLDING, "a", "b", false)]
#[case::case_sensitive_apfs(NORMALIZING, "README", "readme", false)]
#[case::case_sensitive_apfs_composed(NORMALIZING, "caf\u{e9}", "cafe\u{301}", true)]
#[case::exact_case(NamePolicy::EXACT, "README", "readme", false)]
#[case::exact_composed(NamePolicy::EXACT, "caf\u{e9}", "cafe\u{301}", false)]
fn keys_merge_what_the_volume_merges(
#[case] policy: NamePolicy,
#[case] a: &str,
#[case] b: &str,
#[case] same: bool,
) {
assert_eq!(policy.key(a.as_bytes()) == policy.key(b.as_bytes()), same);
}
#[test]
fn invalid_utf8_folds_ascii_only_when_case_folds() {
assert_eq!(FOLDING.key(b"A\xff"), FOLDING.key(b"a\xff"));
assert_ne!(NORMALIZING.key(b"A\xff"), NORMALIZING.key(b"a\xff"));
}
fn leaves(paths: &[(&str, u8)]) -> BTreeMap<Vec<u8>, u8> {
paths
.iter()
.map(|(path, value)| (path.as_bytes().to_vec(), *value))
.collect()
}
fn disk(entries: &[(&str, u8)]) -> BTreeMap<Vec<u8>, u8> {
leaves(entries)
}
#[test]
fn resolves_collisions_as_git_does() {
let resolved = FOLDING
.resolve(&leaves(&[
("Dir/f", 1),
("X.h", 2),
("caf\u{e9}", 3),
("cafe\u{301}", 4),
("dir/f", 5),
("dir/g", 6),
("x.h", 7),
]))
.unwrap();
assert_eq!(
resolved.disk,
disk(&[("Dir/f", 5), ("Dir/g", 6), ("caf\u{e9}", 3), ("x.h", 7)])
);
let collision = |written: &str, shadowed: &[&str]| Collision {
written: written.as_bytes().to_vec(),
shadowed: shadowed
.iter()
.map(|path| path.as_bytes().to_vec())
.collect(),
};
assert_eq!(
resolved.collisions,
[
collision("Dir", &["dir"]),
collision("caf\u{e9}", &["cafe\u{301}"]),
collision("dir/f", &["Dir/f"]),
collision("x.h", &["X.h"]),
]
);
}
#[test]
fn exact_policy_keeps_every_path() {
let planned = leaves(&[
("X.h", 1),
("x.h", 2),
("xt_CONNMARK.h", 3),
("xt_connmark.h", 4),
]);
let resolved = NamePolicy::EXACT.resolve(&planned).unwrap();
assert_eq!(resolved.disk, planned);
assert!(resolved.collisions.is_empty());
}
#[rstest]
#[case::file_first(&[("A", 1), ("a/b", 2)], "a")]
#[case::directory_first(&[("A/b", 1), ("a", 2)], "a")]
fn refuses_a_file_and_directory_sharing_a_name(#[case] paths: &[(&str, u8)], #[case] at: &str) {
assert_eq!(
FOLDING.resolve(&leaves(paths)).err().unwrap(),
at.as_bytes()
);
}
const ALL: Protection = Protection {
ntfs: true,
hfs: true,
};
const NONE: Protection = Protection {
ntfs: false,
hfs: false,
};
#[rstest]
#[case::exact(".git", true, true, true)]
#[case::ascii_case("a/.GIT/b", true, true, true)]
#[case::short_name("git~1", true, false, true)]
#[case::short_name_case("GIT~1", true, false, true)]
#[case::trailing_space(".git ", true, false, true)]
#[case::trailing_dots(".git..", true, false, true)]
#[case::stream(".git::$INDEX_ALLOCATION", true, false, true)]
#[case::hfs_ignorable(".g\u{200c}it", true, true, false)]
#[case::bom("\u{feff}.git", true, true, false)]
#[case::colon("c:d", false, false, false)]
#[case::backslash("a\\b", false, false, false)]
#[case::device("aux", false, false, false)]
#[case::gitignore(".gitignore", false, false, false)]
#[case::git_dot_x(".git.x", false, false, false)]
fn matches_git_metadata_rules(
#[case] path: &str,
#[case] all: bool,
#[case] hfs_only: bool,
#[case] ntfs_only: bool,
) {
let path = path.as_bytes();
assert_eq!(ALL.refuses(path), all);
assert_eq!(
Protection {
ntfs: false,
hfs: true
}
.refuses(path),
hfs_only
);
assert_eq!(
Protection {
ntfs: true,
hfs: false
}
.refuses(path),
ntfs_only
);
assert_eq!(NONE.refuses(path), all && hfs_only && ntfs_only);
}
}