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