use crate::subvolume_sha::{sha1, sha256};
const HEADER: usize = 12;
const STAT: usize = 40;
const DROPPED: [&[u8; 4]; 4] = [b"UNTR", b"FSMN", b"EOIE", b"IEOT"];
const DECLINED: [&[u8; 4]; 2] = [b"link", b"sdir"];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StatFields {
pub ctime_seconds: u32,
pub ctime_nanos: u32,
pub mtime_seconds: u32,
pub mtime_nanos: u32,
pub dev: u32,
pub ino: u32,
pub uid: u32,
pub gid: u32,
pub size: u32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IndexOutcome {
Rewritten {
bytes: Vec<u8>,
updated: usize,
total: usize,
},
Skipped(String),
}
pub(crate) struct Located {
pub(crate) start: usize,
pub(crate) path: Vec<u8>,
pub(crate) flags: u16,
pub(crate) mode: u32,
}
pub(crate) fn be32(bytes: &[u8], at: usize) -> Option<u32> {
let quad: &[u8] = bytes.get(at..at.checked_add(4)?)?;
return Some(u32::from_be_bytes([quad[0], quad[1], quad[2], quad[3]]));
}
fn be16(bytes: &[u8], at: usize) -> Option<u16> {
let pair: &[u8] = bytes.get(at..at.checked_add(2)?)?;
return Some(u16::from_be_bytes([pair[0], pair[1]]));
}
pub(crate) fn walk_entries(bytes: &[u8], hash_length: usize) -> Option<(Vec<Located>, usize)> {
let version: u32 = be32(bytes, 4)?;
let count: usize = usize::try_from(be32(bytes, 8)?).ok()?;
let limit: usize = bytes.len().checked_sub(hash_length)?;
let mut found: Vec<Located> = Vec::with_capacity(count.min(1 << 20));
let mut at: usize = HEADER;
let mut previous: Vec<u8> = Vec::new();
for _ in 0..count {
let mode: u32 = be32(bytes, at.checked_add(24)?)?;
let flags_at: usize = at.checked_add(STAT + hash_length)?;
let flags: u16 = be16(bytes, flags_at)?;
let mut name_at: usize = flags_at + 2;
if flags & 0x4000 != 0 {
if version < 3 {
return None;
}
name_at += 2;
}
let path: Vec<u8>;
let next: usize;
if version == 4 {
let mut cursor: usize = name_at;
let mut byte: u8 = *bytes.get(cursor)?;
cursor += 1;
let mut strip: usize = usize::from(byte & 0x7f);
while byte & 0x80 != 0 {
byte = *bytes.get(cursor)?;
cursor += 1;
strip = strip.checked_add(1)?.checked_shl(7)? | usize::from(byte & 0x7f);
}
let keep: usize = previous.len().checked_sub(strip)?;
let end: usize = cursor + bytes.get(cursor..limit)?.iter().position(|stored: &u8| { return *stored == 0; })?;
let mut joined: Vec<u8> = previous[..keep].to_vec();
joined.extend_from_slice(&bytes[cursor..end]);
path = joined;
next = end + 1;
} else {
let length: usize = bytes.get(name_at..limit)?.iter().position(|stored: &u8| { return *stored == 0; })?;
path = bytes[name_at..name_at + length].to_vec();
next = at.checked_add((name_at - at + length + 8) & !7)?;
}
if next > limit {
return None;
}
previous = path.clone();
found.push(Located { start: at, path, flags, mode });
at = next;
}
return Some((found, at));
}
fn extensions_fit(bytes: &[u8], entries_end: usize, hash_length: usize) -> bool {
let Some(limit) = bytes.len().checked_sub(hash_length) else { return false };
let mut at: usize = entries_end;
while at < limit {
let Some(size) = be32(bytes, at + 4) else { return false };
let Some(end) = at.checked_add(8).and_then(|header_end: usize| { return header_end.checked_add(usize::try_from(size).ok()?); }) else { return false };
if end > limit {
return false;
}
at = end;
}
return at == limit;
}
fn checksum_kind(bytes: &[u8], known: Option<usize>) -> Result<(usize, bool), String> {
let candidates: Vec<usize> = match known {
Some(length) => vec![length],
None => vec![20, 32],
};
for length in &candidates {
let verifies: bool = match length {
20 => bytes.len() >= HEADER + 20 && sha1(&bytes[..bytes.len() - 20]) == bytes[bytes.len() - 20..],
32 => bytes.len() >= HEADER + 32 && sha256(&bytes[..bytes.len() - 32]) == bytes[bytes.len() - 32..],
_ => false,
};
if verifies {
return Ok((*length, false));
}
}
let fitting: Vec<usize> = candidates
.iter()
.copied()
.filter(|length: &usize| {
let zero: bool = bytes.len() >= HEADER + length && bytes[bytes.len() - length..].iter().all(|byte: &u8| { return *byte == 0; });
return zero && walk_entries(bytes, *length).is_some_and(|(_entries, end): (Vec<Located>, usize)| { return extensions_fit(bytes, end, *length); });
})
.collect();
return match fitting.as_slice() {
[length] => Ok((*length, true)),
[] if known.is_some() => Err(String::from("the checksum does not match the repository's hash and is not all zero")),
[] => Err(String::from("the checksum matches neither SHA-1 nor SHA-256, and it is not an all-zero checksum of a file that parses to its end")),
_ => Err(String::from("the checksum is all zero and the file parses as both SHA-1 and SHA-256, and the repository's object format is not known")),
};
}
pub fn rewrite_index_stat(bytes: &[u8], lookup: &mut dyn FnMut(&[u8]) -> Option<StatFields>) -> IndexOutcome {
return rewrite_index_stat_with(bytes, &RewriteContext::default(), lookup, &mut |_path: &[u8], _stored: &StoredStat| { return true; });
}
#[derive(Clone, Copy, Debug, Default)]
pub struct RewriteContext {
pub hash_length: Option<usize>,
pub index_mtime: Option<(u32, u32)>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StoredStat {
pub ctime_seconds: u32,
pub ctime_nanos: u32,
pub mtime_seconds: u32,
pub mtime_nanos: u32,
pub ino: u32,
pub uid: u32,
pub gid: u32,
pub size: u32,
}
pub fn rewrite_index_stat_with(
bytes: &[u8],
context: &RewriteContext,
lookup: &mut dyn FnMut(&[u8]) -> Option<StatFields>,
trusted: &mut dyn FnMut(&[u8], &StoredStat) -> bool,
) -> IndexOutcome {
if bytes.len() < HEADER || &bytes[..4] != b"DIRC" {
return IndexOutcome::Skipped(String::from("the file is not an index (no DIRC signature)"));
}
let version: u32 = be32(bytes, 4).unwrap_or(0);
if !(2..=4).contains(&version) {
return IndexOutcome::Skipped(format!("index version {version} is not 2, 3 or 4"));
}
let (hash_length, zero_checksum): (usize, bool) = match checksum_kind(bytes, context.hash_length) {
Ok(found) => found,
Err(reason) => return IndexOutcome::Skipped(reason),
};
let Some((entries, entries_end)) = walk_entries(bytes, hash_length) else {
return IndexOutcome::Skipped(String::from("the entries do not have the documented layout"));
};
let limit: usize = bytes.len() - hash_length;
let mut kept: Vec<u8> = Vec::new();
let mut at: usize = entries_end;
while at < limit {
let (Some(size), Some(signature)) = (be32(bytes, at + 4), bytes.get(at..at + 4)) else {
return IndexOutcome::Skipped(String::from("an extension header is cut off"));
};
let end: usize = at + 8 + usize::try_from(size).unwrap_or(usize::MAX);
if end > limit {
return IndexOutcome::Skipped(String::from("an extension runs past the end of the file"));
}
if DECLINED.iter().any(|name: &&[u8; 4]| { return signature == name.as_slice(); }) {
return IndexOutcome::Skipped(format!("the index uses the {} extension (split or sparse index)", String::from_utf8_lossy(signature)));
}
if !DROPPED.iter().any(|name: &&[u8; 4]| { return signature == name.as_slice(); }) {
kept.extend_from_slice(&bytes[at..end]);
}
at = end;
}
let mut output: Vec<u8> = bytes[..entries_end].to_vec();
let mut updated: usize = 0;
for entry in &entries {
let regular: bool = matches!(entry.mode & 0o170_000, 0o100_000 | 0o120_000);
let resolved: bool = (entry.flags >> 12) & 3 == 0;
let stored_size: u32 = be32(bytes, entry.start + 36).unwrap_or(0);
if !regular || !resolved || stored_size == 0 {
continue;
}
let stored_seconds: u32 = be32(bytes, entry.start + 8).unwrap_or(0);
let stored_nanos: u32 = be32(bytes, entry.start + 12).unwrap_or(0);
if context.index_mtime.is_some_and(|(index_seconds, _nanos): (u32, u32)| { return stored_seconds >= index_seconds; }) {
continue;
}
let Some(now) = lookup(entry.path.as_slice()) else { continue };
let same_time: bool = now.mtime_seconds == stored_seconds && (stored_nanos == 0 || stored_nanos == now.mtime_nanos);
if now.size != stored_size || !same_time {
continue;
}
let recorded: StoredStat = StoredStat {
ctime_seconds: be32(bytes, entry.start).unwrap_or(0),
ctime_nanos: be32(bytes, entry.start + 4).unwrap_or(0),
mtime_seconds: stored_seconds,
mtime_nanos: stored_nanos,
ino: be32(bytes, entry.start + 20).unwrap_or(0),
uid: be32(bytes, entry.start + 28).unwrap_or(0),
gid: be32(bytes, entry.start + 32).unwrap_or(0),
size: stored_size,
};
if !trusted(entry.path.as_slice(), &recorded) {
continue;
}
let fields: [(usize, u32); 6] = [(0, now.ctime_seconds), (4, now.ctime_nanos), (16, now.dev), (20, now.ino), (28, now.uid), (32, now.gid)];
for (offset, value) in fields {
output[entry.start + offset..entry.start + offset + 4].copy_from_slice(&value.to_be_bytes());
}
updated += 1;
}
output.extend_from_slice(kept.as_slice());
if zero_checksum {
output.extend(std::iter::repeat_n(0u8, hash_length));
} else if hash_length == 20 {
let digest: [u8; 20] = sha1(output.as_slice());
output.extend_from_slice(&digest);
} else {
let digest: [u8; 32] = sha256(output.as_slice());
output.extend_from_slice(&digest);
}
return IndexOutcome::Rewritten { bytes: output, updated, total: entries.len() };
}
#[cfg(test)]
#[path = "subvolume_index_tests.rs"]
mod tests;