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
7mod secure;
8#[cfg(test)]
9mod tests;
10
11use crate::hash::{hex_bytes, sha256_hex};
12
13use std::{
14    io::{self, Read, Write},
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        secure::checksum_path(path, secure::ExpectedArtifactType::File)
60    }
61
62    /// Compute a file checksum from an already-open artifact descriptor.
63    pub(crate) fn from_reader(reader: &mut impl Read) -> Result<Self, ArtifactChecksumError> {
64        let mut hasher = Sha256::new();
65        let mut buffer = vec![0u8; 64 * 1024];
66
67        loop {
68            let read = reader.read(&mut buffer)?;
69            if read == 0 {
70                break;
71            }
72            hasher.update(&buffer[..read]);
73        }
74
75        Ok(Self {
76            algorithm: SHA256_ALGORITHM.to_string(),
77            hash: hex_bytes(hasher.finalize()),
78        })
79    }
80
81    pub(crate) fn copy_from_reader(
82        reader: &mut impl Read,
83        writer: &mut impl Write,
84    ) -> Result<Self, ArtifactChecksumError> {
85        let mut hasher = Sha256::new();
86        let mut buffer = vec![0u8; 64 * 1024];
87
88        loop {
89            let read = reader.read(&mut buffer)?;
90            if read == 0 {
91                break;
92            }
93            writer.write_all(&buffer[..read])?;
94            hasher.update(&buffer[..read]);
95        }
96
97        Ok(Self {
98            algorithm: SHA256_ALGORITHM.to_string(),
99            hash: hex_bytes(hasher.finalize()),
100        })
101    }
102
103    /// Compute a SHA-256 checksum from a file or deterministic directory listing.
104    pub fn from_path(path: &Path) -> Result<Self, ArtifactChecksumError> {
105        secure::checksum_path(path, secure::ExpectedArtifactType::Any)
106    }
107
108    /// Compute a deterministic SHA-256 checksum over all files in a directory.
109    pub fn from_directory(path: &Path) -> Result<Self, ArtifactChecksumError> {
110        secure::checksum_path(path, secure::ExpectedArtifactType::Directory)
111    }
112
113    /// Compose the maintained directory checksum from relative file checksums.
114    pub(crate) fn from_relative_file_checksums(mut files: Vec<(PathBuf, Self)>) -> Self {
115        files.sort_by(|left, right| left.0.cmp(&right.0));
116        let mut hasher = Sha256::new();
117        for (relative_path, file_checksum) in files {
118            hasher.update(relative_path.to_string_lossy().as_bytes());
119            hasher.update([0]);
120            hasher.update(file_checksum.hash.as_bytes());
121            hasher.update(*b"\n");
122        }
123
124        Self {
125            algorithm: SHA256_ALGORITHM.to_string(),
126            hash: hex_bytes(hasher.finalize()),
127        }
128    }
129
130    /// Verify that the checksum matches an expected SHA-256 hash.
131    pub fn verify(&self, expected_hash: &str) -> Result<(), ArtifactChecksumError> {
132        self.validate()?;
133        Self::validate_hash(expected_hash)?;
134
135        if self.hash.eq_ignore_ascii_case(expected_hash) {
136            Ok(())
137        } else {
138            Err(ArtifactChecksumError::ChecksumMismatch {
139                expected: expected_hash.to_string(),
140                actual: self.hash.clone(),
141            })
142        }
143    }
144
145    /// Validate checksum metadata without comparing artifact bytes.
146    pub fn validate(&self) -> Result<(), ArtifactChecksumError> {
147        Self::validate_algorithm(&self.algorithm)?;
148        Self::validate_hash(&self.hash)
149    }
150
151    /// Validate one artifact-checksum algorithm identifier.
152    pub(crate) fn validate_algorithm(algorithm: &str) -> Result<(), ArtifactChecksumError> {
153        if algorithm != SHA256_ALGORITHM {
154            return Err(ArtifactChecksumError::UnsupportedAlgorithm(
155                algorithm.to_string(),
156            ));
157        }
158
159        Ok(())
160    }
161
162    /// Validate one artifact-checksum hash representation.
163    pub(crate) fn validate_hash(hash: &str) -> Result<(), ArtifactChecksumError> {
164        if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
165            return Err(ArtifactChecksumError::InvalidHash(hash.to_string()));
166        }
167
168        Ok(())
169    }
170
171    pub(crate) fn from_relative_path_no_follow(
172        root: &Path,
173        relative: &Path,
174    ) -> Result<Self, ArtifactChecksumError> {
175        secure::checksum_relative_path(root, relative)
176    }
177
178    pub(crate) fn stage_relative_path_no_follow(
179        root: &Path,
180        relative: &Path,
181        destination: &Path,
182    ) -> Result<Self, ArtifactChecksumError> {
183        secure::stage_relative_path(root, relative, destination)
184    }
185}
186
187///
188/// ArtifactChecksumError
189///
190/// Typed checksum failure returned by backup artifact hashing and validation.
191/// Owned by backup artifact support and surfaced to snapshot/runner callers.
192///
193
194#[derive(Debug, ThisError)]
195pub enum ArtifactChecksumError {
196    #[error("checksum mismatch: expected {expected}, actual {actual}")]
197    ChecksumMismatch { expected: String, actual: String },
198
199    #[error("invalid SHA-256 checksum: {0}")]
200    InvalidHash(String),
201
202    #[error(transparent)]
203    Io(#[from] io::Error),
204
205    #[error("unsupported checksum algorithm {0}")]
206    UnsupportedAlgorithm(String),
207
208    #[error("unsupported artifact filesystem entry at {path}: {kind}")]
209    UnsupportedEntry { path: String, kind: String },
210
211    #[error("secure artifact traversal is unsupported on platform {0}")]
212    UnsupportedPlatform(&'static str),
213}