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        let mut hasher = Sha256::new();
51        let mut buffer = vec![0u8; 64 * 1024];
52
53        loop {
54            let read = file.read(&mut buffer)?;
55            if read == 0 {
56                break;
57            }
58            hasher.update(&buffer[..read]);
59        }
60
61        Ok(Self {
62            algorithm: SHA256_ALGORITHM.to_string(),
63            hash: hex_bytes(hasher.finalize()),
64        })
65    }
66
67    /// Compute a SHA-256 checksum from a file or deterministic directory listing.
68    pub fn from_path(path: &Path) -> Result<Self, ArtifactChecksumError> {
69        if path.is_dir() {
70            Self::from_directory(path)
71        } else {
72            Self::from_file(path)
73        }
74    }
75
76    /// Compute a deterministic SHA-256 checksum over all files in a directory.
77    pub fn from_directory(path: &Path) -> Result<Self, ArtifactChecksumError> {
78        let mut files = Vec::new();
79        collect_files(path, path, &mut files)?;
80        files.sort();
81
82        let mut hasher = Sha256::new();
83        for relative_path in files {
84            let full_path = path.join(&relative_path);
85            let file_checksum = Self::from_file(&full_path)?;
86            hasher.update(relative_path.to_string_lossy().as_bytes());
87            hasher.update([0]);
88            hasher.update(file_checksum.hash.as_bytes());
89            hasher.update([b'\n']);
90        }
91
92        Ok(Self {
93            algorithm: SHA256_ALGORITHM.to_string(),
94            hash: hex_bytes(hasher.finalize()),
95        })
96    }
97
98    /// Verify that the checksum matches an expected SHA-256 hash.
99    pub fn verify(&self, expected_hash: &str) -> Result<(), ArtifactChecksumError> {
100        if self.algorithm != SHA256_ALGORITHM {
101            return Err(ArtifactChecksumError::UnsupportedAlgorithm(
102                self.algorithm.clone(),
103            ));
104        }
105
106        if self.hash == expected_hash {
107            Ok(())
108        } else {
109            Err(ArtifactChecksumError::ChecksumMismatch {
110                expected: expected_hash.to_string(),
111                actual: self.hash.clone(),
112            })
113        }
114    }
115}
116
117///
118/// ArtifactChecksumError
119///
120/// Typed checksum failure returned by backup artifact hashing and validation.
121/// Owned by backup artifact support and surfaced to snapshot/runner callers.
122///
123
124#[derive(Debug, ThisError)]
125pub enum ArtifactChecksumError {
126    #[error(transparent)]
127    Io(#[from] io::Error),
128
129    #[error("unsupported checksum algorithm {0}")]
130    UnsupportedAlgorithm(String),
131
132    #[error("checksum mismatch: expected {expected}, actual {actual}")]
133    ChecksumMismatch { expected: String, actual: String },
134}
135
136// Recursively collect file paths relative to a directory root.
137fn collect_files(
138    root: &Path,
139    path: &Path,
140    files: &mut Vec<PathBuf>,
141) -> Result<(), ArtifactChecksumError> {
142    for entry in fs::read_dir(path)? {
143        let entry = entry?;
144        let path = entry.path();
145        if path.is_dir() {
146            collect_files(root, &path, files)?;
147        } else if path.is_file() {
148            let relative = path
149                .strip_prefix(root)
150                .map_err(io::Error::other)?
151                .to_path_buf();
152            files.push(relative);
153        }
154    }
155    Ok(())
156}