canic_backup/artifacts/
mod.rs1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17pub struct ArtifactChecksum {
18 pub algorithm: String,
19 pub hash: String,
20}
21
22impl ArtifactChecksum {
23 #[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 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 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 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 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#[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
118fn 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
140fn sha256_hex(bytes: &[u8]) -> String {
142 digest_hex(Sha256::digest(bytes))
143}
144
145fn 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
156const 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 #[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 #[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 #[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 #[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 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}