Skip to main content

canic_backup/artifacts/
mod.rs

1//! Module: artifacts
2//!
3//! Responsibility: compute and validate backup artifact checksums.
4//! Does not own: snapshot capture, artifact storage, or restore planning.
5//! Boundary: provides deterministic checksum primitives to backup workflows.
6
7#[cfg(test)]
8mod tests;
9
10use crate::hash::{hex_bytes, sha256_hex};
11
12use std::{
13    fs::{self, File},
14    io::{self, Read},
15    path::{Path, PathBuf},
16};
17
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use thiserror::Error as ThisError;
21
22const SHA256_ALGORITHM: &str = "sha256";
23
24///
25/// ArtifactChecksum
26///
27/// SHA-256 checksum metadata for a backup artifact file or directory.
28/// Owned by backup artifact support and serialized into manifests.
29///
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32pub struct ArtifactChecksum {
33    pub algorithm: String,
34    pub hash: String,
35}
36
37impl ArtifactChecksum {
38    /// Compute a SHA-256 checksum from in-memory bytes.
39    #[must_use]
40    pub fn from_bytes(bytes: &[u8]) -> Self {
41        Self {
42            algorithm: SHA256_ALGORITHM.to_string(),
43            hash: sha256_hex(bytes),
44        }
45    }
46
47    /// Compute a SHA-256 checksum from one filesystem file.
48    pub fn from_file(path: &Path) -> Result<Self, ArtifactChecksumError> {
49        let mut file = File::open(path)?;
50        Self::from_reader(&mut file)
51    }
52
53    /// Compute a file checksum from an already-open artifact descriptor.
54    pub(crate) fn from_reader(reader: &mut impl Read) -> Result<Self, ArtifactChecksumError> {
55        let mut hasher = Sha256::new();
56        let mut buffer = vec![0u8; 64 * 1024];
57
58        loop {
59            let read = reader.read(&mut buffer)?;
60            if read == 0 {
61                break;
62            }
63            hasher.update(&buffer[..read]);
64        }
65
66        Ok(Self {
67            algorithm: SHA256_ALGORITHM.to_string(),
68            hash: hex_bytes(hasher.finalize()),
69        })
70    }
71
72    /// Compute a SHA-256 checksum from a file or deterministic directory listing.
73    pub fn from_path(path: &Path) -> Result<Self, ArtifactChecksumError> {
74        if path.is_dir() {
75            Self::from_directory(path)
76        } else {
77            Self::from_file(path)
78        }
79    }
80
81    /// Compute a deterministic SHA-256 checksum over all files in a directory.
82    pub fn from_directory(path: &Path) -> Result<Self, ArtifactChecksumError> {
83        let mut files = Vec::new();
84        collect_files(path, path, &mut files)?;
85        files.sort();
86
87        let checksums = files
88            .into_iter()
89            .map(|relative_path| {
90                let checksum = Self::from_file(&path.join(&relative_path))?;
91                Ok((relative_path, checksum))
92            })
93            .collect::<Result<Vec<_>, ArtifactChecksumError>>()?;
94        Ok(Self::from_relative_file_checksums(checksums))
95    }
96
97    /// Compose the maintained directory checksum from relative file checksums.
98    pub(crate) fn from_relative_file_checksums(mut files: Vec<(PathBuf, Self)>) -> Self {
99        files.sort_by(|left, right| left.0.cmp(&right.0));
100        let mut hasher = Sha256::new();
101        for (relative_path, file_checksum) in files {
102            hasher.update(relative_path.to_string_lossy().as_bytes());
103            hasher.update([0]);
104            hasher.update(file_checksum.hash.as_bytes());
105            hasher.update(*b"\n");
106        }
107
108        Self {
109            algorithm: SHA256_ALGORITHM.to_string(),
110            hash: hex_bytes(hasher.finalize()),
111        }
112    }
113
114    /// Verify that the checksum matches an expected SHA-256 hash.
115    pub fn verify(&self, expected_hash: &str) -> Result<(), ArtifactChecksumError> {
116        if self.algorithm != SHA256_ALGORITHM {
117            return Err(ArtifactChecksumError::UnsupportedAlgorithm(
118                self.algorithm.clone(),
119            ));
120        }
121
122        if self.hash == expected_hash {
123            Ok(())
124        } else {
125            Err(ArtifactChecksumError::ChecksumMismatch {
126                expected: expected_hash.to_string(),
127                actual: self.hash.clone(),
128            })
129        }
130    }
131}
132
133///
134/// ArtifactChecksumError
135///
136/// Typed checksum failure returned by backup artifact hashing and validation.
137/// Owned by backup artifact support and surfaced to snapshot/runner callers.
138///
139
140#[derive(Debug, ThisError)]
141pub enum ArtifactChecksumError {
142    #[error(transparent)]
143    Io(#[from] io::Error),
144
145    #[error("unsupported checksum algorithm {0}")]
146    UnsupportedAlgorithm(String),
147
148    #[error("checksum mismatch: expected {expected}, actual {actual}")]
149    ChecksumMismatch { expected: String, actual: String },
150}
151
152// Recursively collect file paths relative to a directory root.
153fn collect_files(
154    root: &Path,
155    path: &Path,
156    files: &mut Vec<PathBuf>,
157) -> Result<(), ArtifactChecksumError> {
158    for entry in fs::read_dir(path)? {
159        let entry = entry?;
160        let path = entry.path();
161        if path.is_dir() {
162            collect_files(root, &path, files)?;
163        } else if path.is_file() {
164            let relative = path
165                .strip_prefix(root)
166                .map_err(io::Error::other)?
167                .to_path_buf();
168            files.push(relative);
169        }
170    }
171    Ok(())
172}