211 lines
6.0 KiB
Rust
211 lines
6.0 KiB
Rust
//! 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<Self, Error> {
|
|
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<TreeEntry>,
|
|
}
|
|
|
|
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<u8> {
|
|
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<u8> {
|
|
frame_unsigned(ObjectType::Tree, &self.body())
|
|
}
|
|
|
|
pub fn object_id(&self) -> ObjectId {
|
|
blake3_hash(&self.serialize())
|
|
}
|
|
|
|
pub fn parse_body(body: &[u8]) -> Result<Self, Error> {
|
|
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());
|
|
}
|
|
}
|
|
}
|