update_kit/applier/
verify.rs1use std::path::Path;
2
3use sha2::{Digest, Sha256};
4use tokio::io::AsyncReadExt;
5
6use crate::errors::UpdateKitError;
7use crate::utils::http::fetch_with_timeout;
8use crate::utils::security::{require_https, timing_safe_equal};
9
10pub struct ChecksumInfo {
12 pub expected_checksum: Option<String>,
14 pub checksum_url: Option<String>,
16}
17
18pub struct VerifyOptions {
20 pub filename: Option<String>,
22}
23
24pub async fn compute_sha256(file_path: &Path) -> Result<String, UpdateKitError> {
26 let mut file = tokio::fs::File::open(file_path).await.map_err(|e| {
27 UpdateKitError::Io(std::io::Error::new(
28 e.kind(),
29 format!("Failed to open file for checksum: {}", file_path.display()),
30 ))
31 })?;
32
33 let mut hasher = Sha256::new();
34 let mut buf = [0u8; 8192];
35
36 loop {
37 let n = file.read(&mut buf).await?;
38 if n == 0 {
39 break;
40 }
41 hasher.update(&buf[..n]);
42 }
43
44 let hash = hasher.finalize();
45 Ok(hex::encode(hash))
46}
47
48mod hex {
50 pub fn encode(bytes: impl AsRef<[u8]>) -> String {
51 bytes
52 .as_ref()
53 .iter()
54 .map(|b| format!("{b:02x}"))
55 .collect()
56 }
57}
58
59pub async fn verify_checksum(
64 file_path: &Path,
65 checksum_info: &ChecksumInfo,
66 options: Option<&VerifyOptions>,
67) -> Result<(), UpdateKitError> {
68 let expected = if let Some(ref checksum) = checksum_info.expected_checksum {
69 checksum.to_lowercase()
70 } else if let Some(ref url) = checksum_info.checksum_url {
71 let filename = options.and_then(|o| o.filename.as_deref());
72 fetch_checksum_from_url(url, filename).await?
73 } else {
74 return Err(UpdateKitError::ChecksumMissing(
75 "No expected checksum or checksum URL provided".into(),
76 ));
77 };
78
79 let actual = compute_sha256(file_path).await?;
80
81 if !timing_safe_equal(&expected, &actual) {
82 return Err(UpdateKitError::ChecksumMismatch { expected, actual });
83 }
84
85 Ok(())
86}
87
88async fn fetch_checksum_from_url(
94 url: &str,
95 filename: Option<&str>,
96) -> Result<String, UpdateKitError> {
97 require_https(url)?;
98
99 let response = fetch_with_timeout(url, None).await.map_err(|e| {
100 UpdateKitError::ChecksumFetchFailed(format!("Failed to fetch checksum from {url}: {e}"))
101 })?;
102
103 let body = response.text().await.map_err(|e| {
104 UpdateKitError::ChecksumFetchFailed(format!("Failed to read checksum response: {e}"))
105 })?;
106
107 for line in body.lines() {
109 let line = line.trim();
110 if line.is_empty() {
111 continue;
112 }
113
114 let parts: Vec<&str> = if line.contains(" ") {
116 line.splitn(2, " ").collect()
117 } else if line.contains(" *") {
118 line.splitn(2, " *").collect()
119 } else {
120 continue;
121 };
122
123 if parts.len() == 2 {
124 let hash = parts[0].trim();
125 let name = parts[1].trim();
126
127 if let Some(fname) = filename {
129 if name == fname || name.ends_with(&format!("/{fname}")) {
130 return validate_hex_hash(hash);
131 }
132 } else {
133 return validate_hex_hash(hash);
134 }
135 }
136 }
137
138 let trimmed = body.trim();
140 if trimmed.lines().count() == 1 && is_valid_sha256_hex(trimmed) {
141 return Ok(trimmed.to_lowercase());
142 }
143
144 Err(UpdateKitError::ChecksumParseFailed(format!(
145 "Could not parse checksum from {url}"
146 )))
147}
148
149fn validate_hex_hash(s: &str) -> Result<String, UpdateKitError> {
150 if is_valid_sha256_hex(s) {
151 Ok(s.to_lowercase())
152 } else {
153 Err(UpdateKitError::ChecksumParseFailed(format!(
154 "Invalid SHA-256 hash: {s}"
155 )))
156 }
157}
158
159fn is_valid_sha256_hex(s: &str) -> bool {
160 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use tempfile::TempDir;
167
168 #[tokio::test]
169 async fn compute_sha256_hello_world() {
170 let dir = TempDir::new().unwrap();
171 let file_path = dir.path().join("test.txt");
172 tokio::fs::write(&file_path, b"hello world").await.unwrap();
173
174 let hash = compute_sha256(&file_path).await.unwrap();
175 assert_eq!(
177 hash,
178 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
179 );
180 }
181
182 #[tokio::test]
183 async fn verify_checksum_match_succeeds() {
184 let dir = TempDir::new().unwrap();
185 let file_path = dir.path().join("test.txt");
186 tokio::fs::write(&file_path, b"hello world").await.unwrap();
187
188 let info = ChecksumInfo {
189 expected_checksum: Some(
190 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9".into(),
191 ),
192 checksum_url: None,
193 };
194
195 let result = verify_checksum(&file_path, &info, None).await;
196 assert!(result.is_ok());
197 }
198
199 #[tokio::test]
200 async fn verify_checksum_mismatch_errors() {
201 let dir = TempDir::new().unwrap();
202 let file_path = dir.path().join("test.txt");
203 tokio::fs::write(&file_path, b"hello world").await.unwrap();
204
205 let info = ChecksumInfo {
206 expected_checksum: Some(
207 "0000000000000000000000000000000000000000000000000000000000000000".into(),
208 ),
209 checksum_url: None,
210 };
211
212 let result = verify_checksum(&file_path, &info, None).await;
213 assert!(result.is_err());
214 let err = result.unwrap_err();
215 assert!(
216 matches!(err, UpdateKitError::ChecksumMismatch { .. }),
217 "Expected ChecksumMismatch, got: {err:?}"
218 );
219 }
220
221 #[tokio::test]
222 async fn verify_missing_checksum_errors() {
223 let dir = TempDir::new().unwrap();
224 let file_path = dir.path().join("test.txt");
225 tokio::fs::write(&file_path, b"hello world").await.unwrap();
226
227 let info = ChecksumInfo {
228 expected_checksum: None,
229 checksum_url: None,
230 };
231
232 let result = verify_checksum(&file_path, &info, None).await;
233 assert!(result.is_err());
234 let err = result.unwrap_err();
235 assert!(
236 matches!(err, UpdateKitError::ChecksumMissing(_)),
237 "Expected ChecksumMissing, got: {err:?}"
238 );
239 }
240
241 #[tokio::test]
242 async fn verify_checksum_case_insensitive() {
243 let dir = TempDir::new().unwrap();
244 let file_path = dir.path().join("test.txt");
245 tokio::fs::write(&file_path, b"hello world").await.unwrap();
246
247 let info = ChecksumInfo {
248 expected_checksum: Some(
249 "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9".into(),
250 ),
251 checksum_url: None,
252 };
253
254 let result = verify_checksum(&file_path, &info, None).await;
255 assert!(result.is_ok());
256 }
257
258 #[test]
259 fn test_is_valid_sha256_hex() {
260 assert!(is_valid_sha256_hex(
261 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
262 ));
263 assert!(!is_valid_sha256_hex("too_short"));
264 assert!(!is_valid_sha256_hex(
265 "zzzz27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
266 ));
267 }
268
269 #[tokio::test]
270 async fn compute_sha256_empty_file() {
271 let dir = TempDir::new().unwrap();
272 let file_path = dir.path().join("empty.txt");
273 tokio::fs::write(&file_path, b"").await.unwrap();
274
275 let hash = compute_sha256(&file_path).await.unwrap();
276 assert_eq!(
278 hash,
279 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
280 );
281 }
282
283 #[tokio::test]
284 async fn compute_sha256_file_not_found() {
285 let dir = TempDir::new().unwrap();
286 let result = compute_sha256(&dir.path().join("nonexistent.txt")).await;
287 assert!(result.is_err());
288 }
289
290 #[tokio::test]
291 async fn verify_checksum_url_http_rejected() {
292 let dir = TempDir::new().unwrap();
293 let file_path = dir.path().join("test.txt");
294 tokio::fs::write(&file_path, b"hello").await.unwrap();
295
296 let info = ChecksumInfo {
297 expected_checksum: None,
298 checksum_url: Some("http://insecure.com/checksums.txt".into()),
299 };
300
301 let result = verify_checksum(&file_path, &info, None).await;
302 assert!(result.is_err());
303 let err = result.unwrap_err();
304 assert!(
305 matches!(err, UpdateKitError::InsecureUrl(_)),
306 "Expected InsecureUrl, got: {err:?}"
307 );
308 }
309
310 #[test]
311 fn is_valid_sha256_hex_valid() {
312 assert!(is_valid_sha256_hex(&"a".repeat(64)));
313 assert!(is_valid_sha256_hex(
314 "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789"
315 ));
316 }
317
318 #[test]
319 fn is_valid_sha256_hex_invalid_length() {
320 assert!(!is_valid_sha256_hex(&"a".repeat(63)));
321 assert!(!is_valid_sha256_hex(&"a".repeat(65)));
322 assert!(!is_valid_sha256_hex(""));
323 }
324
325 #[test]
326 fn is_valid_sha256_hex_invalid_chars() {
327 assert!(!is_valid_sha256_hex(&format!("{}g", "a".repeat(63))));
328 assert!(!is_valid_sha256_hex(&format!("{} ", "a".repeat(63))));
329 }
330
331 #[test]
332 fn validate_hex_hash_returns_lowercase() {
333 let upper = "A".repeat(64);
334 let result = validate_hex_hash(&upper).unwrap();
335 assert_eq!(result, "a".repeat(64));
336 }
337
338 #[test]
339 fn validate_hex_hash_rejects_invalid() {
340 let result = validate_hex_hash("too_short");
341 assert!(result.is_err());
342 assert!(
343 matches!(result.unwrap_err(), UpdateKitError::ChecksumParseFailed(_)),
344 "Expected ChecksumParseFailed"
345 );
346 }
347}