use crate::{
models::{
common::enums::Provider,
provider::{Asset, Release},
},
providers::provider_manager::ProviderManager,
};
use anyhow::{Result, anyhow};
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum HashAlgo {
Sha256,
Sha512,
}
struct ChecksumEntry {
algo: HashAlgo,
filename: String,
digest: String,
}
pub struct ChecksumVerifier<'a> {
provider_manager: &'a ProviderManager,
download_cache: &'a Path,
}
impl<'a> ChecksumVerifier<'a> {
pub fn new(provider_manager: &'a ProviderManager, download_cache: &'a Path) -> Self {
Self {
provider_manager,
download_cache,
}
}
pub async fn try_verify_file<F>(
&self,
asset_path: &Path,
release: &Release,
provider: &Provider,
dl_progress: &mut Option<F>,
) -> Result<bool>
where
F: FnMut(u64, u64),
{
let asset_filename = asset_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("Invalid asset filename"))?;
let checksum_path = match self
.try_download_checksum(release, asset_filename, provider, dl_progress)
.await?
{
Some(path) => path,
None => return Ok(false), };
let contents = fs::read_to_string(&checksum_path)?;
let entries = Self::parse_checksums(&contents);
if entries.is_empty() {
return Err(anyhow!("Checksum file is empty or invalid"));
}
let checksum_entry = if entries.len() == 1 && entries[0].filename.is_empty() {
&entries[0]
} else {
entries
.iter()
.find(|entry| entry.filename == asset_filename)
.or_else(|| {
entries.iter().find(|entry| {
Path::new(&entry.filename)
.file_name()
.and_then(|n| n.to_str())
== Some(asset_filename)
})
})
.ok_or_else(|| {
anyhow!(
"No checksum found for asset '{}' in checksum file",
asset_filename
)
})?
};
Self::verify_checksum(asset_path, checksum_entry)
}
async fn try_download_checksum<F>(
&self,
release: &Release,
asset_name: &str,
provider: &Provider,
dl_progress: &mut Option<F>,
) -> Result<Option<PathBuf>>
where
F: FnMut(u64, u64),
{
let checksum_asset = Self::find_checksum_asset(release, asset_name);
let Some(asset) = checksum_asset else {
return Ok(None); };
let path = self
.provider_manager
.download_asset(asset, provider, self.download_cache, dl_progress)
.await?;
Ok(Some(path))
}
fn find_checksum_asset<'r>(release: &'r Release, asset_name: &str) -> Option<&'r Asset> {
let basename = Path::new(asset_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(asset_name);
let specific_candidates = [
format!("{asset_name}.sha256"),
format!("{asset_name}.sha512"),
format!("{basename}.sha256"),
format!("{basename}.sha512"),
format!("{basename}.sha256sum"),
format!("{basename}.sha512sum"),
];
for candidate in &specific_candidates {
if let Some(asset) = release.get_asset_by_name_invariant(candidate) {
return Some(asset);
}
}
const COMMON_NAMES: &[&str] = &[
"checksums.txt",
"checksums",
"checksum.txt",
"sha256sums.txt",
"sha256sum.txt",
"sha256sums",
"sha256sum",
"sha512sums.txt",
"sha512sum.txt",
"sha512sums",
"sha512sum",
];
for name in COMMON_NAMES {
if let Some(asset) = release.get_asset_by_name_invariant(name) {
return Some(asset);
}
}
release
.assets
.iter()
.find(|asset| Self::is_checksum_filename(&asset.name))
}
fn is_checksum_filename(name: &str) -> bool {
let lowered = name.to_ascii_lowercase();
lowered.ends_with(".sha256")
|| lowered.ends_with(".sha512")
|| lowered.ends_with(".sha256sum")
|| lowered.ends_with(".sha512sum")
|| lowered.ends_with(".sha256.txt")
|| lowered.ends_with(".sha512.txt")
|| lowered.ends_with(".sum")
|| lowered.contains("checksums")
}
fn parse_checksums(contents: &str) -> Vec<ChecksumEntry> {
let mut entries = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(entry) = Self::parse_standard_format(line) {
entries.push(entry);
} else if let Some(entry) = Self::parse_colon_format(line) {
entries.push(entry);
} else if let Some(entry) = Self::parse_openssl_format(line) {
entries.push(entry);
} else if let Some(entry) = Self::parse_bare_hash(line) {
entries.push(entry);
}
}
entries
}
fn parse_digest(raw: &str) -> Option<(HashAlgo, String)> {
let mut token = raw.trim();
for prefix in ["sha256:", "sha256=", "sha512:", "sha512="] {
if token.len() >= prefix.len() && token[..prefix.len()].eq_ignore_ascii_case(prefix) {
token = &token[prefix.len()..];
break;
}
}
let token = token.trim();
if !token.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let algo = match token.len() {
64 => HashAlgo::Sha256,
128 => HashAlgo::Sha512,
_ => return None,
};
Some((algo, token.to_ascii_lowercase()))
}
fn parse_standard_format(line: &str) -> Option<ChecksumEntry> {
let parts: Vec<&str> = line.splitn(2, |c: char| c.is_whitespace()).collect();
if parts.len() != 2 {
return None;
}
let digest = parts[0].trim();
let filename = parts[1].trim().trim_start_matches('*').trim();
if digest.is_empty() || filename.is_empty() {
return None;
}
let (algo, normalized) = Self::parse_digest(digest)?;
Some(ChecksumEntry {
algo,
filename: filename.to_string(),
digest: normalized,
})
}
fn parse_colon_format(line: &str) -> Option<ChecksumEntry> {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() != 2 {
return None;
}
let filename = parts[0].trim();
let digest = parts[1].trim();
if digest.is_empty() || filename.is_empty() {
return None;
}
let (algo, normalized) = Self::parse_digest(digest)?;
Some(ChecksumEntry {
algo,
filename: filename.to_string(),
digest: normalized,
})
}
fn parse_openssl_format(line: &str) -> Option<ChecksumEntry> {
let (left, right) = line.split_once('=')?;
let left = left.trim();
let open = left.find('(')?;
let close = left.rfind(')')?;
if close <= open + 1 {
return None;
}
let algo_name = left[..open].trim();
let expected_algo = if algo_name.eq_ignore_ascii_case("sha256") {
HashAlgo::Sha256
} else if algo_name.eq_ignore_ascii_case("sha512") {
HashAlgo::Sha512
} else {
return None;
};
let filename = left[open + 1..close].trim();
if filename.is_empty() {
return None;
}
let (algo, normalized) = Self::parse_digest(right.trim())?;
if algo != expected_algo {
return None;
}
Some(ChecksumEntry {
algo,
filename: filename.to_string(),
digest: normalized,
})
}
fn parse_bare_hash(line: &str) -> Option<ChecksumEntry> {
let (algo, normalized) = Self::parse_digest(line.trim())?;
Some(ChecksumEntry {
algo,
filename: String::new(),
digest: normalized,
})
}
fn verify_checksum(asset_path: &Path, checksum: &ChecksumEntry) -> Result<bool> {
use std::io::{BufReader, Read};
if !asset_path.exists() {
return Err(anyhow!(
"Asset file does not exist: {}",
asset_path.display()
));
}
let file = fs::File::open(asset_path)?;
let mut reader = BufReader::new(file);
let mut buffer = [0u8; 8192];
let computed_digest = match checksum.algo {
HashAlgo::Sha256 => {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
loop {
let n = reader.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
format!("{:x}", hasher.finalize())
}
HashAlgo::Sha512 => {
use sha2::Digest;
let mut hasher = sha2::Sha512::new();
loop {
let n = reader.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
format!("{:x}", hasher.finalize())
}
};
Ok(computed_digest.to_lowercase() == checksum.digest.to_lowercase())
}
}
#[cfg(test)]
#[path = "../../../tests/services/packaging/checksum_verifier.rs"]
mod tests;