Skip to main content

canic_backup/artifacts/
mod.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::{
4    fs::{self, File},
5    io::{self, Read},
6    path::{Path, PathBuf},
7};
8use thiserror::Error as ThisError;
9
10const SHA256_ALGORITHM: &str = "sha256";
11
12///
13/// ArtifactChecksum
14///
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17pub struct ArtifactChecksum {
18    pub algorithm: String,
19    pub hash: String,
20}
21
22impl ArtifactChecksum {
23    /// Compute a SHA-256 checksum from in-memory bytes.
24    #[must_use]
25    pub fn from_bytes(bytes: &[u8]) -> Self {
26        Self {
27            algorithm: SHA256_ALGORITHM.to_string(),
28            hash: sha256_hex(bytes),
29        }
30    }
31
32    /// Compute a SHA-256 checksum from one filesystem file.
33    pub fn from_file(path: &Path) -> Result<Self, ArtifactChecksumError> {
34        let mut file = File::open(path)?;
35        let mut hasher = Sha256::new();
36        let mut buffer = vec![0u8; 64 * 1024];
37
38        loop {
39            let read = file.read(&mut buffer)?;
40            if read == 0 {
41                break;
42            }
43            hasher.update(&buffer[..read]);
44        }
45
46        Ok(Self {
47            algorithm: SHA256_ALGORITHM.to_string(),
48            hash: digest_hex(hasher.finalize()),
49        })
50    }
51
52    /// Compute a SHA-256 checksum from a file or deterministic directory listing.
53    pub fn from_path(path: &Path) -> Result<Self, ArtifactChecksumError> {
54        if path.is_dir() {
55            Self::from_directory(path)
56        } else {
57            Self::from_file(path)
58        }
59    }
60
61    /// Compute a deterministic SHA-256 checksum over all files in a directory.
62    pub fn from_directory(path: &Path) -> Result<Self, ArtifactChecksumError> {
63        let mut files = Vec::new();
64        collect_files(path, path, &mut files)?;
65        files.sort();
66
67        let mut hasher = Sha256::new();
68        for relative_path in files {
69            let full_path = path.join(&relative_path);
70            let file_checksum = Self::from_file(&full_path)?;
71            hasher.update(relative_path.to_string_lossy().as_bytes());
72            hasher.update([0]);
73            hasher.update(file_checksum.hash.as_bytes());
74            hasher.update([b'\n']);
75        }
76
77        Ok(Self {
78            algorithm: SHA256_ALGORITHM.to_string(),
79            hash: digest_hex(hasher.finalize()),
80        })
81    }
82
83    /// Verify that the checksum matches an expected SHA-256 hash.
84    pub fn verify(&self, expected_hash: &str) -> Result<(), ArtifactChecksumError> {
85        if self.algorithm != SHA256_ALGORITHM {
86            return Err(ArtifactChecksumError::UnsupportedAlgorithm(
87                self.algorithm.clone(),
88            ));
89        }
90
91        if self.hash == expected_hash {
92            Ok(())
93        } else {
94            Err(ArtifactChecksumError::ChecksumMismatch {
95                expected: expected_hash.to_string(),
96                actual: self.hash.clone(),
97            })
98        }
99    }
100}
101
102///
103/// ArtifactChecksumError
104///
105
106#[derive(Debug, ThisError)]
107pub enum ArtifactChecksumError {
108    #[error(transparent)]
109    Io(#[from] io::Error),
110
111    #[error("unsupported checksum algorithm {0}")]
112    UnsupportedAlgorithm(String),
113
114    #[error("checksum mismatch: expected {expected}, actual {actual}")]
115    ChecksumMismatch { expected: String, actual: String },
116}
117
118// Recursively collect file paths relative to a directory root.
119fn collect_files(
120    root: &Path,
121    path: &Path,
122    files: &mut Vec<PathBuf>,
123) -> Result<(), ArtifactChecksumError> {
124    for entry in fs::read_dir(path)? {
125        let entry = entry?;
126        let path = entry.path();
127        if path.is_dir() {
128            collect_files(root, &path, files)?;
129        } else if path.is_file() {
130            let relative = path
131                .strip_prefix(root)
132                .map_err(io::Error::other)?
133                .to_path_buf();
134            files.push(relative);
135        }
136    }
137    Ok(())
138}
139
140// Compute lowercase hexadecimal SHA-256 from in-memory bytes.
141fn sha256_hex(bytes: &[u8]) -> String {
142    digest_hex(Sha256::digest(bytes))
143}
144
145// Encode a finalized digest as lowercase hexadecimal.
146fn digest_hex(bytes: impl AsRef<[u8]>) -> String {
147    let bytes = bytes.as_ref();
148    let mut out = String::with_capacity(bytes.len() * 2);
149    for byte in bytes {
150        out.push(hex_char(byte >> 4));
151        out.push(hex_char(byte & 0x0f));
152    }
153    out
154}
155
156// Convert one four-bit nibble to lowercase hexadecimal.
157const fn hex_char(nibble: u8) -> char {
158    match nibble {
159        0..=9 => (b'0' + nibble) as char,
160        10..=15 => (b'a' + (nibble - 10)) as char,
161        _ => unreachable!(),
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::{
169        fs,
170        time::{SystemTime, UNIX_EPOCH},
171    };
172
173    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
174
175    // Ensure empty-byte checksums match the standard SHA-256 vector.
176    #[test]
177    fn byte_checksum_matches_sha256_vector() {
178        let checksum = ArtifactChecksum::from_bytes(&[]);
179
180        assert_eq!(checksum.algorithm, "sha256");
181        assert_eq!(checksum.hash, EMPTY_SHA256);
182    }
183
184    // Ensure file checksums use the same implementation as byte checksums.
185    #[test]
186    fn file_checksum_matches_byte_checksum() {
187        let path = temp_path("canic-backup-checksum");
188        fs::write(&path, b"canic backup artifact").expect("write temp artifact");
189
190        let from_file = ArtifactChecksum::from_file(&path).expect("checksum file");
191        let from_bytes = ArtifactChecksum::from_bytes(b"canic backup artifact");
192
193        fs::remove_file(&path).expect("remove temp artifact");
194        assert_eq!(from_file, from_bytes);
195    }
196
197    // Ensure directory checksums are stable regardless of file creation order.
198    #[test]
199    fn directory_checksum_is_order_independent() {
200        let first = temp_path("canic-backup-dir-a");
201        let second = temp_path("canic-backup-dir-b");
202        fs::create_dir_all(first.join("nested")).expect("create first");
203        fs::create_dir_all(second.join("nested")).expect("create second");
204
205        fs::write(first.join("a.txt"), b"a").expect("write first a");
206        fs::write(first.join("nested/b.txt"), b"b").expect("write first b");
207        fs::write(second.join("nested/b.txt"), b"b").expect("write second b");
208        fs::write(second.join("a.txt"), b"a").expect("write second a");
209
210        let first_checksum = ArtifactChecksum::from_directory(&first).expect("checksum first");
211        let second_checksum = ArtifactChecksum::from_directory(&second).expect("checksum second");
212
213        fs::remove_dir_all(first).expect("remove first");
214        fs::remove_dir_all(second).expect("remove second");
215        assert_eq!(first_checksum, second_checksum);
216    }
217
218    // Ensure checksum verification reports mismatches.
219    #[test]
220    fn checksum_verify_rejects_mismatch() {
221        let checksum = ArtifactChecksum::from_bytes(b"actual");
222
223        let err = checksum
224            .verify(EMPTY_SHA256)
225            .expect_err("different hash should fail");
226
227        assert!(matches!(
228            err,
229            ArtifactChecksumError::ChecksumMismatch { .. }
230        ));
231    }
232
233    // Build a unique temp file path without adding test-only dependencies.
234    fn temp_path(prefix: &str) -> std::path::PathBuf {
235        let nanos = SystemTime::now()
236            .duration_since(UNIX_EPOCH)
237            .expect("system time after epoch")
238            .as_nanos();
239        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
240    }
241}