Skip to main content

ic_backup/model/artifacts/
mod.rs

1//! Canonical SHA-256 artifact metadata; filesystem effects belong to ops.
2
3use crate::hash::{hex_bytes, sha256_hex};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// Maintained checksum record with a lowercase, validated SHA-256 digest.
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[serde(try_from = "ChecksumFields")]
10pub struct ArtifactChecksumRecord {
11    algorithm: String,
12    hash: String,
13}
14
15#[derive(Deserialize)]
16#[serde(deny_unknown_fields)]
17struct ChecksumFields {
18    algorithm: String,
19    hash: String,
20}
21
22impl TryFrom<ChecksumFields> for ArtifactChecksumRecord {
23    type Error = ChecksumError;
24
25    fn try_from(value: ChecksumFields) -> Result<Self, Self::Error> {
26        if value.algorithm != "sha256" {
27            return Err(ChecksumError::UnsupportedAlgorithm(value.algorithm));
28        }
29        Self::from_hash(&value.hash)
30    }
31}
32
33impl ArtifactChecksumRecord {
34    /// Compute checksum metadata from exact bytes.
35    #[must_use]
36    pub fn from_bytes(bytes: &[u8]) -> Self {
37        Self {
38            algorithm: "sha256".to_owned(),
39            hash: sha256_hex(bytes),
40        }
41    }
42
43    /// Validate and normalize an existing digest.
44    ///
45    /// # Errors
46    /// Returns [`ChecksumError::InvalidHash`] unless the input has 64 hex digits.
47    pub fn from_hash(hash: &str) -> Result<Self, ChecksumError> {
48        validate_hash(hash)?;
49        Ok(Self {
50            algorithm: "sha256".to_owned(),
51            hash: hash.to_ascii_lowercase(),
52        })
53    }
54
55    pub(crate) fn from_digest(digest: [u8; 32]) -> Self {
56        Self {
57            algorithm: "sha256".to_owned(),
58            hash: hex_bytes(digest),
59        }
60    }
61
62    /// Return the maintained algorithm identifier.
63    #[must_use]
64    pub fn algorithm(&self) -> &str {
65        &self.algorithm
66    }
67
68    /// Return the canonical lowercase hexadecimal digest.
69    #[must_use]
70    pub fn hash(&self) -> &str {
71        &self.hash
72    }
73
74    /// Compare a digest, accepting equivalent uppercase and lowercase hex.
75    ///
76    /// # Errors
77    /// Returns a typed malformed-hash or checksum-mismatch error.
78    pub fn verify(&self, expected_hash: &str) -> Result<(), ChecksumError> {
79        validate_hash(expected_hash)?;
80        if self.hash.eq_ignore_ascii_case(expected_hash) {
81            Ok(())
82        } else {
83            Err(ChecksumError::ChecksumMismatch {
84                expected: expected_hash.to_ascii_lowercase(),
85                actual: self.hash.clone(),
86            })
87        }
88    }
89}
90
91fn validate_hash(hash: &str) -> Result<(), ChecksumError> {
92    if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
93        return Err(ChecksumError::InvalidHash(hash.to_owned()));
94    }
95    Ok(())
96}
97
98/// Typed checksum-record validation failure.
99#[derive(Debug, Error)]
100pub enum ChecksumError {
101    /// The expected and observed exact bytes have different digests.
102    #[error("checksum mismatch: expected {expected}, actual {actual}")]
103    ChecksumMismatch {
104        /// Canonical expected digest.
105        expected: String,
106        /// Canonical observed digest.
107        actual: String,
108    },
109    /// A digest is not exactly 64 hexadecimal digits.
110    #[error("invalid SHA-256 checksum: {0}")]
111    InvalidHash(String),
112    /// Only the maintained SHA-256 algorithm is accepted.
113    #[error("unsupported checksum algorithm {0}")]
114    UnsupportedAlgorithm(String),
115}
116
117#[cfg(test)]
118mod tests;