Skip to main content

provenant/models/
digest.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5macro_rules! define_digest {
6    (
7        $(#[$meta:meta])*
8        $name:ident, $byte_len:literal
9    ) => {
10        $(#[$meta])*
11        #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12        pub struct $name([u8; $byte_len]);
13
14        impl $name {
15            #[allow(dead_code)]
16            pub const EMPTY: Self = Self([0u8; $byte_len]);
17
18            #[allow(dead_code)]
19            pub const fn from_bytes(bytes: [u8; $byte_len]) -> Self {
20                Self(bytes)
21            }
22
23            pub fn from_hex(s: &str) -> Result<Self, ParseDigestError> {
24                let bytes = hex::decode(s).map_err(|_| ParseDigestError::InvalidHex)?;
25                let array: [u8; $byte_len] = bytes
26                    .try_into()
27                    .map_err(|_: Vec<u8>| ParseDigestError::InvalidLength)?;
28                Ok(Self(array))
29            }
30
31            #[allow(dead_code)]
32            pub fn as_bytes(&self) -> &[u8; $byte_len] {
33                &self.0
34            }
35
36            pub fn as_hex(&self) -> String {
37                hex::encode(self.0)
38            }
39        }
40
41        impl fmt::Debug for $name {
42            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43                f.debug_tuple(stringify!($name))
44                    .field(&self.as_hex())
45                    .finish()
46            }
47        }
48
49        impl fmt::Display for $name {
50            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51                f.write_str(&self.as_hex())
52            }
53        }
54
55        impl Serialize for $name {
56            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
57            where
58                S: Serializer,
59            {
60                serializer.serialize_str(&self.as_hex())
61            }
62        }
63
64        impl<'de> Deserialize<'de> for $name {
65            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66            where
67                D: Deserializer<'de>,
68            {
69                let s = String::deserialize(deserializer)?;
70                Self::from_hex(&s).map_err(serde::de::Error::custom)
71            }
72        }
73    };
74}
75
76define_digest!(
77    /// SHA-1 digest (20 bytes / 40 hex characters).
78    Sha1Digest,
79    20
80);
81
82define_digest!(
83    /// MD5 digest (16 bytes / 32 hex characters).
84    Md5Digest,
85    16
86);
87
88define_digest!(
89    /// SHA-256 digest (32 bytes / 64 hex characters).
90    Sha256Digest,
91    32
92);
93
94define_digest!(
95    /// SHA-512 digest (64 bytes / 128 hex characters).
96    Sha512Digest,
97    64
98);
99
100define_digest!(
101    /// Git object SHA-1 digest (20 bytes / 40 hex characters).
102    GitSha1,
103    20
104);
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum ParseDigestError {
108    InvalidHex,
109    InvalidLength,
110}
111
112impl std::fmt::Display for ParseDigestError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            ParseDigestError::InvalidHex => write!(f, "invalid hex encoding"),
116            ParseDigestError::InvalidLength => write!(f, "invalid digest length"),
117        }
118    }
119}
120
121impl std::error::Error for ParseDigestError {}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn sha1_roundtrip() {
129        let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
130        let digest = Sha1Digest::from_hex(hex).unwrap();
131        assert_eq!(digest.as_hex(), hex);
132    }
133
134    #[test]
135    fn sha256_roundtrip() {
136        let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
137        let digest = Sha256Digest::from_hex(hex).unwrap();
138        assert_eq!(digest.as_hex(), hex);
139    }
140
141    #[test]
142    fn sha512_roundtrip() {
143        let hex = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e";
144        let digest = Sha512Digest::from_hex(hex).unwrap();
145        assert_eq!(digest.as_hex(), hex);
146    }
147
148    #[test]
149    fn md5_roundtrip() {
150        let hex = "d41d8cd98f00b204e9800998ecf8427e";
151        let digest = Md5Digest::from_hex(hex).unwrap();
152        assert_eq!(digest.as_hex(), hex);
153    }
154
155    #[test]
156    fn git_sha1_roundtrip() {
157        let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
158        let digest = GitSha1::from_hex(hex).unwrap();
159        assert_eq!(digest.as_hex(), hex);
160    }
161
162    #[test]
163    fn invalid_hex_rejected() {
164        assert!(Sha1Digest::from_hex("not-hex!").is_err());
165    }
166
167    #[test]
168    fn invalid_length_rejected() {
169        assert!(Sha1Digest::from_hex("abcd").is_err());
170        assert!(Sha256Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").is_err());
171    }
172
173    #[test]
174    fn serde_roundtrip() {
175        let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
176        let digest = Sha1Digest::from_hex(hex).unwrap();
177        let json = serde_json::to_string(&digest).unwrap();
178        assert_eq!(json, format!("\"{}\"", hex));
179        let back: Sha1Digest = serde_json::from_str(&json).unwrap();
180        assert_eq!(back, digest);
181    }
182
183    #[test]
184    fn optional_serde_roundtrip() {
185        let some: Option<Sha1Digest> =
186            Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap());
187        let json = serde_json::to_string(&some).unwrap();
188        let back: Option<Sha1Digest> = serde_json::from_str(&json).unwrap();
189        assert_eq!(back, some);
190
191        let none: Option<Sha1Digest> = None;
192        let json = serde_json::to_string(&none).unwrap();
193        let back: Option<Sha1Digest> = serde_json::from_str(&json).unwrap();
194        assert_eq!(back, none);
195    }
196
197    #[test]
198    fn empty_constant_is_all_zeros() {
199        assert_eq!(Sha1Digest::EMPTY.0, [0u8; 20]);
200        assert_eq!(Md5Digest::EMPTY.0, [0u8; 16]);
201        assert_eq!(Sha256Digest::EMPTY.0, [0u8; 32]);
202        assert_eq!(Sha512Digest::EMPTY.0, [0u8; 64]);
203        assert_eq!(GitSha1::EMPTY.0, [0u8; 20]);
204    }
205
206    #[test]
207    fn display_shows_hex() {
208        let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
209        let digest = Sha1Digest::from_hex(hex).unwrap();
210        assert_eq!(format!("{}", digest), hex);
211    }
212}