252 lines
6.7 KiB
Rust
252 lines
6.7 KiB
Rust
use std::fmt::Write;
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
use digest::Digest;
|
|
|
|
use crate::{IOError, PolyIOResult};
|
|
|
|
/// Upper bound only smaller files get a buffer sized to fit
|
|
const MAX_HASH_BUFFER: usize = 256 * 1024;
|
|
|
|
pub struct Sha1Stream(sha1::Sha1);
|
|
|
|
impl Sha1Stream {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self(sha1::Sha1::new())
|
|
}
|
|
|
|
pub fn update(&mut self, data: &[u8]) {
|
|
Digest::update(&mut self.0, data);
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn finish(self) -> String {
|
|
to_hex(&self.0.finalize())
|
|
}
|
|
}
|
|
|
|
impl Default for Sha1Stream {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Runs on the blocking pool one dispatch for the whole file keeping the
|
|
/// digest off the async workers driving concurrent downloads
|
|
pub async fn sha1_file(path: impl AsRef<Path>) -> PolyIOResult<String> {
|
|
let path = path.as_ref().to_path_buf();
|
|
tokio::task::spawn_blocking(move || sha1_file_sync(&path))
|
|
.await
|
|
.map_err(std::io::Error::other)?
|
|
}
|
|
|
|
pub fn sha1_file_sync(path: &Path) -> PolyIOResult<String> {
|
|
let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})?;
|
|
let mut hasher = Sha1Stream::new();
|
|
|
|
let size = file
|
|
.metadata()
|
|
.map(|meta| meta.len())
|
|
.unwrap_or(MAX_HASH_BUFFER as u64);
|
|
let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize;
|
|
let mut buffer = vec![0u8; capacity];
|
|
|
|
loop {
|
|
let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError {
|
|
source: e,
|
|
path: path.to_string_lossy().to_string(),
|
|
})?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buffer[..n]);
|
|
}
|
|
|
|
Ok(hasher.finish())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sha1_bytes(data: &[u8]) -> String {
|
|
let mut hasher = Sha1Stream::new();
|
|
hasher.update(data);
|
|
hasher.finish()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ChecksumAlgorithm {
|
|
Sha1,
|
|
Sha256,
|
|
}
|
|
|
|
impl ChecksumAlgorithm {
|
|
/// Hex length which is how a bare manifest string with no algorithm field
|
|
/// gets identified
|
|
#[must_use]
|
|
pub const fn hex_len(self) -> usize {
|
|
match self {
|
|
Self::Sha1 => 40,
|
|
Self::Sha256 => 64,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Sha1 => "SHA-1",
|
|
Self::Sha256 => "SHA-256",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct Checksum {
|
|
pub algorithm: ChecksumAlgorithm,
|
|
/// Always normalized (trimmed lowercase)
|
|
pub hex: String,
|
|
}
|
|
|
|
impl Checksum {
|
|
#[must_use]
|
|
pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef<str>) -> Self {
|
|
Self {
|
|
algorithm,
|
|
hex: normalize_hash(hex.as_ref()),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sha1(hex: impl AsRef<str>) -> Self {
|
|
Self::new(ChecksumAlgorithm::Sha1, hex)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sha256(hex: impl AsRef<str>) -> Self {
|
|
Self::new(ChecksumAlgorithm::Sha256, hex)
|
|
}
|
|
|
|
/// Whether the hash is the right shape for its algorithm
|
|
/// Callers drop malformed checksums and download unverified
|
|
#[must_use]
|
|
pub fn is_well_formed(&self) -> bool {
|
|
self.hex.len() == self.algorithm.hex_len()
|
|
&& self.hex.bytes().all(|b| b.is_ascii_hexdigit())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn matches(&self, actual: &str) -> bool {
|
|
self.hex == normalize_hash(actual)
|
|
}
|
|
}
|
|
|
|
pub enum ChecksumStream {
|
|
Sha1(sha1::Sha1),
|
|
Sha256(sha2::Sha256),
|
|
}
|
|
|
|
impl ChecksumStream {
|
|
#[must_use]
|
|
pub fn new(algorithm: ChecksumAlgorithm) -> Self {
|
|
match algorithm {
|
|
ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()),
|
|
ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()),
|
|
}
|
|
}
|
|
|
|
pub fn update(&mut self, data: &[u8]) {
|
|
match self {
|
|
Self::Sha1(hasher) => Digest::update(hasher, data),
|
|
Self::Sha256(hasher) => Digest::update(hasher, data),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn finish(self) -> String {
|
|
match self {
|
|
Self::Sha1(hasher) => to_hex(&hasher.finalize()),
|
|
Self::Sha256(hasher) => to_hex(&hasher.finalize()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn normalize_hash(hash: &str) -> String {
|
|
hash.trim().to_ascii_lowercase()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn to_hex(data: &[u8]) -> String {
|
|
data.iter().fold(String::new(), |mut out, b| {
|
|
let _ = write!(out, "{b:02x}");
|
|
out
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sha1_bytes_matches_known_vector() {
|
|
assert_eq!(
|
|
sha1_bytes(b"abc"),
|
|
"a9993e364706816aba3e25717850c26c9cd0d89d"
|
|
);
|
|
assert_eq!(sha1_bytes(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
|
|
}
|
|
|
|
#[test]
|
|
fn stream_and_oneshot_agree() {
|
|
let mut stream = Sha1Stream::new();
|
|
stream.update(b"ab");
|
|
stream.update(b"c");
|
|
assert_eq!(stream.finish(), sha1_bytes(b"abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_hash_trims_and_lowercases() {
|
|
assert_eq!(normalize_hash(" A9B2 "), "a9b2");
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_streams_match_their_one_shot_digests() {
|
|
let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1);
|
|
sha1.update(b"ab");
|
|
sha1.update(b"c");
|
|
assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d");
|
|
|
|
let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256);
|
|
sha256.update(b"ab");
|
|
sha256.update(b"c");
|
|
assert_eq!(
|
|
sha256.finish(),
|
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn checksum_comparison_ignores_case_and_padding() {
|
|
let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D ");
|
|
assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d"));
|
|
assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D"));
|
|
assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hash_of_the_wrong_shape_is_not_well_formed() {
|
|
assert!(!Checksum::sha256("").is_well_formed());
|
|
assert!(!Checksum::sha256("n/a").is_well_formed());
|
|
assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed());
|
|
assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed());
|
|
|
|
assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed());
|
|
assert!(
|
|
Checksum::sha256("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
|
.is_well_formed()
|
|
);
|
|
}
|
|
}
|