use super::{Digest as _, Sha256, Sha256Digest};
use std::{
fmt,
path::{Component, PathBuf},
};
pub fn checksum_relative_files(
mut files: Vec<(PathBuf, Sha256Digest)>,
) -> Result<Sha256Digest, DirectoryChecksumError> {
files.sort_by(|left, right| left.0.cmp(&right.0));
let mut hasher = Sha256::new();
let mut previous = None;
for (relative, checksum) in &files {
let name = relative
.to_str()
.ok_or_else(|| DirectoryChecksumError::NonUtf8Path {
path: relative.clone(),
})?;
if name.contains('\0')
|| name.split('/').any(|part| matches!(part, "" | "." | ".."))
|| !relative
.components()
.all(|part| matches!(part, Component::Normal(_)))
{
return Err(DirectoryChecksumError::InvalidRelativePath {
path: relative.clone(),
});
}
if previous == Some(relative) {
return Err(DirectoryChecksumError::DuplicatePath {
path: relative.clone(),
});
}
previous = Some(relative);
hasher.update(name.as_bytes());
hasher.update([0]);
hasher.update(checksum.hex_bytes());
hasher.update(b"\n");
}
Ok(Sha256Digest::from_bytes(hasher.finalize().into()))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DirectoryChecksumError {
NonUtf8Path {
path: PathBuf,
},
InvalidRelativePath {
path: PathBuf,
},
DuplicatePath {
path: PathBuf,
},
}
impl fmt::Display for DirectoryChecksumError {
#[expect(
clippy::unnecessary_debug_formatting,
reason = "retain escaped exact path identities, including control characters and non-UTF-8 bytes"
)]
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NonUtf8Path { path } => {
write!(formatter, "directory checksum path is not UTF-8: {path:?}")
}
Self::InvalidRelativePath { path } => write!(
formatter,
"directory checksum path is not canonical and relative: {path:?}"
),
Self::DuplicatePath { path } => {
write!(formatter, "duplicate directory checksum path: {path:?}")
}
}
}
}
impl std::error::Error for DirectoryChecksumError {}
#[cfg(test)]
mod tests;