weavatrix-git 0.2.0

Dependency-free, evidence-carrying Git repository reader
Documentation
use crate::{HashKind, ObjectId, Result, error::invalid};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EntryKind {
    Blob,
    Tree,
    Commit,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreeEntry {
    pub mode: u32,
    pub name: Vec<u8>,
    pub id: ObjectId,
    pub kind: EntryKind,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Tree {
    pub entries: Vec<TreeEntry>,
}

impl Tree {
    pub(crate) fn parse(data: &[u8], hash: HashKind, limit: usize) -> Result<Self> {
        let hash_len = hash.bytes();
        let mut cursor = 0;
        let mut entries = Vec::new();
        while cursor < data.len() {
            if entries.len() == limit {
                return Err(crate::GitError::LimitExceeded {
                    resource: "tree entries",
                    limit,
                });
            }
            let space = data[cursor..]
                .iter()
                .position(|byte| *byte == b' ')
                .ok_or_else(|| invalid("tree entry has no mode terminator"))?
                + cursor;
            let mode_text = std::str::from_utf8(&data[cursor..space])
                .map_err(|_| invalid("tree mode is not ASCII"))?;
            let mode =
                u32::from_str_radix(mode_text, 8).map_err(|_| invalid("invalid tree mode"))?;
            cursor = space + 1;
            let nul = data[cursor..]
                .iter()
                .position(|byte| *byte == 0)
                .ok_or_else(|| invalid("tree entry has no name terminator"))?
                + cursor;
            if nul == cursor || data[cursor..nul].contains(&b'/') {
                return Err(invalid("invalid tree entry name"));
            }
            let name = data[cursor..nul].to_vec();
            cursor = nul + 1;
            let end = cursor
                .checked_add(hash_len)
                .ok_or_else(|| invalid("tree object identifier overflow"))?;
            let bytes = data
                .get(cursor..end)
                .ok_or_else(|| invalid("truncated tree object identifier"))?;
            let id = ObjectId::from_bytes(bytes)?;
            cursor = end;
            entries.push(TreeEntry {
                mode,
                name,
                id,
                kind: kind_from_mode(mode)?,
            });
        }
        Ok(Self { entries })
    }
}

fn kind_from_mode(mode: u32) -> Result<EntryKind> {
    match mode {
        0o040_000 => Ok(EntryKind::Tree),
        0o160_000 => Ok(EntryKind::Commit),
        0o100_644 | 0o100_755 | 0o120_000 => Ok(EntryKind::Blob),
        _ => Err(invalid(format!("unsupported tree mode {mode:o}"))),
    }
}

#[cfg(test)]
mod tests {
    use super::{EntryKind, Tree};
    use crate::HashKind;

    #[test]
    fn parses_binary_tree() {
        let mut input = b"100644 hello.txt\0".to_vec();
        input.extend([7; 20]);
        let tree = Tree::parse(&input, HashKind::Sha1, 10).unwrap();
        assert_eq!(tree.entries[0].name, b"hello.txt");
        assert_eq!(tree.entries[0].kind, EntryKind::Blob);
    }

    #[test]
    fn rejects_invalid_and_limited_trees() {
        assert!(Tree::parse(b"broken", HashKind::Sha1, 10).is_err());
        let mut invalid_mode = b"100600 file\0".to_vec();
        invalid_mode.extend([0; 20]);
        assert!(Tree::parse(&invalid_mode, HashKind::Sha1, 10).is_err());
        let mut slash = b"100644 a/b\0".to_vec();
        slash.extend([0; 20]);
        assert!(Tree::parse(&slash, HashKind::Sha1, 10).is_err());
        let mut valid = b"100644 file\0".to_vec();
        valid.extend([0; 20]);
        assert!(Tree::parse(&valid, HashKind::Sha1, 0).is_err());
    }
}