Skip to main content

oci_client/
digest.rs

1//! Errors and functions for validating digests
2
3use http::HeaderMap;
4use sha2::Digest as _;
5use std::fmt::{Display, Formatter};
6
7use crate::sha256_digest;
8
9pub const DOCKER_DIGEST_HEADER: &str = "Docker-Content-Digest";
10
11pub type Result<T> = std::result::Result<T, DigestError>;
12
13/// Errors that can occur when validating digests
14#[derive(Debug, thiserror::Error)]
15pub enum DigestError {
16    /// Invalid digest header
17    #[error("Invalid digest header: {0}")]
18    InvalidHeader(#[from] http::header::ToStrError),
19    /// Invalid digest algorithm found
20    #[error("Unsupported digest algorithm: {0}")]
21    UnsupportedAlgorithm(String),
22    /// Missing digest algorithm
23    #[error("Missing digest algorithm")]
24    MissingAlgorithm,
25    /// Digest verification failed
26    #[error("Invalid digest. Expected {expected}, got {actual}")]
27    VerificationError {
28        /// Expected digest
29        expected: String,
30        /// Actual digest
31        actual: String,
32    },
33}
34
35/// A convenience struct for parsing a digest value with an algorithm
36#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
37pub struct Digest<'a> {
38    pub algorithm: &'a str,
39    pub digest: &'a str,
40}
41
42impl<'a> Display for Digest<'a> {
43    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
44        let Digest { algorithm, digest } = self;
45        write!(f, "{algorithm}:{digest}")
46    }
47}
48
49impl<'a> Digest<'a> {
50    /// Create a new digest from a str. This isn't using `FromStr` because we can't use lifetimes
51    /// properly when implementing the trait
52    pub fn new(digest: &'a str) -> Result<Self> {
53        let (algorithm, digest) = digest
54            .split_once(':')
55            .ok_or(DigestError::MissingAlgorithm)?;
56        Ok(Self { algorithm, digest })
57    }
58}
59
60/// Helper wrapper around various digest algorithms to make it easier to use them with our blob
61/// utils. This has to be an enum because the digest algorithms aren't object safe so we can't box
62/// dynner them
63pub(crate) enum Digester {
64    Sha256(sha2::Sha256),
65    Sha384(sha2::Sha384),
66    Sha512(sha2::Sha512),
67}
68
69impl Digester {
70    pub fn new(digest: &str) -> Result<Self> {
71        let parsed_digest = Digest::new(digest)?;
72
73        match parsed_digest.algorithm {
74            "sha256" => Ok(Digester::Sha256(sha2::Sha256::new())),
75            "sha384" => Ok(Digester::Sha384(sha2::Sha384::new())),
76            "sha512" => Ok(Digester::Sha512(sha2::Sha512::new())),
77            // We already check this above when parsing, but just in case, we return the error as
78            // well here
79            _ => Err(DigestError::UnsupportedAlgorithm(
80                parsed_digest.algorithm.to_string(),
81            )),
82        }
83    }
84
85    pub fn update(&mut self, data: impl AsRef<[u8]>) {
86        match self {
87            Self::Sha256(d) => d.update(data),
88            Self::Sha384(d) => d.update(data),
89            Self::Sha512(d) => d.update(data),
90        }
91    }
92
93    pub fn finalize(&mut self) -> String {
94        match self {
95            Self::Sha256(d) => format!("sha256:{:x}", d.finalize_reset()),
96            Self::Sha384(d) => format!("sha384:{:x}", d.finalize_reset()),
97            Self::Sha512(d) => format!("sha512:{:x}", d.finalize_reset()),
98        }
99    }
100}
101
102/// Helper for extracting `Docker-Content-Digest` header from manifest GET or HEAD request.
103pub fn digest_header_value(headers: HeaderMap) -> Result<Option<String>> {
104    headers
105        .get(DOCKER_DIGEST_HEADER)
106        .and_then(|hv| {
107            hv.to_str()
108                // Treat present but empty header as missing
109                .map(|s| (!s.is_empty()).then(|| s.to_string()))
110                .transpose()
111        })
112        .transpose()
113        .map_err(DigestError::from)
114}
115
116/// Given the optional digest header value and digest of the reference, returns the digest of the
117/// content, validating that the digest of the content matches the proper digest. If neither a
118/// header digest or a reference digest is provided, then the body is digested and returned as the
119/// digest. If both digests are provided, but they use different algorithms, then the header digest
120/// is returned after validation as according to the spec it is the "canonical" digest for the given
121/// content.
122pub fn validate_digest(
123    body: &[u8],
124    digest_header: Option<String>,
125    reference_digest: Option<&str>,
126) -> Result<String> {
127    let digest_header = digest_header.as_ref().map(|s| Digest::new(s)).transpose()?;
128    let reference_digest = reference_digest.map(Digest::new).transpose()?;
129    match (digest_header, reference_digest) {
130        // If both digests are equal, then just calculate once
131        (Some(digest), Some(reference)) if digest == reference => {
132            calculate_and_validate(body, digest)
133        }
134        (Some(digest), Some(reference)) => {
135            calculate_and_validate(body, reference)?;
136            calculate_and_validate(body, digest)
137        }
138        (Some(digest), None) => calculate_and_validate(body, digest),
139        (None, Some(reference)) => calculate_and_validate(body, reference),
140        // If we have neither, just digest the body
141        (None, None) => Ok(sha256_digest(body)),
142    }
143}
144
145/// Helper for calculating and validating the digest of the given content
146fn calculate_and_validate(content: &[u8], parsed_digest: Digest) -> Result<String> {
147    let digest_calculated = match parsed_digest.algorithm {
148        "sha256" => format!("{:x}", sha2::Sha256::digest(content)),
149        "sha384" => format!("{:x}", sha2::Sha384::digest(content)),
150        "sha512" => format!("{:x}", sha2::Sha512::digest(content)),
151        other => return Err(DigestError::UnsupportedAlgorithm(other.to_string())),
152    };
153    let hex = Digest {
154        algorithm: parsed_digest.algorithm,
155        digest: &digest_calculated,
156    };
157    tracing::debug!(%hex, "Computed digest of payload");
158    if hex != parsed_digest {
159        return Err(DigestError::VerificationError {
160            expected: parsed_digest.to_string(),
161            actual: Digest {
162                algorithm: parsed_digest.algorithm,
163                digest: &digest_calculated,
164            }
165            .to_string(),
166        });
167    }
168    Ok(hex.to_string())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_validate_digest() {
177        let body = b"hello world";
178        let digest_sha256 = format!("sha256:{:x}", sha2::Sha256::digest(body));
179        let digest_sha384 = format!("sha384:{:x}", sha2::Sha384::digest(body));
180
181        // Test case 1: Both digests are equal
182        assert_eq!(
183            validate_digest(body, Some(digest_sha256.clone()), Some(&digest_sha256))
184                .expect("Failed to validate digest with matching header and reference"),
185            digest_sha256
186        );
187
188        // Test case 2: Different digests
189        assert_eq!(
190            validate_digest(body, Some(digest_sha256.clone()), Some(&digest_sha384))
191                .expect("Failed to validate digest with different header and reference"),
192            digest_sha256
193        );
194
195        // Test case 3: Only digest_header
196        assert_eq!(
197            validate_digest(body, Some(digest_sha256.clone()), None)
198                .expect("Failed to validate digest with only header"),
199            digest_sha256
200        );
201
202        // Test case 4: Only reference_digest
203        assert_eq!(
204            validate_digest(body, None, Some(&digest_sha384))
205                .expect("Failed to validate digest with only reference"),
206            digest_sha384
207        );
208
209        // Test case 5: No digests provided
210        assert_eq!(
211            validate_digest(body, None, None)
212                .expect("Failed to validate digest with no digests provided"),
213            digest_sha256
214        );
215
216        // Test case 6: Invalid digest
217        let invalid_digest = "sha256:invalid";
218        validate_digest(body, Some(invalid_digest.to_string()), None)
219            .expect_err("Expected error for invalid digest");
220
221        // Test case 7: Valid header digest and invalid layer digest
222        let invalid_layer_digest = "sha512:invalid";
223        validate_digest(
224            body,
225            Some(digest_sha256.clone()),
226            Some(invalid_layer_digest),
227        )
228        .expect_err("Expected error for invalid layer digest");
229
230        // Test case 8: Unsupported algorithm
231        let unsupported_digest = "md5:d41d8cd98f00b204e9800998ecf8427e";
232        validate_digest(body, Some(unsupported_digest.to_string()), None)
233            .expect_err("Expected error for unsupported algorithm");
234    }
235}