Skip to main content

ic_backup/model/artifacts/
mod.rs

1//! Canonical SHA-256 artifact metadata; filesystem effects belong to ops.
2
3use ic_host_artifacts::artifact::Sha256Digest;
4use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
5use thiserror::Error;
6
7/// Maintained checksum record with a lowercase, validated SHA-256 digest.
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
9#[serde(try_from = "ChecksumFields")]
10pub struct ArtifactChecksumRecord {
11    hash: String,
12}
13
14#[derive(Deserialize)]
15#[serde(deny_unknown_fields)]
16struct ChecksumFields {
17    algorithm: String,
18    hash: String,
19}
20
21impl TryFrom<ChecksumFields> for ArtifactChecksumRecord {
22    type Error = ChecksumError;
23
24    fn try_from(value: ChecksumFields) -> Result<Self, Self::Error> {
25        if value.algorithm != "sha256" {
26            return Err(ChecksumError::UnsupportedAlgorithm(value.algorithm));
27        }
28        Self::from_hash(&value.hash)
29    }
30}
31
32impl Serialize for ArtifactChecksumRecord {
33    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
34        let mut record = serializer.serialize_struct("ArtifactChecksumRecord", 2)?;
35        record.serialize_field("algorithm", self.algorithm())?;
36        record.serialize_field("hash", self.hash())?;
37        record.end()
38    }
39}
40
41impl ArtifactChecksumRecord {
42    /// Compute checksum metadata from exact bytes.
43    #[must_use]
44    pub fn from_bytes(bytes: &[u8]) -> Self {
45        Self {
46            hash: Sha256Digest::compute(bytes).to_string(),
47        }
48    }
49
50    /// Validate and normalize an existing digest.
51    ///
52    /// # Errors
53    /// Returns [`ChecksumError::InvalidHash`] unless the input has 64 hex digits.
54    pub fn from_hash(hash: &str) -> Result<Self, ChecksumError> {
55        Ok(Self {
56            hash: canonical_hash(hash)?,
57        })
58    }
59
60    /// Wrap an already-computed raw SHA-256 digest without hashing it again.
61    ///
62    /// The bytes identify content; this does not authenticate its source or custody.
63    #[must_use]
64    pub fn from_digest(digest: [u8; 32]) -> Self {
65        Self {
66            hash: Sha256Digest::from_bytes(digest).to_string(),
67        }
68    }
69
70    /// Move the admitted canonical text into the existing v1 record owner.
71    pub(crate) fn into_hash(self) -> String {
72        self.hash
73    }
74
75    pub(crate) fn sha256(&self) -> Sha256Digest {
76        self.hash
77            .parse()
78            .expect("checksum records contain validated SHA-256 text")
79    }
80
81    /// Return the maintained algorithm identifier.
82    #[must_use]
83    pub const fn algorithm(&self) -> &'static str {
84        "sha256"
85    }
86
87    /// Return the canonical lowercase hexadecimal digest.
88    #[must_use]
89    pub fn hash(&self) -> &str {
90        &self.hash
91    }
92
93    /// Compare a digest, accepting equivalent uppercase and lowercase hex.
94    ///
95    /// # Errors
96    /// Returns a typed malformed-hash or checksum-mismatch error.
97    pub fn verify(&self, expected_hash: &str) -> Result<(), ChecksumError> {
98        let expected = canonical_hash(expected_hash)?;
99        if self.hash == expected {
100            Ok(())
101        } else {
102            Err(ChecksumError::ChecksumMismatch {
103                expected,
104                actual: self.hash.clone(),
105            })
106        }
107    }
108}
109
110pub(crate) fn canonical_hash(hash: &str) -> Result<String, ChecksumError> {
111    let normalized = hash.to_ascii_lowercase();
112    normalized
113        .parse::<Sha256Digest>()
114        .map_err(|_| ChecksumError::InvalidHash(hash.to_owned()))?;
115    Ok(normalized)
116}
117
118/// Typed checksum-record validation failure.
119#[derive(Debug, Error)]
120pub enum ChecksumError {
121    /// The expected and observed exact bytes have different digests.
122    #[error("checksum mismatch: expected {expected}, actual {actual}")]
123    ChecksumMismatch {
124        /// Canonical expected digest.
125        expected: String,
126        /// Canonical observed digest.
127        actual: String,
128    },
129    /// A digest is not exactly 64 hexadecimal digits.
130    #[error("invalid SHA-256 checksum: {0}")]
131    InvalidHash(String),
132    /// Only the maintained SHA-256 algorithm is accepted.
133    #[error("unsupported checksum algorithm {0}")]
134    UnsupportedAlgorithm(String),
135}
136
137#[cfg(test)]
138mod tests;