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;
7use rootcause::report;
8use sha2::Digest;
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/// Verifies that the file at `path` matches the `expected_hex` Sha256 hash.
71///
72/// # Errors
73/// - Computing the hash fails.
74/// - The computed hash does not match.
75pub fn verify(path: &Path, expected_hex: &str) -> rootcause::Result<()> {
76    let actual = compute_sha256(path)?;
77    let expected = expected_hex.to_lowercase();
78
79    if actual != expected {
80        return Err(report!("error checksum mismatch")
81            .attach(format!("path={} expected={expected} actual={actual}", path.display())));
82    }
83
84    Ok(())
85}
86
87/// Parse a checksums file content and find the hash for `filename`.
88///
89/// Handles two formats:
90/// 1. Multiline: `<hex_hash>  <filename>` (with one or two spaces)
91/// 2. Single-line: just a hex hash (for per-file `.sha256` files)
92///
93/// # Errors
94/// - No matching entry found for `filename`.
95fn parse_checksum(content: &str, filename: &str) -> rootcause::Result<String> {
96    let trimmed = content.trim();
97
98    // Single-line file containing only a hex hash (per-file .Sha256 pattern).
99    if !trimmed.contains(' ') && !trimmed.contains('\n') && !trimmed.is_empty() {
100        return Ok(trimmed.to_owned());
101    }
102
103    // Standard multiline format: `<hash>  <filename>` or `<hash> <filename>`
104    for line in trimmed.lines() {
105        let line = line.trim();
106        // Split at first whitespace
107        if let Some((hash, rest)) = line.split_once(' ') {
108            let rest = rest.trim_start();
109            // The filename in checksums files may have leading `*` (binary mode indicator)
110            let entry_filename = rest.strip_prefix('*').unwrap_or(rest);
111            if entry_filename == filename {
112                return Ok(hash.to_owned());
113            }
114        }
115    }
116
117    Err(report!("error checksum entry not found").attach(format!("filename={filename:?} content={trimmed:?}")))
118}
119
120#[cfg(test)]
121mod tests {
122    use rstest::rstest;
123    use test_that::prelude::*;
124
125    use super::*;
126
127    #[rstest]
128    #[case::multi_line_format("abc123  foo.tar.gz\ndef456  bar.zip\n", "bar.zip", "def456")]
129    #[case::single_space("abc123 foo.tar.gz\n", "foo.tar.gz", "abc123")]
130    #[case::binary_mode_indicator("abc123 *foo.tar.gz\n", "foo.tar.gz", "abc123")]
131    #[case::single_line_hash("abc123def456\n", "anything", "abc123def456")]
132    fn test_parse_checksum_returns_expected_hash(
133        #[case] content: &str,
134        #[case] filename: &str,
135        #[case] expected: &str,
136    ) {
137        assert_that!(parse_checksum(content, filename), ok(eq(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        assert_that!(
144            (parse_checksum(content, "missing.txt")).map(|_| ()),
145            err(displays_as(contains_substring("error checksum entry not found")))
146        );
147    }
148
149    #[test]
150    fn test_compute_sha256_returns_expected_hash() {
151        let dir = tempfile::tempdir().unwrap();
152        let file_path = dir.path().join("test.txt");
153        std::fs::write(&file_path, b"hello world").unwrap();
154
155        assert_that!(
156            compute_sha256(&file_path),
157            ok(eq("b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"))
158        );
159    }
160
161    #[test]
162    fn test_verify_succeeds_with_matching_hash() {
163        let dir = tempfile::tempdir().unwrap();
164        let file_path = dir.path().join("test.txt");
165        std::fs::write(&file_path, b"hello world").unwrap();
166
167        assert_that!(
168            verify(
169                &file_path,
170                "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
171            ),
172            ok(eq(()))
173        );
174    }
175
176    #[test]
177    fn test_verify_fails_with_mismatched_hash() {
178        let dir = tempfile::tempdir().unwrap();
179        let file_path = dir.path().join("test.txt");
180        std::fs::write(&file_path, b"hello world").unwrap();
181
182        let verify_result = verify(
183            &file_path,
184            "0000000000000000000000000000000000000000000000000000000000000000",
185        );
186        assert_that!(verify_result.as_ref(), err(anything()));
187        let err = verify_result.expect_err("mismatched hash should fail verification");
188        assert_that!(err.to_string(), contains_substring("error checksum mismatch"));
189    }
190}