use alloc::vec::Vec;
use crate::error::{Error, Result};
use crate::oid::{HashAlgo, ObjectId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileMode {
Tree,
Regular,
Executable,
Symlink,
Gitlink,
}
impl FileMode {
pub const fn as_octal(self) -> &'static str {
match self {
FileMode::Tree => "40000",
FileMode::Regular => "100644",
FileMode::Executable => "100755",
FileMode::Symlink => "120000",
FileMode::Gitlink => "160000",
}
}
pub fn from_octal(s: &[u8]) -> Result<Self> {
Ok(match s {
b"40000" | b"040000" => FileMode::Tree,
b"100644" => FileMode::Regular,
b"100755" => FileMode::Executable,
b"120000" => FileMode::Symlink,
b"160000" => FileMode::Gitlink,
other => {
use alloc::format;
return Err(Error::Parse(format!(
"tree: unknown file mode {:?}",
core::str::from_utf8(other).unwrap_or("<non-utf8>")
)));
}
})
}
pub fn is_tree(self) -> bool {
matches!(self, FileMode::Tree)
}
pub fn from_mode_bits(mode: u32) -> Result<Self> {
Ok(match mode & 0o170000 {
0o040000 => FileMode::Tree,
0o120000 => FileMode::Symlink,
0o160000 => FileMode::Gitlink,
0o100000 => {
if mode & 0o111 != 0 {
FileMode::Executable
} else {
FileMode::Regular
}
}
_ => {
use alloc::format;
return Err(Error::Parse(format!("tree: unrepresentable mode {mode:o}")));
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub mode: FileMode,
pub name: Vec<u8>,
pub id: ObjectId,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Tree {
pub entries: Vec<TreeEntry>,
}
impl Tree {
pub fn parse(algo: HashAlgo, mut payload: &[u8]) -> Result<Self> {
let id_len = algo.raw_len();
let mut entries = Vec::new();
while !payload.is_empty() {
let space = payload
.iter()
.position(|&b| b == b' ')
.ok_or_else(|| Error::Parse("tree: entry missing mode separator".into()))?;
let mode = FileMode::from_octal(&payload[..space])?;
let rest = &payload[space + 1..];
let nul = rest
.iter()
.position(|&b| b == 0)
.ok_or_else(|| Error::Parse("tree: entry missing name terminator".into()))?;
let name = rest[..nul].to_vec();
if name.is_empty() || name.contains(&b'/') || name.contains(&0) {
return Err(Error::Parse(
"tree: entry name is not a single path component".into(),
));
}
let after_name = &rest[nul + 1..];
if after_name.len() < id_len {
return Err(Error::Parse("tree: truncated entry id".into()));
}
let id = ObjectId::from_bytes(algo, &after_name[..id_len])?;
entries.push(TreeEntry { mode, name, id });
payload = &after_name[id_len..];
}
Ok(Tree { entries })
}
pub fn serialize(&self) -> Vec<u8> {
let mut sorted: Vec<&TreeEntry> = self.entries.iter().collect();
sorted.sort_by(|a, b| cmp_entry_names(a, b));
let mut out = Vec::new();
for e in sorted {
out.extend_from_slice(e.mode.as_octal().as_bytes());
out.push(b' ');
out.extend_from_slice(&e.name);
out.push(0);
out.extend_from_slice(e.id.as_bytes());
}
out
}
pub fn get(&self, name: &[u8]) -> Option<&TreeEntry> {
self.entries.iter().find(|e| e.name == name)
}
}
pub fn is_safe_path_component(name: &[u8]) -> bool {
if name.is_empty() || name == b"." || name == b".." {
return false;
}
if name
.iter()
.any(|&b| b == b'/' || b == b'\\' || b == 0 || b == b':')
{
return false;
}
!is_dot_git_component(name)
}
fn is_dot_git_component(name: &[u8]) -> bool {
let mut end = name.len();
while end > 0 && (name[end - 1] == b'.' || name[end - 1] == b' ') {
end -= 1;
}
let trimmed = &name[..end];
ascii_eq_ignore_case(trimmed, b".git") || ascii_eq_ignore_case(name, b"git~1")
}
fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
}
fn cmp_entry_names(a: &TreeEntry, b: &TreeEntry) -> core::cmp::Ordering {
let a_name = effective_name(a);
let b_name = effective_name(b);
a_name.cmp(&b_name)
}
fn effective_name(e: &TreeEntry) -> Vec<u8> {
let mut n = e.name.clone();
if e.mode.is_tree() {
n.push(b'/');
}
n
}
#[cfg(test)]
mod tests {
use super::*;
fn oid(byte: u8) -> ObjectId {
ObjectId::from_bytes(HashAlgo::Sha1, &[byte; 20]).unwrap()
}
#[test]
fn roundtrip() {
let tree = Tree {
entries: alloc::vec![
TreeEntry {
mode: FileMode::Regular,
name: b"a.txt".to_vec(),
id: oid(1)
},
TreeEntry {
mode: FileMode::Tree,
name: b"sub".to_vec(),
id: oid(2)
},
],
};
let bytes = tree.serialize();
let back = Tree::parse(HashAlgo::Sha1, &bytes).unwrap();
assert_eq!(back.entries.len(), 2);
assert_eq!(back.get(b"a.txt").unwrap().mode, FileMode::Regular);
assert_eq!(back.get(b"sub").unwrap().mode, FileMode::Tree);
}
#[test]
fn sort_treats_subtree_with_slash() {
let tree = Tree {
entries: alloc::vec![
TreeEntry {
mode: FileMode::Tree,
name: b"foo".to_vec(),
id: oid(3)
},
TreeEntry {
mode: FileMode::Regular,
name: b"foo.txt".to_vec(),
id: oid(1)
},
TreeEntry {
mode: FileMode::Regular,
name: b"foo".to_vec(),
id: oid(2)
},
],
};
let back = Tree::parse(HashAlgo::Sha1, &tree.serialize()).unwrap();
let names: Vec<&[u8]> = back.entries.iter().map(|e| e.name.as_slice()).collect();
assert_eq!(
names,
alloc::vec![&b"foo"[..], &b"foo.txt"[..], &b"foo"[..]]
);
}
}