Skip to main content

idt/downloaders/
checksum.rs

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