proto_core 0.60.0

Core proto APIs.
use super::checksum_error::ProtoChecksumError;
use starbase_utils::fs;
use starbase_utils::hash::{self, HashError};
use std::fmt::Debug;
use std::io::{BufRead, BufReader};
use std::path::Path;
use tracing::{instrument, trace};

#[instrument]
pub fn hash_file_contents_sha256<P: AsRef<Path> + Debug>(path: P) -> Result<String, HashError> {
    let path = path.as_ref();

    trace!(file = ?path, "Calculating SHA256 checksum");

    let hash = hash::sha256::from_file(path)?;

    trace!(file = ?path, hash, "Calculated hash");

    Ok(hash)
}

#[instrument]
pub fn hash_file_contents_sha512<P: AsRef<Path> + Debug>(path: P) -> Result<String, HashError> {
    let path = path.as_ref();

    trace!(file = ?path, "Calculating SHA512 checksum");

    let hash = hash::sha512::from_file(path)?;

    trace!(file = ?path, hash, "Calculated hash");

    Ok(hash)
}

#[instrument(name = "verify_sha_checksum")]
pub fn verify_checksum(
    download_file: &Path,
    checksum_file: &Path,
    checksum_hash: &str,
) -> Result<bool, ProtoChecksumError> {
    let download_file_name = fs::file_name(download_file);

    for line in BufReader::new(fs::open_file(checksum_file)?)
        .lines()
        .map_while(Result::ok)
    {
        if line.is_empty() {
            continue;
        }

        // A line is one of:
        //   <checksum>
        //   <checksum>  <file>
        //   <checksum> *<file>
        //
        // The checksum is hex, so compare it ignoring case; some tools (e.g.
        // cargo-deny on Windows) publish uppercase digests. When a file name is
        // present it must match ours exactly, since file names are not
        // case-insensitive everywhere.
        let mut parts = line.split_whitespace();
        let file_hash = parts.next().unwrap_or_default();
        let has_file_name = parts.next().is_some();

        let hash_matches = file_hash.eq_ignore_ascii_case(checksum_hash);
        let file_name_matches_if_present = !has_file_name || line.ends_with(&download_file_name);

        if hash_matches && file_name_matches_if_present {
            return Ok(true);
        }

        // Checksum files on Windows are created with Get-FileHash,
        // which has a different file structure than Unix
        // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.5
        if line.starts_with("Hash")
            && let Some((_, hash)) = line.split_once(':')
        {
            // The hash is all uppercase in the checksum file,
            // but the one's we generate are not, so lowercase
            return Ok(hash.trim().to_lowercase() == checksum_hash);
        }
    }

    Ok(false)
}

#[cfg(test)]
mod tests {
    use super::*;
    use starbase_sandbox::create_empty_sandbox;

    // Lowercase, as generated by hash_file_contents_sha256.
    const HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

    fn verify(checksum_contents: &str) -> bool {
        let sandbox = create_empty_sandbox();
        sandbox.create_file("checksum.sha256", checksum_contents);

        verify_checksum(
            Path::new("tool.tar.gz"),
            &sandbox.path().join("checksum.sha256"),
            HASH,
        )
        .unwrap()
    }

    #[test]
    fn matches_bare_hash() {
        assert!(verify(HASH));
    }

    #[test]
    fn matches_hash_and_file_name() {
        assert!(verify(&format!("{HASH}  tool.tar.gz")));
    }

    #[test]
    fn matches_hash_and_binary_file_name() {
        assert!(verify(&format!("{HASH} *tool.tar.gz")));
    }

    #[test]
    fn matches_uppercase_bare_hash() {
        // e.g. cargo-deny's Windows .sha256 files.
        assert!(verify(&HASH.to_uppercase()));
    }

    #[test]
    fn matches_uppercase_hash_and_file_name() {
        assert!(verify(&format!("{}  tool.tar.gz", HASH.to_uppercase())));
    }

    #[test]
    fn matches_windows_get_file_hash_format() {
        assert!(verify(&format!("Hash          : {}", HASH.to_uppercase())));
    }

    #[test]
    fn rejects_non_matching_hash() {
        assert!(!verify(&"a".repeat(64)));
    }

    #[test]
    fn rejects_matching_hash_listed_for_another_file() {
        // The digest matches our download, but it's listed against a different
        // file name; proto scopes by file name, so this must not verify.
        assert!(!verify(&format!("{HASH}  other.tar.gz")));
    }

    #[test]
    fn finds_the_right_line_among_many() {
        assert!(verify(&format!(
            "{}  other.tar.gz\n{HASH}  tool.tar.gz\n",
            "b".repeat(64)
        )));
    }
}