use bstr::{BStr, ByteSlice};
#[allow(clippy::empty_docs)]
pub mod component {
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
pub enum Error {
#[error("A path component must not be empty")]
Empty,
#[error("Path separators like / or \\ are not allowed")]
PathSeparator,
#[error("Windows path prefixes are not allowed")]
WindowsPathPrefix,
#[error("Windows device-names may have side-effects and are not allowed")]
WindowsReservedName,
#[error("Trailing spaces or dots, and the following characters anywhere, are forbidden in Windows paths, along with non-printable ones: <>:\"|?*")]
WindowsIllegalCharacter,
#[error("The .git name may never be used")]
DotGitDir,
#[error("The .gitmodules file must not be a symlink")]
SymlinkedGitModules,
}
#[derive(Debug, Copy, Clone)]
pub struct Options {
pub protect_windows: bool,
pub protect_hfs: bool,
pub protect_ntfs: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
protect_windows: true,
protect_hfs: true,
protect_ntfs: true,
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Mode {
Symlink,
}
}
pub fn component(
input: &BStr,
mode: Option<component::Mode>,
component::Options {
protect_windows,
protect_hfs,
protect_ntfs,
}: component::Options,
) -> Result<&BStr, component::Error> {
if input.is_empty() {
return Err(component::Error::Empty);
}
if protect_windows {
if input.find_byteset(b"/\\").is_some() {
return Err(component::Error::PathSeparator);
}
if input.chars().nth(1) == Some(':') {
return Err(component::Error::WindowsPathPrefix);
}
} else if input.find_byte(b'/').is_some() {
return Err(component::Error::PathSeparator);
}
if protect_hfs {
if is_dot_hfs(input, "git") {
return Err(component::Error::DotGitDir);
}
if is_symlink(mode) && is_dot_hfs(input, "gitmodules") {
return Err(component::Error::SymlinkedGitModules);
}
}
if protect_ntfs {
if is_dot_git_ntfs(input) {
return Err(component::Error::DotGitDir);
}
if is_symlink(mode) && is_dot_ntfs(input, "gitmodules", "gi7eba") {
return Err(component::Error::SymlinkedGitModules);
}
if protect_windows {
if let Some(err) = check_win_devices_and_illegal_characters(input) {
return Err(err);
}
}
}
if !(protect_hfs | protect_ntfs) {
if input.eq_ignore_ascii_case(b".git") {
return Err(component::Error::DotGitDir);
}
if is_symlink(mode) && input.eq_ignore_ascii_case(b".gitmodules") {
return Err(component::Error::SymlinkedGitModules);
}
}
Ok(input)
}
pub fn component_is_windows_device(input: &BStr) -> bool {
is_win_device(input)
}
fn is_win_device(input: &BStr) -> bool {
let Some(in3) = input.get(..3) else { return false };
if in3.eq_ignore_ascii_case(b"AUX") && is_done_windows(input.get(3..)) {
return true;
}
if in3.eq_ignore_ascii_case(b"NUL") && is_done_windows(input.get(3..)) {
return true;
}
if in3.eq_ignore_ascii_case(b"PRN") && is_done_windows(input.get(3..)) {
return true;
}
if in3.eq_ignore_ascii_case(b"COM")
&& input.get(3).map_or(false, |n| *n >= b'1' && *n <= b'9')
&& is_done_windows(input.get(4..))
{
return true;
}
if in3.eq_ignore_ascii_case(b"LPT")
&& input.get(3).map_or(false, u8::is_ascii_digit)
&& is_done_windows(input.get(4..))
{
return true;
}
if in3.eq_ignore_ascii_case(b"CON")
&& (is_done_windows(input.get(3..))
|| (input.get(3..6).map_or(false, |n| n.eq_ignore_ascii_case(b"IN$")) && is_done_windows(input.get(6..)))
|| (input.get(3..7).map_or(false, |n| n.eq_ignore_ascii_case(b"OUT$")) && is_done_windows(input.get(7..))))
{
return true;
}
false
}
fn check_win_devices_and_illegal_characters(input: &BStr) -> Option<component::Error> {
if is_win_device(input) {
return Some(component::Error::WindowsReservedName);
}
if input.iter().any(|b| *b < 0x20 || b":<>\"|?*".contains(b)) {
return Some(component::Error::WindowsIllegalCharacter);
}
if input.ends_with(b".") || input.ends_with(b" ") {
return Some(component::Error::WindowsIllegalCharacter);
}
None
}
fn is_symlink(mode: Option<component::Mode>) -> bool {
mode.map_or(false, |m| m == component::Mode::Symlink)
}
fn is_dot_hfs(input: &BStr, search_case_insensitive: &str) -> bool {
let mut input = input.chars().filter(|c| match *c as u32 {
0x200c | 0x200d | 0x200e | 0x200f | 0x202a | 0x202b | 0x202c | 0x202d | 0x202e | 0x206a | 0x206b | 0x206c | 0x206d | 0x206e | 0x206f | 0xfeff => false, _ => true
});
if input.next() != Some('.') {
return false;
}
let mut comp = search_case_insensitive.chars();
loop {
match (comp.next(), input.next()) {
(Some(a), Some(b)) => {
if !a.eq_ignore_ascii_case(&b) {
return false;
}
}
(None, None) => return true,
_ => return false,
}
}
}
fn is_dot_git_ntfs(input: &BStr) -> bool {
if input
.get(..4)
.map_or(false, |input| input.eq_ignore_ascii_case(b".git"))
{
return is_done_ntfs(input.get(4..));
}
if input
.get(..5)
.map_or(false, |input| input.eq_ignore_ascii_case(b"git~1"))
{
return is_done_ntfs(input.get(5..));
}
false
}
fn is_dot_ntfs(input: &BStr, search_case_insensitive: &str, ntfs_shortname_prefix: &str) -> bool {
if input.first() == Some(&b'.') {
let end_pos = 1 + search_case_insensitive.len();
if input.get(1..end_pos).map_or(false, |input| {
input.eq_ignore_ascii_case(search_case_insensitive.as_bytes())
}) {
is_done_ntfs(input.get(end_pos..))
} else {
false
}
} else {
let search_case_insensitive: &[u8] = search_case_insensitive.as_bytes();
if search_case_insensitive
.get(..6)
.zip(input.get(..6))
.map_or(false, |(ntfs_prefix, first_6_of_input)| {
first_6_of_input.eq_ignore_ascii_case(ntfs_prefix)
&& input.get(6) == Some(&b'~')
&& input.get(7).map_or(false, |num| (b'1'..=b'4').contains(num))
})
{
return is_done_ntfs(input.get(8..));
}
let ntfs_shortname_prefix: &[u8] = ntfs_shortname_prefix.as_bytes();
let mut saw_tilde = false;
let mut pos = 0;
while pos < 8 {
let Some(b) = input.get(pos).copied() else {
return false;
};
if saw_tilde {
if !b.is_ascii_digit() {
return false;
}
} else if b == b'~' {
saw_tilde = true;
pos += 1;
let Some(b) = input.get(pos).copied() else {
return false;
};
if !(b'1'..=b'9').contains(&b) {
return false;
}
} else if pos >= 6
|| b & 0x80 == 0x80
|| ntfs_shortname_prefix
.get(pos)
.map_or(true, |ob| !b.eq_ignore_ascii_case(ob))
{
return false;
}
pos += 1;
}
is_done_ntfs(input.get(pos..))
}
}
fn is_done_ntfs(input: Option<&[u8]>) -> bool {
let Some(input) = input else { return true };
for b in input.bytes() {
if b == b':' {
return true;
}
if b != b' ' && b != b'.' {
return false;
}
}
true
}
fn is_done_windows(input: Option<&[u8]>) -> bool {
let Some(input) = input else { return true };
let skip = input.bytes().take_while(|b| *b == b' ').count();
let Some(next) = input.get(skip) else { return true };
*next == b'.' || *next == b':'
}