Skip to main content

kget/
checksum.rs

1//! Multi-algorithm file checksum computation and sidecar-file parsing.
2//!
3//! Supports SHA-256, SHA-512, SHA-1, MD5, and BLAKE3.
4//! The [`parse_sidecar`] function understands both the GNU `<hash>  <file>`
5//! format and the BSD `ALG (file) = hash` format.
6
7use crate::error::KgetError;
8use sha2::Digest as _;
9use std::fs::File;
10use std::io::Read;
11use std::path::Path;
12
13// ── Algorithm enum ────────────────────────────────────────────────────────────
14
15/// Checksum algorithm used to verify a downloaded file.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ChecksumAlgorithm {
18    Sha256,
19    Sha512,
20    Sha1,
21    Md5,
22    Blake3,
23}
24
25impl ChecksumAlgorithm {
26    /// Guess the algorithm from the hex-string length.
27    ///
28    /// - 32 chars → MD5
29    /// - 40 chars → SHA-1
30    /// - 64 chars → SHA-256  (BLAKE3 also produces 64 chars; SHA-256 is assumed)
31    /// - 128 chars → SHA-512
32    pub fn from_hex_len(hex: &str) -> Option<Self> {
33        match hex.trim().len() {
34            32  => Some(ChecksumAlgorithm::Md5),
35            40  => Some(ChecksumAlgorithm::Sha1),
36            64  => Some(ChecksumAlgorithm::Sha256),
37            128 => Some(ChecksumAlgorithm::Sha512),
38            _   => None,
39        }
40    }
41
42    /// Human-readable algorithm name.
43    pub fn name(&self) -> &'static str {
44        match self {
45            ChecksumAlgorithm::Sha256 => "sha256",
46            ChecksumAlgorithm::Sha512 => "sha512",
47            ChecksumAlgorithm::Sha1   => "sha1",
48            ChecksumAlgorithm::Md5    => "md5",
49            ChecksumAlgorithm::Blake3 => "blake3",
50        }
51    }
52}
53
54// ── Computation ───────────────────────────────────────────────────────────────
55
56const BUF_SIZE: usize = 1024 * 1024; // 1 MiB
57
58/// Compute the checksum of a file using the specified algorithm.
59///
60/// Returns the lowercase hex-encoded digest.
61pub fn compute_checksum(path: &Path, algorithm: &ChecksumAlgorithm) -> Result<String, KgetError> {
62    let mut file = File::open(path)?;
63    let mut buf = vec![0u8; BUF_SIZE];
64
65    match algorithm {
66        ChecksumAlgorithm::Sha256 => {
67            let mut h = sha2::Sha256::new();
68            loop {
69                let n = file.read(&mut buf)?;
70                if n == 0 { break; }
71                sha2::Digest::update(&mut h, &buf[..n]);
72            }
73            Ok(hex::encode(sha2::Digest::finalize(h)))
74        }
75        ChecksumAlgorithm::Sha512 => {
76            let mut h = sha2::Sha512::new();
77            loop {
78                let n = file.read(&mut buf)?;
79                if n == 0 { break; }
80                sha2::Digest::update(&mut h, &buf[..n]);
81            }
82            Ok(hex::encode(sha2::Digest::finalize(h)))
83        }
84        ChecksumAlgorithm::Sha1 => {
85            let mut h = sha1::Sha1::new();
86            loop {
87                let n = file.read(&mut buf)?;
88                if n == 0 { break; }
89                sha1::Digest::update(&mut h, &buf[..n]);
90            }
91            Ok(hex::encode(sha1::Digest::finalize(h)))
92        }
93        ChecksumAlgorithm::Md5 => {
94            let mut h = md5::Md5::new();
95            loop {
96                let n = file.read(&mut buf)?;
97                if n == 0 { break; }
98                md5::Digest::update(&mut h, &buf[..n]);
99            }
100            Ok(hex::encode(md5::Digest::finalize(h)))
101        }
102        ChecksumAlgorithm::Blake3 => {
103            let mut h = blake3::Hasher::new();
104            loop {
105                let n = file.read(&mut buf)?;
106                if n == 0 { break; }
107                h.update(&buf[..n]);
108            }
109            Ok(h.finalize().to_hex().to_string())
110        }
111    }
112}
113
114// ── Sidecar parsing ───────────────────────────────────────────────────────────
115
116/// Scan a checksum sidecar file and return the `(algorithm, hash)` pair
117/// matching `filename`.
118///
119/// Understands two line formats:
120///
121/// - **GNU**: `<hash>  <filename>` or `<hash> *<filename>` (binary mode marker)
122/// - **BSD**: `SHA256 (<filename>) = <hash>`
123///
124/// `filename` is matched as a suffix so both bare names and paths work.
125pub fn parse_sidecar(content: &str, filename: &str) -> Option<(ChecksumAlgorithm, String)> {
126    for line in content.lines() {
127        let line = line.trim();
128        if line.is_empty() || line.starts_with('#') { continue; }
129
130        // ── GNU format: "<hash>  <file>" or "<hash> *<file>" ─────────────────
131        // Split on double-space first, then single-space-asterisk.
132        let gnu = line.split_once("  ")
133            .or_else(|| line.split_once(" *"));
134
135        if let Some((hash_part, file_part)) = gnu {
136            let hash = hash_part.trim();
137            let file = file_part.trim().trim_start_matches('*').trim();
138            if matches_filename(file, filename) {
139                if let Some(algo) = ChecksumAlgorithm::from_hex_len(hash) {
140                    return Some((algo, hash.to_lowercase()));
141                }
142            }
143        }
144
145        // ── BSD format: "SHA256 (<file>) = <hash>" ────────────────────────────
146        if let Some(eq_pos) = line.find(" = ") {
147            let hash = line[eq_pos + 3..].trim().to_lowercase();
148            let prefix = &line[..eq_pos];
149            if let (Some(lp), Some(rp)) = (prefix.find('('), prefix.rfind(')')) {
150                let file = prefix[lp + 1..rp].trim();
151                if matches_filename(file, filename) {
152                    let algo_str = prefix[..lp].trim().to_uppercase();
153                    let algo = match algo_str.as_str() {
154                        "SHA256" => Some(ChecksumAlgorithm::Sha256),
155                        "SHA512" => Some(ChecksumAlgorithm::Sha512),
156                        "SHA1"   => Some(ChecksumAlgorithm::Sha1),
157                        "MD5"    => Some(ChecksumAlgorithm::Md5),
158                        "BLAKE3" => Some(ChecksumAlgorithm::Blake3),
159                        _        => None,
160                    };
161                    if let Some(algo) = algo {
162                        return Some((algo, hash));
163                    }
164                }
165            }
166        }
167    }
168    None
169}
170
171/// Match file names flexibly — either exact or suffix match.
172fn matches_filename(candidate: &str, target: &str) -> bool {
173    let c = candidate.trim_start_matches("./");
174    let t = target.trim_start_matches("./");
175    c == t
176        || c.ends_with(&format!("/{t}"))
177        || t.ends_with(&format!("/{c}"))
178        || std::path::Path::new(c).file_name() == std::path::Path::new(t).file_name()
179}
180
181// ── Tests ─────────────────────────────────────────────────────────────────────
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn parse_gnu_format() {
189        let sidecar = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  empty.bin\n\
190                       abc123def456abc123def456abc123def456abc123def456abc123def456abc1  file.iso\n";
191        let (algo, hash) = parse_sidecar(sidecar, "file.iso").unwrap();
192        assert_eq!(algo, ChecksumAlgorithm::Sha256);
193        assert_eq!(hash, "abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
194    }
195
196    #[test]
197    fn parse_bsd_format() {
198        let sidecar = "SHA256 (ubuntu.iso) = deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n";
199        let (algo, hash) = parse_sidecar(sidecar, "ubuntu.iso").unwrap();
200        assert_eq!(algo, ChecksumAlgorithm::Sha256);
201        assert_eq!(hash, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
202    }
203
204    #[test]
205    fn parse_md5_by_length() {
206        let sidecar = "d41d8cd98f00b204e9800998ecf8427e  empty.bin\n";
207        let (algo, _) = parse_sidecar(sidecar, "empty.bin").unwrap();
208        assert_eq!(algo, ChecksumAlgorithm::Md5);
209    }
210
211    #[test]
212    fn algo_from_hex_len() {
213        assert_eq!(ChecksumAlgorithm::from_hex_len(&"a".repeat(32)),  Some(ChecksumAlgorithm::Md5));
214        assert_eq!(ChecksumAlgorithm::from_hex_len(&"a".repeat(40)),  Some(ChecksumAlgorithm::Sha1));
215        assert_eq!(ChecksumAlgorithm::from_hex_len(&"a".repeat(64)),  Some(ChecksumAlgorithm::Sha256));
216        assert_eq!(ChecksumAlgorithm::from_hex_len(&"a".repeat(128)), Some(ChecksumAlgorithm::Sha512));
217        assert_eq!(ChecksumAlgorithm::from_hex_len(&"a".repeat(10)),  None);
218    }
219}