Skip to main content

canic_backup/artifacts/
mod.rs

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