Skip to main content

oci_zero/
digest.rs

1//! OCI content digests and streaming verification.
2
3use core::fmt;
4
5use sha2::{Digest as _, Sha256};
6
7/// Number of bytes in a SHA-256 digest.
8pub const SHA256_SIZE: usize = 32;
9
10/// A supported OCI content digest.
11#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
12pub struct Digest([u8; SHA256_SIZE]);
13
14impl Digest {
15    /// Parses a canonical `sha256:<64 lowercase hexadecimal digits>` digest.
16    pub fn parse(value: &str) -> Result<Self, DigestError> {
17        let encoded = value
18            .strip_prefix("sha256:")
19            .ok_or(DigestError::UnsupportedAlgorithm)?;
20        if encoded.len() != SHA256_SIZE * 2 {
21            return Err(DigestError::InvalidLength);
22        }
23        let mut bytes = [0; SHA256_SIZE];
24        for (index, pair) in encoded.as_bytes().chunks_exact(2).enumerate() {
25            bytes[index] = (hex(pair[0])? << 4) | hex(pair[1])?;
26        }
27        Ok(Self(bytes))
28    }
29
30    pub const fn from_bytes(bytes: [u8; SHA256_SIZE]) -> Self {
31        Self(bytes)
32    }
33
34    pub const fn as_bytes(&self) -> &[u8; SHA256_SIZE] {
35        &self.0
36    }
37}
38
39impl fmt::Display for Digest {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter.write_str("sha256:")?;
42        for byte in self.0 {
43            write!(formatter, "{byte:02x}")?;
44        }
45        Ok(())
46    }
47}
48
49/// Incremental verifier for a descriptor payload.
50pub struct Verifier {
51    expected_digest: Digest,
52    expected_size: Option<u64>,
53    actual_size: u64,
54    hash: Sha256,
55}
56
57impl Verifier {
58    pub fn new(expected_digest: Digest, expected_size: u64) -> Self {
59        Self {
60            expected_digest,
61            expected_size: Some(expected_size),
62            actual_size: 0,
63            hash: Sha256::new(),
64        }
65    }
66
67    /// Creates a verifier for content whose size is not described separately.
68    pub fn digest_only(expected_digest: Digest) -> Self {
69        Self {
70            expected_digest,
71            expected_size: None,
72            actual_size: 0,
73            hash: Sha256::new(),
74        }
75    }
76
77    pub fn update(&mut self, bytes: &[u8]) -> Result<(), VerifyError> {
78        self.actual_size = self
79            .actual_size
80            .checked_add(bytes.len() as u64)
81            .ok_or(VerifyError::SizeOverflow)?;
82        self.hash.update(bytes);
83        Ok(())
84    }
85
86    pub const fn actual_size(&self) -> u64 {
87        self.actual_size
88    }
89
90    pub fn finish(&self) -> Result<(), VerifyError> {
91        if let Some(expected_size) = self.expected_size.filter(|size| *size != self.actual_size) {
92            return Err(VerifyError::SizeMismatch {
93                expected: expected_size,
94                actual: self.actual_size,
95            });
96        }
97        let actual = Digest::from_bytes(self.hash.clone().finalize().into());
98        if actual != self.expected_digest {
99            return Err(VerifyError::DigestMismatch {
100                expected: self.expected_digest,
101                actual,
102            });
103        }
104        Ok(())
105    }
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum DigestError {
110    UnsupportedAlgorithm,
111    InvalidLength,
112    InvalidEncoding,
113}
114
115impl fmt::Display for DigestError {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter.write_str(match self {
118            Self::UnsupportedAlgorithm => "unsupported digest algorithm",
119            Self::InvalidLength => "invalid SHA-256 digest length",
120            Self::InvalidEncoding => "invalid SHA-256 digest encoding",
121        })
122    }
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub enum VerifyError {
127    SizeOverflow,
128    SizeMismatch { expected: u64, actual: u64 },
129    DigestMismatch { expected: Digest, actual: Digest },
130}
131
132impl fmt::Display for VerifyError {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::SizeOverflow => formatter.write_str("content size overflow"),
136            Self::SizeMismatch { expected, actual } => {
137                write!(
138                    formatter,
139                    "size mismatch: expected {expected}, got {actual}"
140                )
141            }
142            Self::DigestMismatch { expected, actual } => {
143                write!(
144                    formatter,
145                    "digest mismatch: expected {expected}, got {actual}"
146                )
147            }
148        }
149    }
150}
151
152fn hex(byte: u8) -> Result<u8, DigestError> {
153    match byte {
154        b'0'..=b'9' => Ok(byte - b'0'),
155        b'a'..=b'f' => Ok(byte - b'a' + 10),
156        _ => Err(DigestError::InvalidEncoding),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use std::{format, string::ToString};
163
164    use super::{Digest, DigestError, Verifier, VerifyError};
165
166    #[test]
167    fn parses_and_formats_sha256() {
168        let text = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
169        assert_eq!(Digest::parse(text).unwrap().to_string(), text);
170    }
171
172    #[test]
173    fn reports_digest_parse_errors() {
174        assert_eq!(
175            Digest::parse(
176                "sha512:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
177            ),
178            Err(DigestError::UnsupportedAlgorithm)
179        );
180        assert_eq!(Digest::parse("sha256:00"), Err(DigestError::InvalidLength));
181        assert_eq!(
182            Digest::parse(
183                "sha256:ga7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
184            ),
185            Err(DigestError::InvalidEncoding)
186        );
187
188        assert_eq!(
189            DigestError::UnsupportedAlgorithm.to_string(),
190            "unsupported digest algorithm"
191        );
192        assert_eq!(
193            DigestError::InvalidLength.to_string(),
194            "invalid SHA-256 digest length"
195        );
196        assert_eq!(
197            DigestError::InvalidEncoding.to_string(),
198            "invalid SHA-256 digest encoding"
199        );
200    }
201
202    #[test]
203    fn verifies_fragmented_content() {
204        let digest = Digest::parse(
205            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
206        )
207        .unwrap();
208        let mut verifier = Verifier::new(digest, 3);
209        assert_eq!(verifier.actual_size(), 0);
210        verifier.update(b"a").unwrap();
211        assert_eq!(verifier.actual_size(), 1);
212        verifier.update(b"bc").unwrap();
213        assert_eq!(verifier.actual_size(), 3);
214        assert_eq!(verifier.finish(), Ok(()));
215
216        let mut verifier = Verifier::new(digest, 4);
217        verifier.update(b"abc").unwrap();
218        assert!(matches!(
219            verifier.finish(),
220            Err(VerifyError::SizeMismatch { .. })
221        ));
222    }
223
224    #[test]
225    fn formats_verification_errors() {
226        let expected = Digest::from_bytes([0x11; 32]);
227        let actual = Digest::from_bytes([0x22; 32]);
228
229        assert_eq!(
230            VerifyError::SizeOverflow.to_string(),
231            "content size overflow"
232        );
233        assert_eq!(
234            VerifyError::SizeMismatch {
235                expected: 7,
236                actual: 3,
237            }
238            .to_string(),
239            "size mismatch: expected 7, got 3"
240        );
241        assert_eq!(
242            VerifyError::DigestMismatch { expected, actual }.to_string(),
243            format!("digest mismatch: expected {expected}, got {actual}")
244        );
245    }
246}