use super::*;
#[derive(Clone, Copy)]
enum Checksum {
Sha1,
Sha256,
Zero20,
Zero32,
}
#[derive(Clone)]
struct Built {
path: &'static str,
mode: u32,
flags: u16,
size: u32,
mtime_seconds: u32,
mtime_nanos: u32,
}
fn regular(path: &'static str) -> Built {
return Built { path, mode: 0o100_644, flags: 0, size: 10, mtime_seconds: 100, mtime_nanos: 7 };
}
fn fresh() -> StatFields {
return StatFields { ctime_seconds: 900, ctime_nanos: 901, mtime_seconds: 100, mtime_nanos: 7, dev: 5, ino: 6, uid: 1000, gid: 1001, size: 10 };
}
fn entry_bytes(entry: &Built, hash_length: usize) -> Vec<u8> {
let mut bytes: Vec<u8> = Vec::new();
for value in [1u32, 2, entry.mtime_seconds, entry.mtime_nanos, 3, 4, entry.mode, 8, 9, entry.size] {
bytes.extend_from_slice(&value.to_be_bytes());
}
bytes.extend(std::iter::repeat_n(0xab, hash_length));
bytes.extend_from_slice(&entry.flags.to_be_bytes());
if entry.flags & 0x4000 != 0 {
bytes.extend_from_slice(&0u16.to_be_bytes());
}
return bytes;
}
fn build(version: u32, entries: &[Built], extensions: &[(&[u8; 4], Vec<u8>)], checksum: Checksum) -> Vec<u8> {
let hash_length: usize = if matches!(checksum, Checksum::Sha256 | Checksum::Zero32) { 32 } else { 20 };
let mut bytes: Vec<u8> = b"DIRC".to_vec();
bytes.extend_from_slice(&version.to_be_bytes());
bytes.extend_from_slice(&u32::try_from(entries.len()).expect("count").to_be_bytes());
let mut previous: Vec<u8> = Vec::new();
for entry in entries {
let start: usize = bytes.len();
bytes.extend(entry_bytes(entry, hash_length));
let path: &[u8] = entry.path.as_bytes();
if version == 4 {
let common: usize = previous.iter().zip(path).take_while(|(left, right)| { return left == right; }).count();
let strip: usize = previous.len() - common;
assert!(strip < 0x80, "the built paths strip less than a varint byte");
bytes.push(u8::try_from(strip).expect("strip"));
bytes.extend_from_slice(&path[common..]);
bytes.push(0);
previous = path.to_vec();
} else {
bytes.extend_from_slice(path);
bytes.push(0);
while (bytes.len() - start) % 8 != 0 {
bytes.push(0);
}
}
}
for (signature, payload) in extensions {
bytes.extend_from_slice(signature.as_slice());
bytes.extend_from_slice(&u32::try_from(payload.len()).expect("size").to_be_bytes());
bytes.extend_from_slice(payload.as_slice());
}
match checksum {
Checksum::Sha1 => bytes.extend_from_slice(&sha1(bytes.as_slice())),
Checksum::Sha256 => bytes.extend_from_slice(&sha256(bytes.as_slice())),
Checksum::Zero20 => bytes.extend_from_slice(&[0u8; 20]),
Checksum::Zero32 => bytes.extend_from_slice(&[0u8; 32]),
}
return bytes;
}
fn stored(bytes: &[u8], hash_length: usize) -> Vec<(Vec<u8>, [u32; 9])> {
let (entries, _end) = walk_entries(bytes, hash_length).expect("walk");
return entries
.iter()
.map(|entry: &Located| {
let field = |index: usize| -> u32 { return be32(bytes, entry.start + index * 4).expect("field"); };
return (entry.path.clone(), [field(0), field(1), field(2), field(3), field(4), field(5), field(7), field(8), field(9)]);
})
.collect();
}
const REWRITTEN: [u32; 9] = [900, 901, 100, 7, 5, 6, 1000, 1001, 10];
const UNTOUCHED: [u32; 9] = [1, 2, 100, 7, 3, 4, 8, 9, 10];
fn rewritten(outcome: IndexOutcome) -> (Vec<u8>, usize, usize) {
let IndexOutcome::Rewritten { bytes, updated, total } = outcome else { panic!("the index was skipped: {outcome:?}") };
return (bytes, updated, total);
}
fn skipped(outcome: IndexOutcome) -> String {
let IndexOutcome::Skipped(reason) = outcome else { panic!("the index was rewritten") };
return reason;
}
#[test]
fn a_version_2_index_gets_fresh_stat_fields_and_a_valid_checksum() {
let input: Vec<u8> = build(2, &[regular("README.md"), regular("src/main.rs")], &[(b"TREE", vec![1, 2, 3])], Checksum::Sha1);
let (output, updated, total) = rewritten(rewrite_index_stat(input.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); }));
assert_eq!((updated, total), (2, 2));
assert_eq!(stored(output.as_slice(), 20), vec![(b"README.md".to_vec(), REWRITTEN), (b"src/main.rs".to_vec(), REWRITTEN)]);
assert_eq!(output.len(), input.len(), "entry and extension lengths never change");
assert_eq!(&output[output.len() - 20..], sha1(&output[..output.len() - 20]).as_slice());
assert!(output.windows(4).any(|window: &[u8]| { return window == b"TREE"; }), "the cache-tree extension is kept");
}
#[test]
fn only_entries_whose_file_still_matches_are_rewritten() {
let mut stage: Built = regular("conflict");
stage.flags = 0x1000;
let mut empty: Built = regular("empty");
empty.size = 0;
let mut directory: Built = regular("gitlink");
directory.mode = 0o160_000;
let mut link: Built = regular("symlink");
link.mode = 0o120_000;
let mut plain_time: Built = regular("plain-time");
plain_time.mtime_nanos = 0;
let entries: Vec<Built> = vec![regular("missing"), regular("bigger"), regular("later"), stage, empty, directory, link, plain_time];
let input: Vec<u8> = build(2, entries.as_slice(), &[], Checksum::Sha1);
let mut lookup = |path: &[u8]| -> Option<StatFields> {
return match path {
b"missing" => None,
b"bigger" => Some(StatFields { size: 11, ..fresh() }),
b"later" => Some(StatFields { mtime_nanos: 8, ..fresh() }),
b"plain-time" => Some(StatFields { mtime_nanos: 123_456, ..fresh() }),
_ => Some(fresh()),
};
};
let (output, updated, total) = rewritten(rewrite_index_stat(input.as_slice(), &mut lookup));
assert_eq!((updated, total), (2, 8));
for (path, fields) in stored(output.as_slice(), 20) {
let expected: [u32; 9] = match path.as_slice() {
b"symlink" => REWRITTEN,
b"plain-time" => [900, 901, 100, 0, 5, 6, 1000, 1001, 10],
b"empty" => [1, 2, 100, 7, 3, 4, 8, 9, 0],
_ => UNTOUCHED,
};
assert_eq!(fields, expected, "{}", String::from_utf8_lossy(path.as_slice()));
}
}
#[test]
fn extended_flags_belong_to_version_3_only() {
let mut extended: Built = regular("intent");
extended.flags = 0x4000;
let v3: Vec<u8> = build(3, &[extended.clone(), regular("after")], &[], Checksum::Sha1);
let (output, updated, _total) = rewritten(rewrite_index_stat(v3.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); }));
assert_eq!(updated, 2);
assert_eq!(stored(output.as_slice(), 20).len(), 2);
let v2: Vec<u8> = build(2, &[extended], &[], Checksum::Sha1);
assert!(skipped(rewrite_index_stat(v2.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); })).contains("documented layout"));
}
#[test]
fn version_4_paths_are_expanded_before_the_lookup() {
let input: Vec<u8> = build(4, &[regular("dir/alpha"), regular("dir/beta"), regular("other")], &[], Checksum::Sha1);
let mut asked: Vec<Vec<u8>> = Vec::new();
let (output, updated, _total) = rewritten(rewrite_index_stat(input.as_slice(), &mut |path: &[u8]| {
asked.push(path.to_vec());
return Some(fresh());
}));
assert_eq!(asked, vec![b"dir/alpha".to_vec(), b"dir/beta".to_vec(), b"other".to_vec()]);
assert_eq!(updated, 3);
assert_eq!(output.len(), input.len());
}
#[test]
fn both_hash_lengths_and_the_zero_checksum_are_kept() {
let wide: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Sha256);
let (output, updated, _total) = rewritten(rewrite_index_stat(wide.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); }));
assert_eq!(updated, 1);
assert_eq!(&output[output.len() - 32..], sha256(&output[..output.len() - 32]).as_slice());
assert_eq!(stored(output.as_slice(), 32), vec![(b"a".to_vec(), REWRITTEN)]);
let zero: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Zero20);
let (kept_zero, zero_updated, _zero_total) = rewritten(rewrite_index_stat(zero.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); }));
assert_eq!(zero_updated, 1);
assert_eq!(&kept_zero[kept_zero.len() - 20..], &[0u8; 20]);
}
#[test]
fn derived_extensions_are_dropped_and_the_others_kept() {
let extensions: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"TREE", vec![1]),
(b"UNTR", vec![2, 2]),
(b"REUC", vec![3]),
(b"FSMN", vec![4]),
(b"EOIE", vec![5; 24]),
(b"IEOT", vec![6; 8]),
];
let input: Vec<u8> = build(2, &[regular("a")], extensions.as_slice(), Checksum::Sha1);
let (output, _updated, _total) = rewritten(rewrite_index_stat(input.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); }));
let tail: &[u8] = &output[HEADER + 64..output.len() - 20];
let signatures: Vec<&[u8]> = vec![&tail[..4], &tail[9..13]];
assert_eq!(signatures, vec![b"TREE".as_slice(), b"REUC".as_slice()]);
assert_eq!(tail.len(), 9 + 9);
assert_eq!(&output[output.len() - 20..], sha1(&output[..output.len() - 20]).as_slice());
}
#[test]
fn split_and_sparse_indexes_are_declined() {
for signature in [b"link", b"sdir"] {
let input: Vec<u8> = build(2, &[regular("a")], &[(signature, vec![0])], Checksum::Sha1);
let reason: String = skipped(rewrite_index_stat(input.as_slice(), &mut |_path: &[u8]| { return Some(fresh()); }));
assert!(reason.contains(String::from_utf8_lossy(signature).as_ref()), "{reason}");
}
}
#[test]
fn malformed_files_are_declined_with_a_reason() {
let good: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Sha1);
let lookup = |_path: &[u8]| -> Option<StatFields> { return Some(fresh()); };
let mut not_an_index: Vec<u8> = good.clone();
not_an_index[0] = b'X';
assert!(skipped(rewrite_index_stat(not_an_index.as_slice(), &mut { lookup })).contains("no DIRC signature"));
assert!(skipped(rewrite_index_stat(b"DIR", &mut { lookup })).contains("no DIRC signature"));
for version in [1u32, 5] {
let mut wrong: Vec<u8> = good.clone();
wrong[4..8].copy_from_slice(&version.to_be_bytes());
assert!(skipped(rewrite_index_stat(wrong.as_slice(), &mut { lookup })).contains(format!("version {version}").as_str()));
}
let mut corrupt: Vec<u8> = good.clone();
corrupt[20] ^= 1;
assert!(skipped(rewrite_index_stat(corrupt.as_slice(), &mut { lookup })).contains("checksum matches neither"));
let mut cut_entry: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Zero20);
cut_entry[8..12].copy_from_slice(&5u32.to_be_bytes());
assert!(skipped(rewrite_index_stat(cut_entry.as_slice(), &mut { lookup })).contains("checksum matches neither"));
let zero_with_cut_extension: Vec<u8> = {
let mut bytes: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Zero20);
let at: usize = bytes.len() - 20;
bytes.splice(at..at, *b"TREE\0\0\0");
bytes
};
assert!(skipped(rewrite_index_stat(zero_with_cut_extension.as_slice(), &mut { lookup })).contains("checksum matches neither"));
let with_checksum = |extension: Vec<u8>| -> Vec<u8> {
let mut bytes: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Sha1);
bytes.truncate(bytes.len() - 20);
bytes.extend_from_slice(extension.as_slice());
let digest: [u8; 20] = sha1(bytes.as_slice());
bytes.extend_from_slice(&digest);
return bytes;
};
let cut_extension: Vec<u8> = with_checksum(b"TREE\0\0\0".to_vec());
assert!(skipped(rewrite_index_stat(cut_extension.as_slice(), &mut { lookup })).contains("an extension header is cut off") || skipped(rewrite_index_stat(cut_extension.as_slice(), &mut { lookup })).contains("runs past the end"));
let long_extension: Vec<u8> = {
let mut extension: Vec<u8> = b"TREE".to_vec();
extension.extend_from_slice(&1000u32.to_be_bytes());
with_checksum(extension)
};
assert!(skipped(rewrite_index_stat(long_extension.as_slice(), &mut { lookup })).contains("runs past the end"));
}
fn ambiguous_sha256_index() -> Vec<u8> {
let mut bytes: Vec<u8> = b"DIRC".to_vec();
bytes.extend_from_slice(&2u32.to_be_bytes());
bytes.extend_from_slice(&1u32.to_be_bytes());
for value in [1u32, 2, 100, 7, 3, 4, 0o100_644, 8, 9, 10] {
bytes.extend_from_slice(&value.to_be_bytes());
}
bytes.extend_from_slice(&[0u8; 20]);
bytes.extend_from_slice(&[0x00, 0x01, 0x78, 0x00, b'U', b'N', b'T', b'R', 0x00, 0x00, 0x00, 0x14]);
bytes.extend_from_slice(&4u16.to_be_bytes());
bytes.extend_from_slice(b"real\0\0");
bytes.extend_from_slice(&[0u8; 32]);
assert_eq!(bytes.len(), 124);
return bytes;
}
#[test]
fn an_ambiguous_zero_checksum_is_declined_unless_the_repository_hash_is_known() {
let input: Vec<u8> = ambiguous_sha256_index();
let lookup = |_path: &[u8]| -> Option<StatFields> { return Some(fresh()); };
let reason: String = skipped(rewrite_index_stat(input.as_slice(), &mut { lookup }));
assert!(reason.contains("both SHA-1 and SHA-256"), "{reason}");
let known: RewriteContext = RewriteContext { hash_length: Some(32), index_mtime: None };
let (output, updated, total) = rewritten(rewrite_index_stat_with(input.as_slice(), &known, &mut { lookup }, &mut |_path: &[u8], _stored: &StoredStat| { return true; }));
assert_eq!((output.len(), updated, total), (124, 1, 1), "no byte of a SHA-256 index is dropped");
assert_eq!(stored(output.as_slice(), 32)[0].0, b"real".to_vec());
assert_eq!(&output[output.len() - 32..], &[0u8; 32], "an index written without a checksum keeps none");
}
#[test]
fn an_empty_skip_hash_sha256_index_and_a_contradicting_format_are_handled() {
let empty: Vec<u8> = build(2, &[], &[], Checksum::Zero32);
let lookup = |_path: &[u8]| -> Option<StatFields> { return Some(fresh()); };
let (output, _updated, total) = rewritten(rewrite_index_stat(empty.as_slice(), &mut { lookup }));
assert_eq!((output.len(), total), (empty.len(), 0));
let sha1_index: Vec<u8> = build(2, &[regular("a")], &[], Checksum::Sha1);
let wrong: RewriteContext = RewriteContext { hash_length: Some(32), index_mtime: None };
let reason: String = skipped(rewrite_index_stat_with(sha1_index.as_slice(), &wrong, &mut { lookup }, &mut |_path: &[u8], _stored: &StoredStat| { return true; }));
assert!(reason.contains("does not match the repository's hash"), "{reason}");
}
#[test]
fn racy_entries_and_untrusted_entries_keep_their_stat_fields() {
let input: Vec<u8> = build(2, &[regular("README.md")], &[], Checksum::Sha1);
let lookup = |_path: &[u8]| -> Option<StatFields> { return Some(fresh()); };
let always = |_path: &[u8], _stored: &StoredStat| -> bool { return true; };
for (index_seconds, expected) in [(100u32, 0usize), (101, 1), (99, 0)] {
let context: RewriteContext = RewriteContext { hash_length: None, index_mtime: Some((index_seconds, 0)) };
let (output, updated, _total) = rewritten(rewrite_index_stat_with(input.as_slice(), &context, &mut { lookup }, &mut { always }));
assert_eq!(updated, expected, "index time {index_seconds}");
let fields: [u32; 9] = stored(output.as_slice(), 20)[0].1;
assert_eq!(fields, if expected == 1 { REWRITTEN } else { UNTOUCHED }, "index time {index_seconds}");
}
let mut seen: Vec<StoredStat> = Vec::new();
let (_output, updated, _total) = rewritten(rewrite_index_stat_with(input.as_slice(), &RewriteContext::default(), &mut { lookup }, &mut |_path: &[u8], recorded: &StoredStat| {
seen.push(*recorded);
return false;
}));
assert_eq!(updated, 0);
assert_eq!(seen, vec![StoredStat { ctime_seconds: 1, ctime_nanos: 2, mtime_seconds: 100, mtime_nanos: 7, ino: 4, uid: 8, gid: 9, size: 10 }]);
}