Skip to main content

canic_backup/artifacts/
mod.rs

1//! Module: artifacts
2//!
3//! Responsibility: project shared artifact checksums into Canic manifests and validate them.
4//! Does not own: snapshot capture, artifact storage, or restore planning.
5//! Boundary: IC Backup owns no-follow traversal, staging and publication; Canic owns manifests.
6
7#[cfg(test)]
8mod tests;
9
10use std::{io, path::Path};
11#[cfg(test)]
12use std::{io::Read, path::PathBuf};
13
14use ic_backup::{
15    model::artifacts::ArtifactChecksumRecord,
16    ops::artifacts::{self, ArtifactError},
17};
18use serde::{Deserialize, Serialize};
19use thiserror::Error as ThisError;
20
21const SHA256_ALGORITHM: &str = "sha256";
22
23pub(crate) fn artifact_path_segment(value: &str) -> String {
24    value
25        .chars()
26        .map(|ch| match ch {
27            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch,
28            _ => '_',
29        })
30        .collect()
31}
32
33///
34/// ArtifactChecksum
35///
36/// SHA-256 checksum metadata for a backup artifact file or directory.
37/// Owned by backup artifact support and serialized into manifests.
38///
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41pub struct ArtifactChecksum {
42    pub algorithm: String,
43    pub hash: String,
44}
45
46impl ArtifactChecksum {
47    /// Compute a SHA-256 checksum from in-memory bytes.
48    #[must_use]
49    pub fn from_bytes(bytes: &[u8]) -> Self {
50        Self::from_record(ArtifactChecksumRecord::from_bytes(bytes))
51    }
52
53    /// Compute a SHA-256 checksum from one filesystem file.
54    pub fn from_file(path: &Path) -> Result<Self, ArtifactChecksumError> {
55        artifacts::checksum_file(path)
56            .map(Self::from_record)
57            .map_err(artifact_error)
58    }
59
60    /// Compute a file checksum from an already-open artifact descriptor.
61    #[cfg(test)]
62    pub(crate) fn from_reader(reader: &mut impl Read) -> Result<Self, ArtifactChecksumError> {
63        artifacts::checksum_reader(reader)
64            .map(Self::from_record)
65            .map_err(artifact_error)
66    }
67
68    /// Compute a SHA-256 checksum from a file or deterministic directory listing.
69    pub fn from_path(path: &Path) -> Result<Self, ArtifactChecksumError> {
70        artifacts::checksum_path(path)
71            .map(Self::from_record)
72            .map_err(artifact_error)
73    }
74
75    /// Compute a deterministic SHA-256 checksum over all files in a directory.
76    pub fn from_directory(path: &Path) -> Result<Self, ArtifactChecksumError> {
77        artifacts::checksum_directory(path)
78            .map(Self::from_record)
79            .map_err(artifact_error)
80    }
81
82    /// Compose the maintained directory checksum from relative file checksums.
83    #[cfg(test)]
84    pub(crate) fn from_relative_file_checksums(
85        files: Vec<(PathBuf, Self)>,
86    ) -> Result<Self, ArtifactChecksumError> {
87        let files = files
88            .into_iter()
89            .map(|(path, checksum)| {
90                checksum.validate()?;
91                ArtifactChecksumRecord::from_hash(&checksum.hash)
92                    .map(|record| (path, record))
93                    .map_err(|_| ArtifactChecksumError::InvalidHash(checksum.hash))
94            })
95            .collect::<Result<_, _>>()?;
96        artifacts::checksum_relative_files(files)
97            .map(Self::from_record)
98            .map_err(|error| artifact_error(error.into()))
99    }
100
101    /// Verify that the checksum matches an expected SHA-256 hash.
102    pub fn verify(&self, expected_hash: &str) -> Result<(), ArtifactChecksumError> {
103        self.validate()?;
104        Self::validate_hash(expected_hash)?;
105
106        if self.hash.eq_ignore_ascii_case(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    /// Validate checksum metadata without comparing artifact bytes.
117    pub fn validate(&self) -> Result<(), ArtifactChecksumError> {
118        Self::validate_algorithm(&self.algorithm)?;
119        Self::validate_hash(&self.hash)
120    }
121
122    /// Validate one artifact-checksum algorithm identifier.
123    pub(crate) fn validate_algorithm(algorithm: &str) -> Result<(), ArtifactChecksumError> {
124        if algorithm != SHA256_ALGORITHM {
125            return Err(ArtifactChecksumError::UnsupportedAlgorithm(
126                algorithm.to_string(),
127            ));
128        }
129
130        Ok(())
131    }
132
133    /// Validate one artifact-checksum hash representation.
134    pub(crate) fn validate_hash(hash: &str) -> Result<(), ArtifactChecksumError> {
135        if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
136            return Err(ArtifactChecksumError::InvalidHash(hash.to_string()));
137        }
138
139        Ok(())
140    }
141
142    pub(crate) fn from_relative_path_no_follow(
143        root: &Path,
144        relative: &Path,
145    ) -> Result<Self, ArtifactChecksumError> {
146        artifacts::checksum_relative_path(root, relative)
147            .map(Self::from_record)
148            .map_err(artifact_error)
149    }
150
151    pub(crate) fn stage_relative_path_no_follow(
152        root: &Path,
153        relative: &Path,
154        destination: &Path,
155    ) -> Result<Self, ArtifactChecksumError> {
156        artifacts::stage_relative_path(root, relative, destination)
157            .map(Self::from_record)
158            .map_err(artifact_error)
159    }
160
161    fn from_record(record: ArtifactChecksumRecord) -> Self {
162        Self {
163            algorithm: record.algorithm().to_owned(),
164            hash: record.hash().to_owned(),
165        }
166    }
167}
168
169fn artifact_error(error: ArtifactError) -> ArtifactChecksumError {
170    match error {
171        ArtifactError::Io(error) => ArtifactChecksumError::Io(error),
172        error => ArtifactChecksumError::Artifact(error),
173    }
174}
175
176///
177/// ArtifactChecksumError
178///
179/// Typed checksum failure returned by backup artifact hashing and validation.
180/// Owned by backup artifact support and surfaced to snapshot/runner callers.
181///
182
183#[derive(Debug, ThisError)]
184pub enum ArtifactChecksumError {
185    #[error("checksum mismatch: expected {expected}, actual {actual}")]
186    ChecksumMismatch { expected: String, actual: String },
187
188    #[error("invalid SHA-256 checksum: {0}")]
189    InvalidHash(String),
190
191    #[error(transparent)]
192    Io(#[from] io::Error),
193
194    #[error("unsupported checksum algorithm {0}")]
195    UnsupportedAlgorithm(String),
196
197    /// Shared no-follow traversal, path-identity or platform-admission failure.
198    #[error(transparent)]
199    Artifact(ArtifactError),
200}