Skip to main content

update_kit/applier/
verify.rs

1use 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
10/// Information needed to verify a file's checksum.
11pub struct ChecksumInfo {
12    /// A pre-known expected SHA-256 hex digest.
13    pub expected_checksum: Option<String>,
14    /// URL from which to fetch a checksum file (SHA256SUMS format).
15    pub checksum_url: Option<String>,
16}
17
18/// Options for checksum verification.
19pub struct VerifyOptions {
20    /// Filename to look for in a multi-line checksum file.
21    pub filename: Option<String>,
22}
23
24/// Compute the SHA-256 hash of a file, returning the hex digest.
25pub 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
48/// Hex-encode helper (no extra dep needed, sha2 re-exports digest).
49mod 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
59/// Verify the checksum of a file.
60///
61/// Uses `expected_checksum` directly if provided, otherwise fetches from
62/// `checksum_url`. Returns `ChecksumMissing` if neither is available.
63pub 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
88/// Fetch a checksum value from a URL.
89///
90/// The URL must use HTTPS. The response body is parsed as either:
91/// - A multi-line file with `"hash  filename"` or `"hash *filename"` format
92/// - A single line containing only a 64-character hex string
93async 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    // Try to find a matching line in "hash  filename" format
108    for line in body.lines() {
109        let line = line.trim();
110        if line.is_empty() {
111            continue;
112        }
113
114        // Try splitting on two-space or " *" separators (common in sha256sum output)
115        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 we have a filename, match it; otherwise take the first valid line
128            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    // Fallback: if body is a single 64-char hex line
139    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        // SHA-256 of "hello world"
176        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        // SHA-256 of empty string
277        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}