use rstest::rstest;
use super::{Error, Limits, LogRecord, LogValue, RefRecord, Table};
use crate::refs::{RefName, Target};
use crate::{ObjectFormat, ObjectId};
fn table(format: ObjectFormat) -> Table {
let id = ObjectId::from_bytes(format, &vec![1; format.digest_len()]).unwrap();
Table {
format,
min_update_index: 1,
max_update_index: 2,
references: vec![
RefRecord {
name: name(b"HEAD").into(),
update_index: 1,
target: Some(Target::Symbolic(name(b"refs/heads/main"))),
peeled: None,
},
RefRecord {
name: name(b"refs/deleted").into(),
update_index: 2,
target: None,
peeled: None,
},
RefRecord {
name: name(b"refs/heads/main").into(),
update_index: 2,
target: Some(Target::Direct(id)),
peeled: Some(id),
},
],
logs: vec![
LogRecord {
name: name(b"HEAD").into(),
update_index: 2,
value: Some(LogValue {
old: id,
new: id,
name: b"\xff name ".to_vec(),
email: b"a@b".to_vec(),
seconds: u64::MAX,
offset_minutes: -1439,
message: b"raw\0\r\n bytes".to_vec(),
}),
},
LogRecord {
name: name(b"HEAD").into(),
update_index: 1,
value: None,
},
],
}
}
fn name(bytes: &[u8]) -> RefName {
RefName::new(bytes).unwrap()
}
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn retains_binary_records_and_tombstones(#[case] format: ObjectFormat) {
let table = table(format);
let encoded = table.encode(Limits::default()).unwrap();
assert_eq!(Table::decode(&encoded, Limits::default()).unwrap(), table);
}
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn roundtrips_empty_table(#[case] format: ObjectFormat) {
let mut table = table(format);
table.references.clear();
table.logs.clear();
let encoded = table.encode(Limits::default()).unwrap();
assert_eq!(Table::decode(&encoded, Limits::default()).unwrap(), table);
}
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn roundtrips_log_only_table(#[case] format: ObjectFormat) {
let mut table = table(format);
table.references.clear();
let encoded = table.encode(Limits::default()).unwrap();
assert_eq!(Table::decode(&encoded, Limits::default()).unwrap(), table);
}
#[rstest]
#[case::header(0)]
#[case::footer(200)]
fn rejects_changed_checksum_or_magic(#[case] offset: usize) {
let mut bytes = table(ObjectFormat::Sha1).encode(Limits::default()).unwrap();
bytes[offset] ^= 1;
assert!(Table::decode(&bytes, Limits::default()).is_err());
}
#[test]
fn rejects_every_truncated_prefix() {
let bytes = table(ObjectFormat::Sha256)
.encode(Limits::default())
.unwrap();
assert_all_prefixes_fail(&bytes);
}
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn decodes_every_damaged_block_byte_without_panicking(#[case] format: ObjectFormat) {
let bytes = table(format).encode(Limits::default()).unwrap();
for position in 0..bytes.len() {
for value in [0x00, 0x01, 0x7f, 0x80, 0xff] {
let mut damaged = bytes.clone();
damaged[position] = value;
let _ = Table::decode(&damaged, Limits::default());
}
}
}
fn assert_all_prefixes_fail(bytes: &[u8]) {
for length in 0..bytes.len() {
assert!(
Table::decode(&bytes[..length], Limits::default()).is_err(),
"prefix {length}"
);
}
}
#[rstest]
#[case::bytes(Limits { bytes: 0, ..Limits::default() })]
#[case::block(Limits { block_bytes: 1, ..Limits::default() })]
#[case::records(Limits { records: 1, ..Limits::default() })]
#[case::strings(Limits { string_bytes: 1, ..Limits::default() })]
#[case::decoded(Limits { decoded_bytes: 1, ..Limits::default() })]
fn refuses_resource_limits(#[case] limits: Limits) {
let table = table(ObjectFormat::Sha1);
let bytes = table.encode(Limits::default()).unwrap();
assert!(Table::decode(&bytes, limits).is_err());
assert!(table.encode(limits).is_err());
}
#[test]
fn rejects_duplicate_reference_keys() {
let mut table = table(ObjectFormat::Sha1);
table.references.insert(1, table.references[0].clone());
assert!(matches!(
table.encode(Limits::default()),
Err(Error::Malformed(_))
));
}
#[test]
fn rejects_mixed_hash_formats() {
let mut table = table(ObjectFormat::Sha1);
table.references[2].target = Some(Target::Direct(ObjectId::Sha256([1; 32])));
assert!(matches!(
table.encode(Limits::default()),
Err(Error::Malformed(_))
));
}
#[rstest]
#[case::prefix(28, 1)]
#[case::reserved_value(29, 36)]
#[case::update_range(34, 127)]
#[case::block_length(27, 1)]
fn rejects_corrupt_reference_framing(#[case] offset: usize, #[case] value: u8) {
let mut bytes = table(ObjectFormat::Sha1).encode(Limits::default()).unwrap();
bytes[offset] = value;
assert!(Table::decode(&bytes, Limits::default()).is_err());
}
#[test]
fn rejects_corrupt_restart_pointer() {
let mut bytes = table(ObjectFormat::Sha1).encode(Limits::default()).unwrap();
let block_len = u32::from_be_bytes([0, bytes[25], bytes[26], bytes[27]]) as usize;
let count = u16::from_be_bytes(bytes[block_len - 2..block_len].try_into().unwrap()) as usize;
bytes[block_len - 2 - count * 3 + 2] += 1;
assert!(Table::decode(&bytes, Limits::default()).is_err());
}
#[test]
fn rejects_corrupt_compressed_log() {
let mut bytes = table(ObjectFormat::Sha1).encode(Limits::default()).unwrap();
let footer = bytes.len() - 68;
let log = u64::from_be_bytes(bytes[footer + 48..footer + 56].try_into().unwrap()) as usize;
bytes[log + 4] = 0;
assert!(Table::decode(&bytes, Limits::default()).is_err());
}
fn first_block_end(bytes: &[u8], format: ObjectFormat) -> usize {
let header = if format == ObjectFormat::Sha1 { 24 } else { 28 };
assert_eq!(bytes[header], b'r');
let length = &bytes[header + 1..header + 4];
usize::from(length[0]) << 16 | usize::from(length[1]) << 8 | usize::from(length[2])
}
#[rstest]
#[case::sha1_empty_restarts(ObjectFormat::Sha1, "count", "empty restart table")]
#[case::sha1_restart_offset(ObjectFormat::Sha1, "offset", "restart offsets")]
#[case::sha256_empty_restarts(ObjectFormat::Sha256, "count", "empty restart table")]
#[case::sha256_restart_offset(ObjectFormat::Sha256, "offset", "restart offsets")]
fn rejects_damaged_restart_table(
#[case] format: ObjectFormat,
#[case] damage: &str,
#[case] reason: &str,
) {
let mut bytes = table(format).encode(Limits::default()).unwrap();
let end = first_block_end(&bytes, format);
let count = usize::from(u16::from_be_bytes([bytes[end - 2], bytes[end - 1]]));
match damage {
"count" => bytes[end - 2..end].copy_from_slice(&[0, 0]),
_ => bytes[end - 2 - 3 * count..end - 2 - 3 * count + 3].copy_from_slice(&[0, 0, 0]),
}
assert!(matches!(
Table::decode(&bytes, Limits::default()),
Err(Error::Malformed(actual)) if actual == reason
));
}