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