Skip to main content

provenant/models/
digest.rs

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