Skip to main content

rget/
integrity.rs

1//! Checksum verification (PRD §16).
2//!
3//! A mismatch is a failure, never a warning: `rget` exits non-zero and the
4//! download is marked failed (PRD Invariant 5).
5
6use std::io::Read;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use anyhow::{Context, Result, bail};
11
12use crate::progress::{Event, Reporter};
13use crate::shutdown::Cancel;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Algorithm {
17    Sha256,
18    Sha512,
19    Blake3,
20}
21
22impl Algorithm {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Algorithm::Sha256 => "sha256",
26            Algorithm::Sha512 => "sha512",
27            Algorithm::Blake3 => "blake3",
28        }
29    }
30
31    /// Expected hex length, used to reject a truncated or pasted-wrong digest
32    /// before spending minutes hashing a large file.
33    pub fn hex_len(&self) -> usize {
34        match self {
35            Algorithm::Sha256 => 64,
36            Algorithm::Sha512 => 128,
37            Algorithm::Blake3 => 64,
38        }
39    }
40
41    /// Display name used in the UI.
42    pub fn label(&self) -> &'static str {
43        match self {
44            Algorithm::Sha256 => "SHA-256",
45            Algorithm::Sha512 => "SHA-512",
46            Algorithm::Blake3 => "BLAKE3",
47        }
48    }
49}
50
51impl std::str::FromStr for Algorithm {
52    type Err = anyhow::Error;
53
54    fn from_str(s: &str) -> Result<Self> {
55        match s.trim().to_ascii_lowercase().replace('-', "").as_str() {
56            "sha256" => Ok(Algorithm::Sha256),
57            "sha512" => Ok(Algorithm::Sha512),
58            "blake3" | "b3" => Ok(Algorithm::Blake3),
59            other => bail!("unknown checksum algorithm `{other}`"),
60        }
61    }
62}
63
64impl std::fmt::Display for Algorithm {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Checksum {
72    pub algorithm: Algorithm,
73    pub expected: String,
74}
75
76impl Checksum {
77    /// Accepts a bare digest, or one prefixed `sha256:`/`sha256=`, and
78    /// normalises to lowercase hex.
79    pub fn parse(algorithm: Algorithm, raw: &str) -> Result<Self> {
80        let cleaned = raw
81            .trim()
82            .rsplit([':', '='])
83            .next()
84            .unwrap_or("")
85            .trim()
86            .to_ascii_lowercase();
87        if cleaned.len() != algorithm.hex_len() {
88            bail!(
89                "{} digest must be {} hex characters, got {}",
90                algorithm.label(),
91                algorithm.hex_len(),
92                cleaned.len()
93            );
94        }
95        if !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
96            bail!("{} digest is not valid hex", algorithm.label());
97        }
98        Ok(Self {
99            algorithm,
100            expected: cleaned,
101        })
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Outcome {
107    Match { actual: String },
108    Mismatch { expected: String, actual: String },
109}
110
111impl Outcome {
112    pub fn ok(&self) -> bool {
113        matches!(self, Outcome::Match { .. })
114    }
115
116    pub fn actual(&self) -> &str {
117        match self {
118            Outcome::Match { actual } | Outcome::Mismatch { actual, .. } => actual,
119        }
120    }
121}
122
123/// Hash a file, reporting progress. Runs on a blocking thread: hashing a large
124/// file is CPU-bound and would otherwise stall the runtime.
125pub async fn verify(
126    path: &Path,
127    checksum: Checksum,
128    reporter: Reporter,
129    cancel: Cancel,
130) -> Result<Outcome> {
131    let path: PathBuf = path.to_path_buf();
132    let total = std::fs::metadata(&path)
133        .with_context(|| format!("cannot stat {}", path.display()))?
134        .len();
135
136    reporter.emit(Event::VerificationStarted {
137        algorithm: checksum.algorithm.as_str().to_string(),
138        total_size: total,
139    });
140
141    let actual = tokio::task::spawn_blocking({
142        let reporter = reporter.clone();
143        let algorithm = checksum.algorithm;
144        move || hash_file(&path, algorithm, total, &reporter, &cancel)
145    })
146    .await
147    .context("hashing task panicked")??;
148
149    let outcome = if actual == checksum.expected {
150        Outcome::Match { actual }
151    } else {
152        Outcome::Mismatch {
153            expected: checksum.expected.clone(),
154            actual,
155        }
156    };
157
158    reporter.emit(Event::VerificationCompleted {
159        algorithm: checksum.algorithm.as_str().to_string(),
160        ok: outcome.ok(),
161        expected: Some(checksum.expected),
162        actual: outcome.actual().to_string(),
163    });
164
165    Ok(outcome)
166}
167
168/// Streaming hash. Buffer is fixed at 1 MiB so memory does not scale with the
169/// file (PRD §30).
170fn hash_file(
171    path: &Path,
172    algorithm: Algorithm,
173    total: u64,
174    reporter: &Reporter,
175    cancel: &Cancel,
176) -> Result<String> {
177    let mut file =
178        std::fs::File::open(path).with_context(|| format!("cannot read {}", path.display()))?;
179    let mut buf = vec![0u8; 1 << 20];
180    let mut hasher = Hasher::new(algorithm);
181    let mut read_total = 0u64;
182    let mut last_report = std::time::Instant::now();
183
184    loop {
185        if cancel.is_cancelled() {
186            bail!("verification cancelled");
187        }
188        let n = file.read(&mut buf).context("read failed while hashing")?;
189        if n == 0 {
190            break;
191        }
192        hasher.update(&buf[..n]);
193        read_total += n as u64;
194        if last_report.elapsed() >= std::time::Duration::from_millis(100) {
195            reporter.emit(Event::VerificationProgress {
196                bytes: read_total,
197                total_size: total,
198            });
199            last_report = std::time::Instant::now();
200        }
201    }
202
203    reporter.emit(Event::VerificationProgress {
204        bytes: read_total,
205        total_size: total,
206    });
207    Ok(hasher.finalize())
208}
209
210enum Hasher {
211    Sha256(sha2::Sha256),
212    Sha512(sha2::Sha512),
213    Blake3(Box<blake3::Hasher>),
214}
215
216impl Hasher {
217    fn new(algorithm: Algorithm) -> Self {
218        use sha2::Digest;
219        match algorithm {
220            Algorithm::Sha256 => Hasher::Sha256(sha2::Sha256::new()),
221            Algorithm::Sha512 => Hasher::Sha512(sha2::Sha512::new()),
222            Algorithm::Blake3 => Hasher::Blake3(Box::new(blake3::Hasher::new())),
223        }
224    }
225
226    fn update(&mut self, data: &[u8]) {
227        use sha2::Digest;
228        match self {
229            Hasher::Sha256(h) => h.update(data),
230            Hasher::Sha512(h) => h.update(data),
231            Hasher::Blake3(h) => {
232                h.update(data);
233            }
234        }
235    }
236
237    fn finalize(self) -> String {
238        use sha2::Digest;
239        match self {
240            Hasher::Sha256(h) => hex(&h.finalize()),
241            Hasher::Sha512(h) => hex(&h.finalize()),
242            Hasher::Blake3(h) => h.finalize().to_hex().to_string(),
243        }
244    }
245}
246
247fn hex(bytes: &[u8]) -> String {
248    let mut s = String::with_capacity(bytes.len() * 2);
249    for b in bytes {
250        use std::fmt::Write;
251        let _ = write!(s, "{b:02x}");
252    }
253    s
254}
255
256/// Hash a byte slice — used by tests and by mirror equivalence checks.
257pub fn hash_bytes(algorithm: Algorithm, data: &[u8]) -> String {
258    let mut h = Hasher::new(algorithm);
259    h.update(data);
260    h.finalize()
261}
262
263/// Wrap in an `Arc` for sharing with the UI without cloning the digest.
264pub type SharedChecksum = Arc<Checksum>;
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
271    const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
272
273    #[test]
274    fn parses_algorithms() {
275        assert_eq!("sha256".parse::<Algorithm>().unwrap(), Algorithm::Sha256);
276        assert_eq!("SHA-512".parse::<Algorithm>().unwrap(), Algorithm::Sha512);
277        assert_eq!("blake3".parse::<Algorithm>().unwrap(), Algorithm::Blake3);
278        assert!("md5".parse::<Algorithm>().is_err());
279    }
280
281    #[test]
282    fn known_digests() {
283        assert_eq!(hash_bytes(Algorithm::Sha256, b""), EMPTY_SHA256);
284        assert_eq!(hash_bytes(Algorithm::Sha256, b"abc"), ABC_SHA256);
285        assert_eq!(
286            hash_bytes(Algorithm::Sha512, b"abc"),
287            "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a\
288             2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
289        );
290        assert_eq!(
291            hash_bytes(Algorithm::Blake3, b"abc"),
292            "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85"
293        );
294    }
295
296    #[test]
297    fn checksum_parsing_is_forgiving_but_strict() {
298        let c = Checksum::parse(
299            Algorithm::Sha256,
300            &format!("  {}  ", ABC_SHA256.to_uppercase()),
301        )
302        .unwrap();
303        assert_eq!(c.expected, ABC_SHA256);
304
305        let c = Checksum::parse(Algorithm::Sha256, &format!("sha256:{ABC_SHA256}")).unwrap();
306        assert_eq!(c.expected, ABC_SHA256);
307
308        // Truncated, over-long and non-hex digests are caught up front.
309        assert!(Checksum::parse(Algorithm::Sha256, "abc").is_err());
310        assert!(Checksum::parse(Algorithm::Sha256, &"a".repeat(65)).is_err());
311        assert!(Checksum::parse(Algorithm::Sha256, &"z".repeat(64)).is_err());
312        // Right length, wrong algorithm's length.
313        assert!(Checksum::parse(Algorithm::Sha512, ABC_SHA256).is_err());
314    }
315
316    #[tokio::test]
317    async fn verifies_a_file_and_reports_progress() {
318        let dir = std::env::temp_dir().join(format!("rget-integrity-{}", std::process::id()));
319        std::fs::create_dir_all(&dir).unwrap();
320        let path = dir.join("data.bin");
321        // Larger than the read buffer, so the streaming path is exercised.
322        let data: Vec<u8> = (0..(3 << 20)).map(|i| (i % 251) as u8).collect();
323        std::fs::write(&path, &data).unwrap();
324        let expected = hash_bytes(Algorithm::Sha256, &data);
325
326        let (reporter, mut rx) = Reporter::new();
327        let outcome = verify(
328            &path,
329            Checksum::parse(Algorithm::Sha256, &expected).unwrap(),
330            reporter,
331            Cancel::new(),
332        )
333        .await
334        .unwrap();
335        assert!(outcome.ok());
336
337        let mut saw_started = false;
338        let mut final_progress = 0;
339        let mut completed_ok = None;
340        while let Ok(ev) = rx.try_recv() {
341            match ev {
342                Event::VerificationStarted { total_size, .. } => {
343                    saw_started = true;
344                    assert_eq!(total_size, data.len() as u64);
345                }
346                Event::VerificationProgress { bytes, .. } => final_progress = bytes,
347                Event::VerificationCompleted { ok, .. } => completed_ok = Some(ok),
348                _ => {}
349            }
350        }
351        assert!(saw_started);
352        assert_eq!(final_progress, data.len() as u64);
353        assert_eq!(completed_ok, Some(true));
354
355        std::fs::remove_dir_all(&dir).ok();
356    }
357
358    #[tokio::test]
359    async fn mismatch_is_reported_not_swallowed() {
360        let dir = std::env::temp_dir().join(format!("rget-integrity-bad-{}", std::process::id()));
361        std::fs::create_dir_all(&dir).unwrap();
362        let path = dir.join("data.bin");
363        std::fs::write(&path, b"abc").unwrap();
364
365        let outcome = verify(
366            &path,
367            Checksum::parse(Algorithm::Sha256, EMPTY_SHA256).unwrap(),
368            Reporter::silent(),
369            Cancel::new(),
370        )
371        .await
372        .unwrap();
373
374        assert!(!outcome.ok());
375        match outcome {
376            Outcome::Mismatch { expected, actual } => {
377                assert_eq!(expected, EMPTY_SHA256);
378                assert_eq!(actual, ABC_SHA256);
379            }
380            Outcome::Match { .. } => panic!("must not report a match"),
381        }
382        std::fs::remove_dir_all(&dir).ok();
383    }
384}