//! Tree object: an ordered set of (name, type, hash, mode) entries. //! //! Per ยง2.3.2, each entry is: //! 2 bytes: name length (LE u16, max 255) //! N bytes: name (UTF-8, no null terminator) //! 1 byte: entry type (1=Blob, 2=Tree) //! 1 byte: mode bits (bit 0 executable, bit 1 symlink) //! 32 bytes: object hash (raw BLAKE3) //! //! Entries are sorted byte-wise by name. Names must not contain '/', null, //! or be `.` or `..`. use byteorder::{ByteOrder, LittleEndian}; use crate::error::Error; use crate::hash::{blake3_hash, ObjectId}; use crate::object::{frame_unsigned, ObjectType}; #[derive(Copy, Clone, Debug, PartialEq, Eq)] #[repr(u8)] pub enum EntryType { Blob = 1, Tree = 2, } impl EntryType { pub fn from_u8(b: u8) -> Result { Ok(match b { 1 => Self::Blob, 2 => Self::Tree, n => return Err(Error::MalformedObject(format!("bad tree entry type {n}"))), }) } } #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct FileMode(pub u8); impl FileMode { pub const REGULAR: FileMode = FileMode(0); pub const EXECUTABLE: FileMode = FileMode(0b01); pub const SYMLINK: FileMode = FileMode(0b10); pub fn is_executable(self) -> bool { self.0 & 0b01 != 0 } pub fn is_symlink(self) -> bool { self.0 & 0b10 != 0 } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TreeEntry { pub name: String, pub entry_type: EntryType, pub mode: FileMode, pub hash: ObjectId, } impl TreeEntry { pub fn validate_name(name: &str) -> Result<(), Error> { if name.is_empty() { return Err(Error::InvalidPath("empty tree-entry name".into())); } if name.len() > 255 { return Err(Error::InvalidPath(format!( "name too long ({} bytes)", name.len() ))); } if name == "." || name == ".." { return Err(Error::InvalidPath(format!("reserved name: {name}"))); } if name.contains('/') { return Err(Error::InvalidPath(format!("name contains '/': {name}"))); } if name.bytes().any(|b| b == 0) { return Err(Error::InvalidPath("name contains null byte".into())); } Ok(()) } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Tree { pub entries: Vec, } impl Tree { pub fn new() -> Self { Self::default() } /// Sort entries by name (byte-wise) and validate; required for hash /// determinism. pub fn sort_and_validate(&mut self) -> Result<(), Error> { for e in &self.entries { TreeEntry::validate_name(&e.name)?; } self.entries .sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); // detect duplicate names for w in self.entries.windows(2) { if w[0].name == w[1].name { return Err(Error::MalformedObject(format!( "duplicate tree entry name: {}", w[0].name ))); } } Ok(()) } pub fn body(&self) -> Vec { let mut out = Vec::with_capacity(self.entries.len() * 64); for e in &self.entries { let n = e.name.len() as u16; let mut len_buf = [0u8; 2]; LittleEndian::write_u16(&mut len_buf, n); out.extend_from_slice(&len_buf); out.extend_from_slice(e.name.as_bytes()); out.push(e.entry_type as u8); out.push(e.mode.0); out.extend_from_slice(e.hash.as_bytes()); } out } pub fn serialize(&self) -> Vec { frame_unsigned(ObjectType::Tree, &self.body()) } pub fn object_id(&self) -> ObjectId { blake3_hash(&self.serialize()) } pub fn parse_body(body: &[u8]) -> Result { let mut entries = Vec::new(); let mut p = 0usize; while p < body.len() { if body.len() < p + 2 { return Err(Error::MalformedObject( "tree entry: short name length".into(), )); } let n = LittleEndian::read_u16(&body[p..p + 2]) as usize; p += 2; if body.len() < p + n + 1 + 1 + 32 { return Err(Error::MalformedObject("tree entry truncated".into())); } let name = std::str::from_utf8(&body[p..p + n]) .map_err(|_| Error::MalformedObject("tree name not UTF-8".into()))? .to_string(); p += n; let entry_type = EntryType::from_u8(body[p])?; p += 1; let mode = FileMode(body[p]); p += 1; let mut h = [0u8; 32]; h.copy_from_slice(&body[p..p + 32]); p += 32; entries.push(TreeEntry { name, entry_type, mode, hash: ObjectId(h), }); } Ok(Tree { entries }) } pub fn find(&self, name: &str) -> Option<&TreeEntry> { self.entries.iter().find(|e| e.name == name) } } #[cfg(test)] mod tests { use super::*; #[test] fn tree_roundtrip() { let mut t = Tree::new(); t.entries.push(TreeEntry { name: "b.txt".into(), entry_type: EntryType::Blob, mode: FileMode::REGULAR, hash: ObjectId([1; 32]), }); t.entries.push(TreeEntry { name: "a.txt".into(), entry_type: EntryType::Blob, mode: FileMode::EXECUTABLE, hash: ObjectId([2; 32]), }); t.sort_and_validate().unwrap(); let body = t.body(); let t2 = Tree::parse_body(&body).unwrap(); assert_eq!(t.entries, t2.entries); // sorted: a then b assert_eq!(t.entries[0].name, "a.txt"); } #[test] fn rejects_dot_dotdot_slash_null() { for n in [".", "..", "a/b", "x\0y", ""] { assert!(TreeEntry::validate_name(n).is_err()); } } }