use std::path::Path;
use std::process::Command;
const DIGEST_LEN: usize = 64;
pub(crate) fn of_file(path: &Path) -> Result<String, String> {
let attempts: [(&str, &[&str]); 3] = [
("shasum", &["-a", "256"]),
("sha256sum", &[]),
("openssl", &["dgst", "-sha256", "-r"]),
];
let mut last = String::from("no sha256 tool is available");
for (tool, args) in attempts {
let out = match Command::new(tool).args(args).arg("--").arg(path).output() {
Ok(out) => out,
Err(e) => {
last = format!("{tool}: {e}");
continue;
}
};
if !out.status.success() {
last = format!("{tool} failed ({})", out.status);
continue;
}
let text = String::from_utf8_lossy(&out.stdout);
if let Some(digest) = first_digest(&text) {
return Ok(digest);
}
last = format!("{tool} produced no digest");
}
Err(last)
}
pub(crate) fn first_digest(text: &str) -> Option<String> {
text.split(|c: char| c.is_whitespace() || c == '*')
.find(|token| token.len() == DIGEST_LEN && token.bytes().all(|b| b.is_ascii_hexdigit()))
.map(|token| token.to_ascii_lowercase())
}
pub(crate) fn find(body: &str, name: &str) -> Option<String> {
let lines: Vec<&str> = body
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
for line in &lines {
let mentions = line
.split(|c: char| c.is_whitespace() || c == '*')
.any(|token| token.trim_start_matches("./") == name);
if mentions {
if let Some(digest) = first_digest(line) {
return Some(digest);
}
}
}
if lines.len() == 1 {
let only = lines[0].trim();
if only.len() == DIGEST_LEN {
return first_digest(only);
}
}
None
}
pub(crate) fn matches(a: &str, b: &str) -> bool {
a.len() == DIGEST_LEN && b.len() == DIGEST_LEN && a.eq_ignore_ascii_case(b)
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const B: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[test]
fn reads_the_digest_out_of_each_tool_format() {
assert_eq!(
first_digest(&format!("{A} update.AppImage\n")).as_deref(),
Some(A)
);
assert_eq!(
first_digest(&format!("{A} *update.AppImage\n")).as_deref(),
Some(A)
);
assert_eq!(
first_digest(&format!("{A} *./update.AppImage\n")).as_deref(),
Some(A)
);
assert_eq!(
first_digest(&format!("SHA256(update.AppImage)= {A}")).as_deref(),
Some(A)
);
}
#[test]
fn rejects_anything_that_is_not_a_digest() {
assert_eq!(first_digest(""), None);
assert_eq!(first_digest("not a digest at all"), None);
assert_eq!(first_digest(&A[..63]), None);
assert_eq!(first_digest(&format!("{A}f")), None);
assert_eq!(first_digest(&"z".repeat(64)), None);
}
#[test]
fn a_listing_matches_the_named_asset_only() {
let body = format!("{A} Acme-x86_64.AppImage\n{B} Acme-aarch64.AppImage\n");
assert_eq!(find(&body, "Acme-x86_64.AppImage").as_deref(), Some(A));
assert_eq!(find(&body, "Acme-aarch64.AppImage").as_deref(), Some(B));
assert_eq!(find(&body, "Acme.dmg"), None);
}
#[test]
fn a_lone_digest_file_belongs_to_its_asset() {
assert_eq!(find(&format!("{A}\n"), "Acme.AppImage").as_deref(), Some(A));
assert_eq!(find("not-a-digest\n", "Acme.AppImage"), None);
}
#[test]
fn a_listing_with_one_entry_still_has_to_name_the_asset() {
let body = format!("{A} something-else.AppImage\n");
assert_eq!(find(&body, "Acme.AppImage"), None);
}
#[test]
fn digests_compare_case_insensitively_and_by_length() {
assert!(matches(A, &A.to_ascii_uppercase()));
assert!(!matches(A, B));
assert!(!matches(A, &A[..63]));
assert!(!matches("", ""));
}
}