Skip to main content

idt/downloaders/
checksum.rs

1use std::fs::File;
2use std::io::Read;
3use std::path::Path;
4
5use rootcause::prelude::ResultExt as _;
6use rootcause::report;
7use sha2::Digest as _;
8
9/// Computes the SHA256 hex digest of the file at `path`.
10///
11/// # Errors
12/// - The file cannot be opened or read.
13pub fn compute_sha256(path: &Path) -> rootcause::Result<String> {
14    let mut file = File::open(path)
15        .context("error opening file for checksum")
16        .attach_with(|| format!("path={}", path.display()))?;
17
18    let mut hasher = sha2::Sha256::new();
19    let mut buf = [0_u8; 8192];
20
21    loop {
22        let n = file
23            .read(&mut buf)
24            .context("error reading file for checksum")
25            .attach_with(|| format!("path={}", path.display()))?;
26        if n == 0 {
27            break;
28        }
29        hasher.update(
30            buf.get(..n)
31                .ok_or_else(|| report!("error slicing buffer"))
32                .attach_with(|| format!("n={n} buf_len={}", buf.len()))?,
33        );
34    }
35
36    Ok(format!("{:x}", hasher.finalize()))
37}
38
39/// Downloads a checksums file from `checksums_url` and extracts the expected hash for `filename`.
40///
41/// Supports the standard `<hex_hash>  <filename>` format used by `sha256sum` / `shasum`, as well as
42/// single-hash files (one line containing only a hex hash).
43///
44/// # Errors
45/// - The checksums file download fails.
46/// - The file cannot be read as UTF-8.
47/// - No matching entry is found for `filename`.
48pub fn download_and_find_checksum(checksums_url: &str, filename: &str) -> rootcause::Result<String> {
49    let body = ureq::get(checksums_url)
50        .call()
51        .context("error downloading checksums file")
52        .attach_with(|| format!("url={checksums_url}"))?
53        .into_body()
54        .read_to_string()
55        .context("error reading checksums response")
56        .attach_with(|| format!("url={checksums_url}"))?;
57
58    parse_checksum(&body, filename)
59}
60
61/// Parse a checksums file content and find the hash for `filename`.
62///
63/// Handles two formats:
64/// 1. Multi-line: `<hex_hash>  <filename>` (with one or two spaces)
65/// 2. Single-line: just a hex hash (for per-file `.sha256` files)
66///
67/// # Errors
68/// - No matching entry found for `filename`.
69fn parse_checksum(content: &str, filename: &str) -> rootcause::Result<String> {
70    let trimmed = content.trim();
71
72    // Single-line file containing only a hex hash (per-file .sha256 pattern).
73    if !trimmed.contains(' ') && !trimmed.contains('\n') && !trimmed.is_empty() {
74        return Ok(trimmed.to_owned());
75    }
76
77    // Standard multi-line format: `<hash>  <filename>` or `<hash> <filename>`
78    for line in trimmed.lines() {
79        let line = line.trim();
80        // Split at first whitespace
81        if let Some((hash, rest)) = line.split_once(' ') {
82            let rest = rest.trim_start();
83            // The filename in checksums files may have a leading `*` (binary mode indicator)
84            let entry_filename = rest.strip_prefix('*').unwrap_or(rest);
85            if entry_filename == filename {
86                return Ok(hash.to_owned());
87            }
88        }
89    }
90
91    Err(report!("error checksum entry not found").attach(format!("filename={filename:?} content={trimmed:?}")))
92}
93
94/// Verifies that the file at `path` matches the `expected_hex` SHA256 hash.
95///
96/// # Errors
97/// - Computing the hash fails.
98/// - The computed hash does not match.
99pub fn verify(path: &Path, expected_hex: &str) -> rootcause::Result<()> {
100    let actual = compute_sha256(path)?;
101    let expected = expected_hex.to_lowercase();
102
103    if actual != expected {
104        return Err(report!("error checksum mismatch")
105            .attach(format!("path={} expected={expected} actual={actual}", path.display())));
106    }
107
108    Ok(())
109}
110
111#[cfg(test)]
112mod tests {
113    use rstest::rstest;
114
115    use super::*;
116
117    #[rstest]
118    #[case::multi_line_format("abc123  foo.tar.gz\ndef456  bar.zip\n", "bar.zip", "def456")]
119    #[case::single_space("abc123 foo.tar.gz\n", "foo.tar.gz", "abc123")]
120    #[case::binary_mode_indicator("abc123 *foo.tar.gz\n", "foo.tar.gz", "abc123")]
121    #[case::single_line_hash("abc123def456\n", "anything", "abc123def456")]
122    fn parse_checksum_returns_expected_hash(#[case] content: &str, #[case] filename: &str, #[case] expected: &str) {
123        assert2::assert!(let Ok(hash) = parse_checksum(content, filename));
124        pretty_assertions::assert_eq!(hash, expected);
125    }
126
127    #[test]
128    fn parse_checksum_returns_error_when_not_found() {
129        let content = "abc123  foo.tar.gz\ndef456  bar.zip\n";
130        assert2::assert!(let Err(err) = parse_checksum(content, "missing.txt"));
131        assert!(err.to_string().contains("error checksum entry not found"));
132    }
133
134    #[test]
135    fn compute_sha256_returns_expected_hash() {
136        let dir = tempfile::tempdir().unwrap();
137        let file_path = dir.path().join("test.txt");
138        std::fs::write(&file_path, b"hello world").unwrap();
139
140        assert2::assert!(let Ok(hash) = compute_sha256(&file_path));
141        pretty_assertions::assert_eq!(hash, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9");
142    }
143
144    #[test]
145    fn verify_succeeds_with_matching_hash() {
146        let dir = tempfile::tempdir().unwrap();
147        let file_path = dir.path().join("test.txt");
148        std::fs::write(&file_path, b"hello world").unwrap();
149
150        assert2::assert!(let
151            Ok(()) = verify(
152                &file_path,
153                "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
154            )
155        );
156    }
157
158    #[test]
159    fn verify_fails_with_mismatched_hash() {
160        let dir = tempfile::tempdir().unwrap();
161        let file_path = dir.path().join("test.txt");
162        std::fs::write(&file_path, b"hello world").unwrap();
163
164        assert2::assert!(let
165            Err(err) = verify(
166                &file_path,
167                "0000000000000000000000000000000000000000000000000000000000000000"
168            )
169        );
170        assert!(err.to_string().contains("error checksum mismatch"));
171    }
172}