Skip to main content

pf_core/
digest.rs

1// SPDX-License-Identifier: MIT
2//! SHA-256 content digests, OCI-style.
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::fmt;
7
8use crate::error::{Error, Result};
9
10/// A SHA-256 content digest, formatted `sha256:<64-hex>` per OCI conventions.
11#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(transparent)]
13pub struct Digest256(String);
14
15impl Digest256 {
16    /// Compute the digest of a byte slice.
17    #[must_use]
18    pub fn of(bytes: &[u8]) -> Self {
19        let mut hasher = Sha256::new();
20        hasher.update(bytes);
21        let hex = hex::encode(hasher.finalize());
22        Self(format!("sha256:{hex}"))
23    }
24
25    /// Borrow the canonical `sha256:<hex>` string.
26    #[must_use]
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30
31    /// Borrow just the 64-char hex part (without the `sha256:` prefix).
32    #[must_use]
33    pub fn hex(&self) -> &str {
34        // safe: format is validated on construction
35        &self.0["sha256:".len()..]
36    }
37
38    /// Parse a `sha256:<64-hex>` string. Errors on bad prefix or length.
39    pub fn parse(s: &str) -> Result<Self> {
40        let Some(rest) = s.strip_prefix("sha256:") else {
41            return Err(Error::InvalidDigest(format!(
42                "missing sha256: prefix in {s:?}"
43            )));
44        };
45        if rest.len() != 64 || !rest.chars().all(|c| c.is_ascii_hexdigit()) {
46            return Err(Error::InvalidDigest(format!("not 64 hex chars: {s:?}")));
47        }
48        Ok(Self(s.to_owned()))
49    }
50}
51
52impl fmt::Display for Digest256 {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(&self.0)
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn empty_input_has_known_sha256() {
64        let d = Digest256::of(b"");
65        assert_eq!(
66            d.as_str(),
67            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
68        );
69    }
70
71    #[test]
72    fn deterministic_across_calls() {
73        let payload = b"processfork";
74        assert_eq!(Digest256::of(payload), Digest256::of(payload));
75    }
76
77    #[test]
78    fn parses_canonical_form() {
79        let d = Digest256::of(b"hi");
80        let parsed = Digest256::parse(d.as_str()).unwrap();
81        assert_eq!(d, parsed);
82        assert_eq!(parsed.hex().len(), 64);
83    }
84
85    #[test]
86    fn rejects_bad_prefix_or_length() {
87        assert!(Digest256::parse("md5:abc").is_err());
88        assert!(Digest256::parse("sha256:short").is_err());
89        assert!(Digest256::parse("sha256:zzzz").is_err());
90    }
91}