idt/downloaders/
checksum.rs1use 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
10pub 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
48pub 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
70pub 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
87fn parse_checksum(content: &str, filename: &str) -> rootcause::Result<String> {
96 let trimmed = content.trim();
97
98 if !trimmed.contains(' ') && !trimmed.contains('\n') && !trimmed.is_empty() {
100 return Ok(trimmed.to_owned());
101 }
102
103 for line in trimmed.lines() {
105 let line = line.trim();
106 if let Some((hash, rest)) = line.split_once(' ') {
108 let rest = rest.trim_start();
109 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}