#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::cmp::Ordering;
use std::collections::HashSet;
use crate::ObjectId;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Tree {
format: crate::ObjectFormat,
entries: Vec<TreeEntry>,
original: Vec<u8>,
}
impl Tree {
pub fn new(
format: crate::ObjectFormat,
mut entries: Vec<TreeEntry>,
) -> Result<Self, TreeError> {
for entry in &entries {
entry.id.require_format(format)?;
}
validate_names(&entries)?;
entries.sort_by(TreeEntry::git_cmp);
let original = encode_entries(&entries);
let tree = Self {
format,
entries,
original,
};
Ok(tree)
}
pub fn parse(format: crate::ObjectFormat, payload: &[u8]) -> Result<Self, TreeError> {
let original = payload.to_vec();
let mut entries = Vec::new();
for entry in RawEntries::new(format, payload) {
let (mode, name, id) = entry?;
entries.push(TreeEntry {
mode,
name: name.to_vec(),
id,
});
}
Ok(Self {
format,
entries,
original,
})
}
pub fn object_format(&self) -> crate::ObjectFormat {
self.format
}
pub fn entries(&self) -> &[TreeEntry] {
&self.entries
}
pub fn validate(&self) -> Result<(), TreeError> {
validate_names(&self.entries)?;
if !self.entries.is_sorted_by(|a, b| a.git_cmp(b).is_le()) {
return Err(TreeError::Unsorted);
}
Ok(())
}
pub fn encode(&self) -> Vec<u8> {
self.original.clone()
}
pub fn id(&self) -> ObjectId {
self.format
.hash_object(crate::ObjectKind::Tree, &self.original)
}
}
fn encode_entries(entries: &[TreeEntry]) -> Vec<u8> {
let mut payload = Vec::new();
for entry in entries {
payload.extend_from_slice(entry.mode.bytes());
payload.push(b' ');
payload.extend_from_slice(&entry.name);
payload.push(0);
payload.extend_from_slice(entry.id.as_bytes());
}
payload
}
pub(crate) struct RawEntries<'a> {
format: crate::ObjectFormat,
payload: &'a [u8],
}
impl<'a> RawEntries<'a> {
pub(crate) fn new(format: crate::ObjectFormat, payload: &'a [u8]) -> Self {
Self { format, payload }
}
fn parse_one(&mut self) -> Result<(EntryMode, &'a [u8], ObjectId), TreeError> {
let (mode, rest) =
crate::bytes::split_once(self.payload, b' ').ok_or(TreeError::MissingModeDelimiter)?;
let mode = EntryMode::parse(mode)?;
let (name, rest) =
crate::bytes::split_once(rest, 0).ok_or(TreeError::MissingNameDelimiter)?;
let (raw_id, rest) = rest
.split_at_checked(self.format.digest_len())
.ok_or(TreeError::TruncatedObjectId)?;
let id =
ObjectId::from_bytes(self.format, raw_id).map_err(|_| TreeError::TruncatedObjectId)?;
self.payload = rest;
Ok((mode, name, id))
}
}
impl<'a> Iterator for RawEntries<'a> {
type Item = Result<(EntryMode, &'a [u8], ObjectId), TreeError>;
fn next(&mut self) -> Option<Self::Item> {
if self.payload.is_empty() {
return None;
}
let entry = self.parse_one();
if entry.is_err() {
self.payload = &[];
}
Some(entry)
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct TreeEntry {
pub mode: EntryMode,
pub name: Vec<u8>,
pub id: ObjectId,
}
impl TreeEntry {
pub fn git_cmp(&self, other: &Self) -> Ordering {
let left = self
.name
.iter()
.copied()
.chain(std::iter::once(self.mode.terminator()));
let right = other
.name
.iter()
.copied()
.chain(std::iter::once(other.mode.terminator()));
left.cmp(right)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum EntryMode {
Blob,
Executable,
Symlink,
Tree,
Gitlink,
}
impl EntryMode {
fn bytes(self) -> &'static [u8] {
match self {
Self::Blob => b"100644",
Self::Executable => b"100755",
Self::Symlink => b"120000",
Self::Tree => b"40000",
Self::Gitlink => b"160000",
}
}
fn parse(bytes: &[u8]) -> Result<Self, TreeError> {
if bytes.is_empty() {
return Err(TreeError::UnsupportedMode);
}
let mut mode = 0u32;
for &byte in bytes {
if !(b'0'..=b'7').contains(&byte) {
return Err(TreeError::UnsupportedMode);
}
mode = mode.wrapping_mul(8).wrapping_add(u32::from(byte - b'0'));
}
Ok(match mode & 0o170000 {
0o040000 => Self::Tree,
0o100000 if mode & 0o100 != 0 => Self::Executable,
0o100000 => Self::Blob,
0o120000 => Self::Symlink,
_ => Self::Gitlink,
})
}
fn terminator(self) -> u8 {
if self == Self::Tree { b'/' } else { 0 }
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum TreeError {
#[error(transparent)]
ObjectFormat(#[from] crate::ObjectFormatError),
#[error("missing tree mode delimiter")]
MissingModeDelimiter,
#[error("unsupported or malformed tree entry mode")]
UnsupportedMode,
#[error("missing tree name delimiter")]
MissingNameDelimiter,
#[error("truncated tree entry object identity")]
TruncatedObjectId,
#[error("invalid tree entry name")]
InvalidName,
#[error("duplicate tree entry name")]
DuplicateName,
#[error("tree entries are not in Git order")]
Unsorted,
}
fn validate_names(entries: &[TreeEntry]) -> Result<(), TreeError> {
let mut names = HashSet::with_capacity(entries.len());
for entry in entries {
let name = entry.name.as_slice();
if name.is_empty()
|| name.contains(&0)
|| name.contains(&b'/')
|| name == b"."
|| name == b".."
{
return Err(TreeError::InvalidName);
}
if !names.insert(name) {
return Err(TreeError::DuplicateName);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn entry(mode: EntryMode, name: &[u8]) -> TreeEntry {
TreeEntry {
mode,
name: name.to_vec(),
id: ObjectId::Sha1([0x81; 20]),
}
}
fn record(prefix: &[u8]) -> Vec<u8> {
[prefix, &[0x81; 20]].concat()
}
#[rstest]
#[case::mode_without_space(b"100644".as_slice(), TreeError::MissingModeDelimiter)]
#[case::name_without_nul(b"100644 a", TreeError::MissingNameDelimiter)]
#[case::delimiters_at_end(b"100644 \0", TreeError::TruncatedObjectId)]
#[case::short_id(&[b"100644 a\0".as_slice(), &[0x81; 19]].concat(), TreeError::TruncatedObjectId)]
fn rejects_truncated_entry(#[case] payload: &[u8], #[case] expected: TreeError) {
assert_eq!(
Tree::parse(crate::ObjectFormat::Sha1, payload),
Err(expected)
);
}
#[test]
fn empty_tree_has_git_identity() {
let tree = Tree::new(crate::ObjectFormat::Sha1, vec![]).unwrap();
assert_eq!(tree.encode(), b"");
assert_eq!(Tree::parse(crate::ObjectFormat::Sha1, b"").unwrap(), tree);
assert_eq!(tree.validate(), Ok(()));
assert_eq!(
tree.id().to_string(),
"4b825dc642cb6eb9a060e54bf8d69288fbee4904"
);
}
#[rstest]
#[case::regular(EntryMode::Blob, b"100644 a\0")]
#[case::executable(EntryMode::Executable, b"100755 a\0")]
#[case::symlink(EntryMode::Symlink, b"120000 a\0")]
#[case::directory(EntryMode::Tree, b"40000 a\0")]
#[case::gitlink(EntryMode::Gitlink, b"160000 a\0")]
fn preserves_supported_modes(#[case] mode: EntryMode, #[case] prefix: &[u8]) {
let expected = record(prefix);
let tree = Tree::new(crate::ObjectFormat::Sha1, vec![entry(mode, b"a")]).unwrap();
assert_eq!(tree.encode(), expected);
assert_eq!(
Tree::parse(crate::ObjectFormat::Sha1, &expected)
.unwrap()
.entries(),
&[entry(mode, b"a")]
);
}
#[test]
fn preserves_delimiter_bytes_inside_object_id() {
let raw_id = *b"\0 abcdefghijklmnop\0 ";
let payload = [
b"100644 a\0".as_slice(),
&raw_id,
b"100755 b\0",
&[0x81; 20],
]
.concat();
let tree = Tree::parse(crate::ObjectFormat::Sha1, &payload).unwrap();
assert_eq!(
tree.entries(),
&[
TreeEntry {
mode: EntryMode::Blob,
name: b"a".to_vec(),
id: ObjectId::Sha1(raw_id),
},
entry(EntryMode::Executable, b"b"),
]
);
assert_eq!(tree.encode(), payload);
assert_eq!(tree.validate(), Ok(()));
}
#[rstest]
#[case::non_utf8(b"\xff\x80")]
#[case::whitespace(b"a b\t\n")]
#[case::backslash(b"a\\b")]
fn preserves_name_bytes(#[case] name: &[u8]) {
let expected = [b"100644 ".as_slice(), name, b"\0", &[0x81; 20]].concat();
let tree = Tree::new(
crate::ObjectFormat::Sha1,
vec![entry(EntryMode::Blob, name)],
)
.unwrap();
assert_eq!(tree.encode(), expected);
assert_eq!(
Tree::parse(crate::ObjectFormat::Sha1, &expected)
.unwrap()
.entries()[0]
.name,
name
);
}
#[rstest]
#[case::file_prefix(EntryMode::Blob, b"a", EntryMode::Blob, b"a.c")]
#[case::directory_after_dot(EntryMode::Blob, b"a.c", EntryMode::Tree, b"a")]
#[case::directory_before_zero(EntryMode::Tree, b"a", EntryMode::Blob, b"a0")]
#[case::longer_directory(EntryMode::Tree, b"a", EntryMode::Tree, b"aa")]
#[case::gitlink_is_file(EntryMode::Gitlink, b"a", EntryMode::Blob, b"a.c")]
#[case::symlink_is_file(EntryMode::Symlink, b"a", EntryMode::Tree, b"a")]
#[case::unsigned_bytes(EntryMode::Blob, b"z", EntryMode::Blob, b"\x80")]
fn compares_git_names(
#[case] left_mode: EntryMode,
#[case] left_name: &[u8],
#[case] right_mode: EntryMode,
#[case] right_name: &[u8],
) {
let left = entry(left_mode, left_name);
let right = entry(right_mode, right_name);
assert_eq!(left.git_cmp(&right), Ordering::Less);
assert_eq!(right.git_cmp(&left), Ordering::Greater);
}
#[test]
fn sorts_file_and_directory_prefixes() {
let tree = Tree::new(
crate::ObjectFormat::Sha1,
vec![
entry(EntryMode::Blob, b"a0"),
entry(EntryMode::Tree, b"a"),
entry(EntryMode::Blob, b"a.c"),
],
)
.unwrap();
let names: Vec<_> = tree
.entries()
.iter()
.map(|entry| entry.name.as_slice())
.collect();
assert_eq!(names, [b"a.c".as_slice(), b"a", b"a0"]);
assert_eq!(tree.validate(), Ok(()));
}
#[rstest]
#[case::empty(b"")]
#[case::nul(b"a\0b")]
#[case::slash(b"a/b")]
#[case::dot(b".")]
#[case::dot_dot(b"..")]
fn rejects_invalid_new_names(#[case] name: &[u8]) {
assert_eq!(
Tree::new(
crate::ObjectFormat::Sha1,
vec![entry(EntryMode::Blob, name)]
),
Err(TreeError::InvalidName)
);
}
#[rstest]
#[case::empty(b"100644 \0")]
#[case::slash(b"100644 a/b\0")]
#[case::dot(b"100644 .\0")]
#[case::dot_dot(b"100644 ..\0")]
fn parses_invalid_names_without_repair(#[case] prefix: &[u8]) {
let payload = record(prefix);
let tree = Tree::parse(crate::ObjectFormat::Sha1, &payload).unwrap();
assert_eq!(tree.encode(), payload);
assert_eq!(tree.validate(), Err(TreeError::InvalidName));
}
#[test]
fn preserves_unsorted_input_until_explicit_reconstruction() {
let payload = [record(b"40000 a\0"), record(b"100644 a.c\0")].concat();
let tree = Tree::parse(crate::ObjectFormat::Sha1, &payload).unwrap();
assert_eq!(tree.encode(), payload);
assert_eq!(tree.validate(), Err(TreeError::Unsorted));
let rebuilt = Tree::new(crate::ObjectFormat::Sha1, tree.entries().to_vec()).unwrap();
assert_eq!(rebuilt.entries()[0].name, b"a.c");
assert_ne!(rebuilt.id(), tree.id());
}
#[rstest]
#[case::same_mode(b"100644 a\0")]
#[case::different_mode(b"40000 a\0")]
fn detects_duplicate_names_even_when_nonadjacent(#[case] last: &[u8]) {
let payload = [record(b"100644 a\0"), record(b"100644 a.c\0"), record(last)].concat();
let tree = Tree::parse(crate::ObjectFormat::Sha1, &payload).unwrap();
assert_eq!(tree.encode(), payload);
assert_eq!(tree.validate(), Err(TreeError::DuplicateName));
assert_eq!(
Tree::new(crate::ObjectFormat::Sha1, tree.entries().to_vec()),
Err(TreeError::DuplicateName)
);
}
#[rstest]
#[case::empty(b" a\0")]
#[case::non_octal(b"100648 a\0")]
#[case::negative(b"-100644 a\0")]
fn rejects_unsupported_mode_spellings(#[case] prefix: &[u8]) {
assert_eq!(
Tree::parse(crate::ObjectFormat::Sha1, &record(prefix)),
Err(TreeError::UnsupportedMode)
);
}
#[rstest]
#[case::missing_space(b"100644", TreeError::MissingModeDelimiter)]
#[case::missing_nul(b"100644 name", TreeError::MissingNameDelimiter)]
#[case::no_id(b"100644 a\0", TreeError::TruncatedObjectId)]
#[case::short_id(b"100644 a\0abcdefghijklmnopqrs", TreeError::TruncatedObjectId)]
fn rejects_incomplete_records(#[case] payload: &[u8], #[case] error: TreeError) {
assert_eq!(Tree::parse(crate::ObjectFormat::Sha1, payload), Err(error));
}
#[test]
fn rejects_trailing_partial_entry() {
let payload = [record(b"100644 a\0"), b"100644 b\0short".to_vec()].concat();
assert_eq!(
Tree::parse(crate::ObjectFormat::Sha1, &payload),
Err(TreeError::TruncatedObjectId)
);
}
}
#[cfg(test)]
mod format_boundary_tests {
use super::*;
#[test]
fn rejects_sha256_entry_before_encoding() {
let result = Tree::new(
crate::ObjectFormat::Sha1,
vec![TreeEntry {
mode: EntryMode::Blob,
name: b"a".to_vec(),
id: ObjectId::Sha256([1; 32]),
}],
);
assert!(matches!(result, Err(TreeError::ObjectFormat(_))));
}
}
#[cfg(test)]
mod dual_format_tests {
use rstest::rstest;
use super::*;
use crate::ObjectFormat;
#[rstest]
#[case::sha1(ObjectFormat::Sha1, "4b825dc642cb6eb9a060e54bf8d69288fbee4904")]
#[case::sha256(
ObjectFormat::Sha256,
"6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"
)]
fn empty_tree_retains_selected_identity(#[case] format: ObjectFormat, #[case] expected: &str) {
let tree = Tree::new(format, vec![]).unwrap();
assert_eq!(tree.id().to_string(), expected);
assert_eq!(Tree::parse(format, b"").unwrap(), tree);
}
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn truncated_digest_is_rejected(#[case] format: ObjectFormat) {
let mut bytes = b"160000 submodule\0".to_vec();
bytes.extend(vec![1; format.digest_len() - 1]);
assert_eq!(
Tree::parse(format, &bytes),
Err(TreeError::TruncatedObjectId)
);
}
#[test]
fn sha256_tree_rejects_sha1_gitlink() {
assert!(matches!(
Tree::new(
ObjectFormat::Sha256,
vec![TreeEntry {
mode: EntryMode::Gitlink,
name: b"submodule".to_vec(),
id: ObjectId::Sha1([1; 20])
}]
),
Err(TreeError::ObjectFormat(_))
));
}
}