use std::fmt;
use std::pin::Pin;
use std::str::FromStr;
use std::task::{Context as TaskContext, Poll};
use anyhow::{Context, Result, bail};
use aws_lc_rs::digest::{Context as Sha256Context, SHA256};
use tokio::io::{AsyncRead, ReadBuf};
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) struct Sha256Digest([u8; 32]);
impl Sha256Digest {
fn from_bytes(bytes: &[u8]) -> Self {
let mut digest = [0_u8; 32];
digest.copy_from_slice(bytes);
Self(digest)
}
pub(crate) fn verify(self, actual: Self, subject: impl fmt::Display) -> Result<()> {
if actual != self {
bail!("SHA256 checksum mismatch for `{subject}`: expected {self}, got {actual}");
}
Ok(())
}
}
impl FromStr for Sha256Digest {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
let value = value.strip_prefix("sha256:").unwrap_or(value).trim();
if value.len() != 64 {
bail!("SHA256 digest must be 64 hex characters");
}
let mut digest = [0_u8; 32];
hex::decode_to_slice(value, &mut digest).context("Failed to parse SHA256 digest")?;
Ok(Self(digest))
}
}
impl fmt::Display for Sha256Digest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&hex::encode(self.0))
}
}
pub(crate) struct HashReader<R> {
inner: R,
hasher: Sha256Context,
}
impl<R: AsyncRead + Unpin> HashReader<R> {
pub(crate) fn new(inner: R) -> Self {
Self {
inner,
hasher: Sha256Context::new(&SHA256),
}
}
pub(crate) fn finish(self) -> Sha256Digest {
let digest = self.hasher.finish();
Sha256Digest::from_bytes(digest.as_ref())
}
}
impl<R: AsyncRead + Unpin> AsyncRead for HashReader<R> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
let filled_before = buf.filled().len();
match Pin::new(&mut this.inner).poll_read(cx, buf) {
Poll::Ready(Ok(())) => {
let filled = buf.filled();
if filled.len() > filled_before {
Sha256Context::update(&mut this.hasher, &filled[filled_before..]);
}
Poll::Ready(Ok(()))
}
other => other,
}
}
}
pub(crate) fn digest_from_sha256sums(
contents: &str,
filename: &str,
) -> Result<Option<Sha256Digest>> {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((digest, name)) = line.split_once(char::is_whitespace) else {
continue;
};
let name = name.trim();
let name = name.strip_prefix('*').unwrap_or(name);
if name == filename {
return digest.parse().map(Some);
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use tokio::io::AsyncReadExt;
use super::*;
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn parses_plain_and_prefixed_digest() {
let plain = Sha256Digest::from_str(EMPTY_SHA256).unwrap();
let prefixed = Sha256Digest::from_str(&format!("sha256:{EMPTY_SHA256}")).unwrap();
assert_eq!(plain, prefixed);
assert_eq!(plain.to_string(), EMPTY_SHA256);
assert!(plain.verify(prefixed, "test").is_ok());
}
#[test]
fn rejects_invalid_digest() {
assert!(Sha256Digest::from_str("abc").is_err());
assert!(
Sha256Digest::from_str(
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
)
.is_err()
);
}
#[test]
fn parses_sha256sums_file() -> Result<()> {
let digest = digest_from_sha256sums(
indoc::indoc! {"
0000000000000000000000000000000000000000000000000000000000000000 other.tar.gz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *target.tar.gz
"},
"target.tar.gz",
)?
.context("expected target digest")?;
assert_eq!(digest.to_string(), EMPTY_SHA256);
Ok(())
}
#[test]
fn parses_sha256sums_binary_mode_marker() -> Result<()> {
let digest = digest_from_sha256sums(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *target.tar.gz",
"target.tar.gz",
)?
.context("expected target digest")?;
assert_eq!(digest.to_string(), EMPTY_SHA256);
Ok(())
}
#[test]
fn returns_none_for_missing_sha256sums_entry() -> Result<()> {
let digest = digest_from_sha256sums(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *other.tar.gz",
"target.tar.gz",
)?;
assert_eq!(digest, None);
Ok(())
}
#[tokio::test]
async fn verifies_file_contents() -> Result<()> {
let temp = tempfile::tempdir()?;
let file = temp.path().join("empty");
fs_err::write(&file, [])?;
verify_file(Sha256Digest::from_str(EMPTY_SHA256)?, &file).await?;
Ok(())
}
#[tokio::test]
async fn rejects_file_mismatch() -> Result<()> {
let temp = tempfile::tempdir()?;
let file = temp.path().join("not-empty");
fs_err::write(&file, b"data")?;
let err = verify_file(Sha256Digest::from_str(EMPTY_SHA256)?, &file)
.await
.unwrap_err();
assert!(err.to_string().contains("SHA256 checksum mismatch"));
Ok(())
}
async fn verify_file(required_digest: Sha256Digest, path: &Path) -> Result<()> {
let actual = digest_file(path).await?;
required_digest.verify(actual, path.display())
}
async fn digest_file(path: &Path) -> Result<Sha256Digest> {
let mut file = fs_err::tokio::File::open(path).await.with_context(|| {
format!(
"Failed to open `{}` for SHA256 verification",
path.display()
)
})?;
let mut hasher = Sha256Context::new(&SHA256);
let mut buf = [0_u8; 8192];
loop {
let read = file.read(&mut buf).await.with_context(|| {
format!(
"Failed to read `{}` for SHA256 verification",
path.display()
)
})?;
if read == 0 {
break;
}
Sha256Context::update(&mut hasher, &buf[..read]);
}
let digest = hasher.finish();
Ok(Sha256Digest::from_bytes(digest.as_ref()))
}
}