use crate::config::ConfigSet;
use crate::dotfile::{
is_hfs_dot_gitattributes, is_hfs_dot_gitignore, is_hfs_dot_gitmodules, is_hfs_dot_mailmap,
is_hfs_dotgit, is_ntfs_dot_gitattributes, is_ntfs_dot_gitignore, is_ntfs_dot_gitmodules,
is_ntfs_dot_mailmap, is_ntfs_dotgit,
};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EntryMode(u32);
impl EntryMode {
#[must_use]
pub const fn new(mode: u32) -> Self {
Self(mode)
}
#[must_use]
pub fn is_symlink(self) -> bool {
self.0 == 0o120000
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum PathValidationKind {
Fsck,
#[default]
Worktree,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProtectOptions {
pub protect_ntfs: bool,
pub protect_hfs: bool,
}
impl Default for ProtectOptions {
fn default() -> Self {
Self {
protect_ntfs: true,
protect_hfs: cfg!(target_os = "macos"),
}
}
}
impl ProtectOptions {
#[must_use]
pub fn from_config(config: &ConfigSet) -> Self {
let protect_ntfs = config
.get_bool("core.protectNTFS")
.or_else(|| config.get_bool("core.protectntfs"))
.and_then(|r| r.ok())
.unwrap_or(true);
let default_hfs = cfg!(target_os = "macos");
let protect_hfs = config
.get_bool("core.protectHFS")
.or_else(|| config.get_bool("core.protecthfs"))
.and_then(|r| r.ok())
.unwrap_or(default_hfs);
Self {
protect_ntfs,
protect_hfs,
}
}
#[must_use]
pub const fn fsck() -> Self {
Self {
protect_ntfs: true,
protect_hfs: true,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum PathComponentError {
#[error("path component is empty")]
Empty,
#[error("path component is a single dot")]
Dot,
#[error("path component is parent directory")]
DotDot,
#[error("path component is a .git alias")]
DotGit,
#[error("path component contains a slash")]
ContainsSlash,
#[error("path component contains a NUL byte")]
ContainsNul,
#[error("path component is an HFS .git alias")]
HfsDotGit,
#[error("path component is an NTFS .git alias")]
NtfsDotGit,
#[error("path component contains a backslash before a .git alias")]
NtfsBackslash,
#[error("path component uses a forbidden NTFS drive or stream prefix")]
NtfsDriveOrStream,
#[error("symbolic link must not be named .gitmodules")]
SymlinkedDotGitmodules,
#[error("symbolic link must not be named .gitattributes")]
SymlinkedDotGitattributes,
#[error("symbolic link must not be named .gitignore")]
SymlinkedDotGitignore,
#[error("symbolic link must not be named .mailmap")]
SymlinkedDotMailmap,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TreeEntryNameAudit {
pub empty: bool,
pub dot: bool,
pub dotdot: bool,
pub slash: bool,
pub nul: bool,
pub dotgit: bool,
}
impl TreeEntryNameAudit {
pub fn absorb(&mut self, other: Self) {
self.empty |= other.empty;
self.dot |= other.dot;
self.dotdot |= other.dotdot;
self.slash |= other.slash;
self.nul |= other.nul;
self.dotgit |= other.dotgit;
}
}
pub fn validate_component(
name: &[u8],
mode: EntryMode,
opts: ProtectOptions,
kind: PathValidationKind,
) -> Result<(), PathComponentError> {
if name.is_empty() {
return Err(PathComponentError::Empty);
}
if name.contains(&0) {
return Err(PathComponentError::ContainsNul);
}
if name.contains(&b'/') {
return Err(PathComponentError::ContainsSlash);
}
if let Some(err) = validate_component_symlink_dotfile(name, mode, opts, kind) {
return Err(err);
}
if opts.protect_ntfs && ntfs_path_syntax_rules_apply() {
if has_dos_drive_prefix_bytes(name) {
return Err(PathComponentError::NtfsDriveOrStream);
}
if let Ok(name_str) = std::str::from_utf8(name) {
if scan_ntfs_backslash_dot_special(name_str, ntfs_backslash_dotdot_tail) {
return Err(PathComponentError::NtfsBackslash);
}
}
}
let audit = audit_tree_entry_name(name, opts);
if audit.empty {
return Err(PathComponentError::Empty);
}
if audit.slash {
return Err(PathComponentError::ContainsSlash);
}
if audit.nul {
return Err(PathComponentError::ContainsNul);
}
if audit.dot {
return Err(PathComponentError::Dot);
}
if audit.dotdot {
return Err(PathComponentError::DotDot);
}
if audit.dotgit {
return Err(classify_dotgit_component_error(name, opts));
}
Ok(())
}
pub fn validate_path(
path: &[u8],
mode: EntryMode,
opts: ProtectOptions,
kind: PathValidationKind,
) -> Result<(), PathComponentError> {
if path.is_empty() {
return Err(PathComponentError::Empty);
}
if path.contains(&0) {
return Err(PathComponentError::ContainsNul);
}
for component in path.split(|&b| b == b'/') {
if component.is_empty() {
return Err(PathComponentError::Empty);
}
validate_component(component, mode, opts, kind)?;
}
Ok(())
}
fn ntfs_path_syntax_rules_apply() -> bool {
cfg!(windows)
}
#[must_use]
pub fn audit_tree_entry_name(name: &[u8], opts: ProtectOptions) -> TreeEntryNameAudit {
let mut audit = TreeEntryNameAudit::default();
if name.is_empty() {
audit.empty = true;
return audit;
}
if name.contains(&0) {
audit.nul = true;
}
if name == b"." {
audit.dot = true;
}
if name == b".." {
audit.dotdot = true;
}
if name.contains(&b'/') {
audit.slash = true;
}
if component_is_dotgit_alias(name, opts) {
audit.dotgit = true;
}
audit
}
impl TreeEntryNameAudit {
pub fn first_error(self) -> Result<(), PathComponentError> {
if self.empty {
return Err(PathComponentError::Empty);
}
if self.slash {
return Err(PathComponentError::ContainsSlash);
}
if self.nul {
return Err(PathComponentError::ContainsNul);
}
if self.dot {
return Err(PathComponentError::Dot);
}
if self.dotdot {
return Err(PathComponentError::DotDot);
}
if self.dotgit {
return Err(PathComponentError::DotGit);
}
Ok(())
}
}
#[must_use]
pub fn component_is_dotgit_alias(name: &[u8], opts: ProtectOptions) -> bool {
if name.eq_ignore_ascii_case(b".git") {
return true;
}
if name.eq_ignore_ascii_case(b"git~1") && opts.protect_ntfs {
return true;
}
let Ok(name_str) = std::str::from_utf8(name) else {
return false;
};
if opts.protect_hfs && is_hfs_dotgit(name_str) {
return true;
}
if opts.protect_ntfs && (is_ntfs_dotgit(name_str) || scan_ntfs_backslash_dotgit(name_str)) {
return true;
}
false
}
fn validate_component_symlink_dotfile(
name: &[u8],
mode: EntryMode,
opts: ProtectOptions,
kind: PathValidationKind,
) -> Option<PathComponentError> {
if !mode.is_symlink() {
return None;
}
if name.eq_ignore_ascii_case(b".gitmodules") {
return Some(PathComponentError::SymlinkedDotGitmodules);
}
let Ok(name_str) = std::str::from_utf8(name) else {
return None;
};
if dot_gitmodules_spelling(name_str, opts) {
return Some(PathComponentError::SymlinkedDotGitmodules);
}
if kind == PathValidationKind::Worktree {
return None;
}
if name.eq_ignore_ascii_case(b".gitattributes") {
return Some(PathComponentError::SymlinkedDotGitattributes);
}
if name.eq_ignore_ascii_case(b".gitignore") {
return Some(PathComponentError::SymlinkedDotGitignore);
}
if name.eq_ignore_ascii_case(b".mailmap") {
return Some(PathComponentError::SymlinkedDotMailmap);
}
if dot_gitattributes_spelling(name_str, opts) {
return Some(PathComponentError::SymlinkedDotGitattributes);
}
if opts.protect_hfs && is_hfs_dot_gitignore(name_str) {
return Some(PathComponentError::SymlinkedDotGitignore);
}
if opts.protect_hfs && is_hfs_dot_mailmap(name_str) {
return Some(PathComponentError::SymlinkedDotMailmap);
}
if opts.protect_ntfs && is_ntfs_dot_gitignore(name_str) {
return Some(PathComponentError::SymlinkedDotGitignore);
}
if opts.protect_ntfs && is_ntfs_dot_mailmap(name_str) {
return Some(PathComponentError::SymlinkedDotMailmap);
}
None
}
fn dot_gitmodules_spelling(name: &str, opts: ProtectOptions) -> bool {
(opts.protect_hfs && is_hfs_dot_gitmodules(name))
|| (opts.protect_ntfs && is_ntfs_dot_gitmodules(name))
|| (opts.protect_ntfs && scan_ntfs_backslash_dot_special(name, is_ntfs_dot_gitmodules))
}
fn dot_gitattributes_spelling(name: &str, opts: ProtectOptions) -> bool {
(opts.protect_hfs && is_hfs_dot_gitattributes(name))
|| (opts.protect_ntfs && is_ntfs_dot_gitattributes(name))
|| (opts.protect_ntfs && scan_ntfs_backslash_dot_special(name, is_ntfs_dot_gitattributes))
}
fn classify_dotgit_component_error(name: &[u8], opts: ProtectOptions) -> PathComponentError {
if name.eq_ignore_ascii_case(b".git") || name.eq_ignore_ascii_case(b"git~1") {
return PathComponentError::DotGit;
}
let Ok(name_str) = std::str::from_utf8(name) else {
return PathComponentError::DotGit;
};
if opts.protect_ntfs && scan_ntfs_backslash_dotgit(name_str) {
return PathComponentError::NtfsBackslash;
}
if opts.protect_hfs && is_hfs_dotgit(name_str) {
return PathComponentError::HfsDotGit;
}
if opts.protect_ntfs && is_ntfs_dotgit(name_str) {
return PathComponentError::NtfsDotGit;
}
PathComponentError::DotGit
}
fn scan_ntfs_backslash_dotgit(name: &str) -> bool {
scan_ntfs_backslash_dot_special(name, is_ntfs_dotgit)
}
fn ntfs_backslash_dotdot_tail(after: &str) -> bool {
after == ".." || after.starts_with("..\\") || after.starts_with("../")
}
fn scan_ntfs_backslash_dot_special(name: &str, mut pred: impl FnMut(&str) -> bool) -> bool {
if !name.contains('\\') {
return false;
}
let mut rest = name;
while let Some(idx) = rest.find('\\') {
let segment = &rest[..idx];
if !segment.is_empty() && pred(segment) {
return true;
}
rest = &rest[idx + 1..];
}
!rest.is_empty() && pred(rest)
}
fn has_dos_drive_prefix_bytes(name: &[u8]) -> bool {
name.len() >= 2 && name[0].is_ascii_alphabetic() && name[1] == b':'
}
#[cfg(test)]
mod tests {
use super::*;
const BLOB: EntryMode = EntryMode::new(0o100644);
const SYMLINK: EntryMode = EntryMode::new(0o120000);
const FSCK: ProtectOptions = ProtectOptions::fsck();
const OFF: ProtectOptions = ProtectOptions {
protect_ntfs: false,
protect_hfs: false,
};
const FSCK_KIND: PathValidationKind = PathValidationKind::Fsck;
const WT_KIND: PathValidationKind = PathValidationKind::Worktree;
#[test]
fn empty_dot_and_dotdot_rejected() {
assert_eq!(
validate_component(b"", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::Empty
);
assert_eq!(
validate_component(b".", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::Dot
);
assert_eq!(
validate_component(b"..", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::DotDot
);
}
#[test]
fn slash_and_nul_in_component() {
assert_eq!(
validate_component(b"foo/bar", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::ContainsSlash
);
assert_eq!(
validate_component(b"foo\0bar", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::ContainsNul
);
}
#[test]
fn ascii_dotgit_and_git_tilde_one() {
for name in [b".git".as_slice(), b".GIT".as_slice()] {
assert_eq!(
validate_component(name, BLOB, OFF, FSCK_KIND).unwrap_err(),
PathComponentError::DotGit
);
}
for name in [b"git~1".as_slice(), b"GIT~1".as_slice()] {
assert_eq!(
validate_component(name, BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::DotGit
);
assert!(validate_component(name, BLOB, OFF, FSCK_KIND).is_ok());
}
assert!(validate_component(b"git~2", BLOB, OFF, FSCK_KIND).is_ok());
}
#[test]
fn hfs_dotgit_unicode_ignored_codepoints() {
let name = ".gI\u{200c}T";
assert_eq!(
validate_component(name.as_bytes(), BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::HfsDotGit
);
assert!(validate_component(name.as_bytes(), BLOB, OFF, FSCK_KIND).is_ok());
}
#[test]
fn ntfs_dotgit_trailing_dot_and_spaces() {
assert_eq!(
validate_component(b".git.", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsDotGit
);
assert_eq!(
validate_component(b".git ", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsDotGit
);
}
#[test]
fn ntfs_backslash_dotgit_segment() {
assert_eq!(
validate_component(b"foo\\.git", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsBackslash
);
for name in [b".git\\config".as_slice(), b".git\\", b".GIT\\config"] {
assert_eq!(
validate_component(name, BLOB, FSCK, WT_KIND).unwrap_err(),
PathComponentError::NtfsBackslash,
"reject {name:?}"
);
}
if cfg!(windows) {
assert_eq!(
validate_component(br"a\..\x", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsBackslash
);
} else {
assert!(validate_component(br"a\..\x", BLOB, FSCK, FSCK_KIND).is_ok());
}
assert!(validate_component(br"a\..\x", BLOB, OFF, FSCK_KIND).is_ok());
}
#[test]
fn ntfs_alternate_stream_dotgit() {
assert_eq!(
validate_component(b".git::$INDEX_ALLOCATION", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsDotGit
);
}
#[test]
fn ntfs_dos_drive_prefix_in_component() {
if cfg!(windows) {
assert_eq!(
validate_component(b"C:foo", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsDriveOrStream
);
} else {
assert!(validate_component(b"C:foo", BLOB, FSCK, FSCK_KIND).is_ok());
assert!(validate_component(b"C:foo", BLOB, FSCK, WT_KIND).is_ok());
}
}
#[test]
fn issue_1037_multibyte_at_byte_six_no_panic() {
for name in ["abcde日.txt", "ab日本.txt", "file0日本.txt"] {
assert!(validate_component(name.as_bytes(), BLOB, FSCK, FSCK_KIND).is_ok());
assert!(!audit_tree_entry_name(name.as_bytes(), FSCK).dotgit);
}
}
#[test]
fn symlink_dot_special_files() {
assert_eq!(
validate_component(b".gitmodules", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::SymlinkedDotGitmodules
);
assert_eq!(
validate_component(b".gitattributes", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::SymlinkedDotGitattributes
);
assert_eq!(
validate_component(b".gitignore", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::SymlinkedDotGitignore
);
assert_eq!(
validate_component(b".mailmap", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::SymlinkedDotMailmap
);
assert!(validate_component(b".gitmodules", BLOB, FSCK, FSCK_KIND).is_ok());
assert!(validate_component(b".gitattributes", SYMLINK, FSCK, WT_KIND).is_ok());
}
#[test]
fn symlink_ascii_dot_special_banned_when_protect_flags_off() {
assert_eq!(
validate_component(b".gitmodules", SYMLINK, OFF, FSCK_KIND).unwrap_err(),
PathComponentError::SymlinkedDotGitmodules
);
assert_eq!(
validate_component(b".gitignore", SYMLINK, OFF, FSCK_KIND).unwrap_err(),
PathComponentError::SymlinkedDotGitignore
);
}
#[test]
fn dos_drive_prefix_on_non_utf8_bytes() {
if cfg!(windows) {
assert_eq!(
validate_component(b"C:\xff", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::NtfsDriveOrStream
);
} else {
assert!(validate_component(b"C:\xff", BLOB, FSCK, FSCK_KIND).is_ok());
}
}
#[test]
fn validate_path_rejects_empty_slash_delimited_components() {
for path in [
b"/a".as_slice(),
b"a/".as_slice(),
b"a//b".as_slice(),
b"//a".as_slice(),
] {
assert_eq!(
validate_path(path, BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::Empty,
"path {:?} should be rejected",
path
);
}
assert!(validate_path(b"a/b", BLOB, FSCK, FSCK_KIND).is_ok());
}
#[test]
fn validate_path_splits_on_slash() {
assert!(validate_path(b"dir/file.txt", BLOB, FSCK, FSCK_KIND).is_ok());
assert_eq!(
validate_path(b"dir/.git/file", BLOB, FSCK, FSCK_KIND).unwrap_err(),
PathComponentError::DotGit
);
}
#[test]
fn protect_options_from_config_defaults() {
let cfg = ConfigSet::new();
let opts = ProtectOptions::from_config(&cfg);
assert!(opts.protect_ntfs);
assert_eq!(opts.protect_hfs, cfg!(target_os = "macos"));
}
#[test]
fn audit_matches_validate_for_tree_cases() {
let audit = audit_tree_entry_name(b".git", FSCK);
assert!(audit.dotgit);
assert_eq!(audit.first_error().unwrap_err(), PathComponentError::DotGit);
}
}