#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository input (#103)"
)]
use super::{Entry, Mode, Stage};
use crate::ObjectId;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct Claim {
pub start: usize,
pub end: usize,
pub id: ObjectId,
}
struct Record<'a> {
name: &'a [u8],
entries: Option<usize>,
id: Option<ObjectId>,
subtrees: usize,
}
pub(super) fn claims(format: crate::ObjectFormat, data: &[u8], entries: &[Entry]) -> Vec<Claim> {
let Some(records) = parse(format, data, entries.len() + 1) else {
return Vec::new();
};
let children = children(&records);
let mut claims = Vec::new();
let mut pending = vec![(0, Vec::new(), 0, entries.len())];
while let Some((position, prefix, start, end)) = pending.pop() {
let (Some(record), Some(within)) = (records.get(position), entries.get(start..end)) else {
continue;
};
if let (Some(count), Some(id)) = (record.entries, record.id)
&& count == within.len()
&& count != 0
&& within.iter().all(plain)
{
claims.push(Claim { start, end, id });
continue;
}
for &child in children.get(position).into_iter().flatten() {
let Some(child_record) = records.get(child) else {
continue;
};
let mut directory = prefix.clone();
directory.extend_from_slice(child_record.name);
directory.push(b'/');
let first = within.partition_point(|e| e.path < directory);
let last =
within.partition_point(|e| e.path < directory || e.path.starts_with(&directory));
if first < last {
pending.push((child, directory, start + first, start + last));
}
}
}
claims.sort_by_key(|claim| claim.start);
claims
}
fn plain(entry: &Entry) -> bool {
entry.stage == Stage::Normal && !entry.intent_to_add && entry.mode != Mode::SparseDirectory
}
fn parse(format: crate::ObjectFormat, data: &[u8], max_records: usize) -> Option<Vec<Record<'_>>> {
let mut records = Vec::new();
let mut rest = data;
let mut open: Vec<usize> = Vec::new();
loop {
if records.len() == max_records {
return None;
}
let (name, after) = split_at_byte(rest, 0)?;
if records.is_empty() != name.is_empty() || name.contains(&b'/') {
return None;
}
let (count, after) = split_at_byte(after, b' ')?;
let entries = decimal(count, true)?;
let (subtrees, after) = split_at_byte(after, b'\n')?;
let subtrees = usize::try_from(decimal(subtrees, false)?).ok()?;
rest = after;
let (entries, id) = if entries < 0 {
(None, None)
} else {
let (digest, after) = rest.split_at_checked(format.digest_len())?;
rest = after;
let id = ObjectId::from_bytes(format, digest).ok()?;
(Some(usize::try_from(entries).ok()?), Some(id))
};
records.push(Record {
name,
entries,
id,
subtrees,
});
if let Some(remaining) = open.last_mut() {
*remaining = remaining.checked_sub(1)?;
}
open.push(subtrees);
while open.last() == Some(&0) {
open.pop();
}
if open.is_empty() {
return rest.is_empty().then_some(records);
}
}
}
fn split_at_byte(bytes: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> {
let position = bytes.iter().position(|b| *b == delimiter)?;
let (before, after) = bytes.split_at_checked(position)?;
Some((before, after.get(1..)?))
}
fn children(records: &[Record<'_>]) -> Vec<Vec<usize>> {
let mut children = vec![Vec::new(); records.len()];
let mut open: Vec<(usize, usize)> = Vec::new();
for (position, record) in records.iter().enumerate() {
if let Some((parent, remaining)) = open.last_mut() {
if let Some(list) = children.get_mut(*parent) {
list.push(position);
}
*remaining = remaining.saturating_sub(1);
}
open.push((position, record.subtrees));
while open.last().is_some_and(|(_, remaining)| *remaining == 0) {
open.pop();
}
}
children
}
fn decimal(bytes: &[u8], signed: bool) -> Option<i64> {
if signed && bytes == b"-1" {
return Some(-1);
}
if bytes.is_empty() || bytes.len() > 18 || !bytes.iter().all(u8::is_ascii_digit) {
return None;
}
std::str::from_utf8(bytes).ok()?.parse().ok()
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::ObjectFormat;
const FORMAT: ObjectFormat = ObjectFormat::Sha1;
fn id(byte: u8) -> ObjectId {
ObjectId::from_bytes(FORMAT, &[byte; 20]).unwrap()
}
fn entry(path: &str) -> Entry {
Entry::new(path.as_bytes().to_vec(), Mode::Regular, id(0xee))
}
fn record(name: &str, entries: i64, subtrees: usize, tree: u8) -> Vec<u8> {
let mut bytes = format!("{name}\0{entries} {subtrees}\n").into_bytes();
if entries >= 0 {
bytes.extend_from_slice(&[tree; 20]);
}
bytes
}
fn entries() -> Vec<Entry> {
["a-x/1", "a.txt", "a/2", "a/z/1", "ab/1", "b/1"]
.into_iter()
.map(entry)
.collect()
}
#[test]
fn invalid_root_yields_its_valid_subtrees() {
let data = [
record("", -1, 4, 0),
record("a", 2, 1, 1),
record("z", 1, 0, 2),
record("b", 1, 0, 3),
record("ab", -1, 0, 0),
record("a-x", 1, 0, 4),
]
.concat();
assert_eq!(
claims(FORMAT, &data, &entries()),
[
Claim {
start: 0,
end: 1,
id: id(4)
},
Claim {
start: 2,
end: 4,
id: id(1)
},
Claim {
start: 5,
end: 6,
id: id(3)
},
]
);
}
#[test]
fn valid_root_claims_every_entry() {
let data = [record("", 6, 0, 9)].concat();
assert_eq!(
claims(FORMAT, &data, &entries()),
[Claim {
start: 0,
end: 6,
id: id(9)
}]
);
}
#[rstest]
#[case::count_mismatch([record("", 5, 0, 9)].concat())]
#[case::truncated_digest(b"\x006 0\n\x01\x02".to_vec())]
#[case::trailing_bytes([record("", 6, 0, 9), b"x".to_vec()].concat())]
#[case::missing_children([record("", -1, 2, 0), record("a", 2, 0, 1)].concat())]
#[case::named_root([record("r", 6, 0, 9)].concat())]
#[case::slash_in_name([record("", -1, 1, 0), record("a/z", 1, 0, 2)].concat())]
#[case::sign([record("", -1, 1, 0), b"a\0+2 0\n".to_vec(), vec![1; 20]].concat())]
fn unusable_records_claim_nothing(#[case] data: Vec<u8>) {
assert_eq!(claims(FORMAT, &data, &entries()), []);
}
#[test]
fn conflicted_or_intent_to_add_ranges_are_not_claimed() {
let mut entries = entries();
entries[2].intent_to_add = true;
let data = [record("", 6, 1, 9), record("a", 2, 0, 1)].concat();
assert_eq!(claims(FORMAT, &data, &entries), []);
}
}