use std::io::Read;
use camino::Utf8Path;
use digest::Digest;
#[cfg(feature = "legacy-digests")]
use md5::Md5;
use minijinja::{Error, ErrorKind};
#[cfg(feature = "legacy-digests")]
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use super::fs_utils;
use crate::hex::to_lower_hex;
use crate::localization::{self, keys};
use crate::stdlib::io_helpers::io_to_error;
pub(super) fn compute_hash(path: &Utf8Path, alg: &str) -> Result<String, Error> {
if alg.eq_ignore_ascii_case("sha256") {
hash_stream::<Sha256>(path)
} else if alg.eq_ignore_ascii_case("sha512") {
hash_stream::<Sha512>(path)
} else if alg.eq_ignore_ascii_case("sha1") {
#[cfg(feature = "legacy-digests")]
{
hash_stream::<Sha1>(path)
}
#[cfg(not(feature = "legacy-digests"))]
{
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM_LEGACY)
.with_arg("algorithm", "sha1")
.with_arg("feature", "legacy-digests")
.to_string(),
))
}
} else if alg.eq_ignore_ascii_case("md5") {
#[cfg(feature = "legacy-digests")]
{
hash_stream::<Md5>(path)
}
#[cfg(not(feature = "legacy-digests"))]
{
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM_LEGACY)
.with_arg("algorithm", "md5")
.with_arg("feature", "legacy-digests")
.to_string(),
))
}
} else {
Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM)
.with_arg("algorithm", alg)
.to_string(),
))
}
}
pub(super) fn compute_digest(path: &Utf8Path, len: usize, alg: &str) -> Result<String, Error> {
let mut hash = compute_hash(path, alg)?;
if len < hash.len() {
hash.truncate(len);
}
Ok(hash)
}
fn hash_stream<H>(path: &Utf8Path) -> Result<String, Error>
where
H: Digest,
{
let mut file = fs_utils::open_file(path)?;
let mut hasher = H::new();
let mut buffer = [0_u8; 8192];
loop {
let read = file.read(&mut buffer).map_err(|err| {
io_to_error(
path,
&localization::message(keys::STDLIB_PATH_ACTION_READ),
err,
)
})?;
if read == 0 {
break;
}
let chunk = if let Some(chunk) = buffer.get(..read) {
chunk
} else {
tracing::debug!(
read,
capacity = buffer.len(),
"Read reported more bytes than the buffer holds; clamping to full buffer"
);
&buffer
};
hasher.update(chunk);
}
Ok(to_lower_hex(&hasher.finalize()))
}
#[cfg(test)]
mod tests {
use anyhow::{Result, anyhow, ensure};
use camino::Utf8PathBuf;
use cap_std::{ambient_authority, fs_utf8::Dir};
use rstest::rstest;
use sha2::{Digest, Sha256};
use tempfile::TempDir;
use super::{compute_hash, to_lower_hex};
const FIXTURE_NAME: &str = "payload";
fn fixture(payload: &[u8]) -> Result<(TempDir, Utf8PathBuf)> {
let dir = tempfile::tempdir()?;
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.map_err(|path| anyhow!("temporary path is not valid UTF-8: {path:?}"))?;
let handle = Dir::open_ambient_dir(&root, ambient_authority())?;
handle.write(FIXTURE_NAME, payload)?;
Ok((dir, root.join(FIXTURE_NAME)))
}
fn patterned(size: usize) -> Vec<u8> {
(0..=u8::MAX).cycle().take(size).collect()
}
#[rstest]
#[case::empty(0)]
#[case::single_read(4)]
#[case::exactly_one_buffer(8192)]
#[case::spans_two_reads(8193)]
#[case::spans_several_reads(70_000)]
fn streamed_digest_matches_a_one_shot_digest(#[case] size: usize) -> Result<()> {
let payload = patterned(size);
let (_dir, file) = fixture(&payload)?;
let streamed = compute_hash(&file, "sha256")?;
let one_shot = to_lower_hex(&Sha256::digest(&payload));
ensure!(
streamed == one_shot,
"streamed digest of {size} bytes was {streamed} but a one-shot digest is {one_shot}"
);
Ok(())
}
#[rstest]
fn streamed_digest_matches_the_known_vector_for_abc() -> Result<()> {
let (_dir, file) = fixture(b"abc")?;
let expected = concat!(
"ba7816bf8f01cfea414140de5dae2223",
"b00361a396177a9cb410ff61f20015ad",
);
let digest = compute_hash(&file, "sha256")?;
ensure!(
digest == expected,
"expected the published digest {expected} but streamed {digest}"
);
Ok(())
}
mod properties {
use proptest::prelude::*;
use sha2::{Digest, Sha256};
use super::{compute_hash, fixture, to_lower_hex};
proptest! {
#[test]
fn streamed_digest_matches_a_one_shot_digest_for_any_length(
payload in prop::collection::vec(any::<u8>(), 0..20_000),
) {
let (_dir, file) = fixture(&payload).expect("stage the payload");
let streamed = compute_hash(&file, "sha256").expect("hash the payload");
let one_shot = to_lower_hex(&Sha256::digest(&payload));
prop_assert_eq!(streamed, one_shot);
}
}
}
}