Skip to main content

maincopy_server/domain/publication/
provenance.rs

1use std::{fmt, str::FromStr};
2
3use maincopy_shared::source::{GIT_SHA1_SOURCE_COMMIT_PREFIX, GIT_SHA256_SOURCE_COMMIT_PREFIX};
4use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
5use thiserror::Error;
6
7#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum SourceCommitAlgorithm {
10    Sha1,
11    Sha256,
12}
13
14impl SourceCommitAlgorithm {
15    const fn byte_length(self) -> usize {
16        match self {
17            Self::Sha1 => 20,
18            Self::Sha256 => 32,
19        }
20    }
21}
22
23#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub struct SourceCommit {
25    algorithm: SourceCommitAlgorithm,
26    bytes: Box<[u8]>,
27    encoded: Box<str>,
28}
29
30impl SourceCommit {
31    pub fn parse(value: &str) -> Result<Self, SourceCommitParseError> {
32        let (algorithm, hex) = if let Some(hex) = value.strip_prefix(GIT_SHA1_SOURCE_COMMIT_PREFIX)
33        {
34            (SourceCommitAlgorithm::Sha1, hex)
35        } else if let Some(hex) = value.strip_prefix(GIT_SHA256_SOURCE_COMMIT_PREFIX) {
36            (SourceCommitAlgorithm::Sha256, hex)
37        } else {
38            return Err(SourceCommitParseError::InvalidPrefix);
39        };
40        if hex.len() != algorithm.byte_length() * 2 {
41            return Err(SourceCommitParseError::InvalidLength { algorithm });
42        }
43        if !hex
44            .bytes()
45            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
46        {
47            return Err(SourceCommitParseError::InvalidEncoding { algorithm });
48        }
49        let mut bytes = Vec::with_capacity(algorithm.byte_length());
50        for pair in hex.as_bytes().as_chunks::<2>().0 {
51            let high = decode_nibble(pair[0])
52                .ok_or(SourceCommitParseError::InvalidEncoding { algorithm })?;
53            let low = decode_nibble(pair[1])
54                .ok_or(SourceCommitParseError::InvalidEncoding { algorithm })?;
55            bytes.push(high << 4 | low);
56        }
57        Ok(Self {
58            algorithm,
59            bytes: bytes.into_boxed_slice(),
60            encoded: value.into(),
61        })
62    }
63
64    pub(crate) fn from_git_hex(value: &str) -> Result<Self, SourceCommitParseError> {
65        match value.len() {
66            40 => Self::parse(&format!("{GIT_SHA1_SOURCE_COMMIT_PREFIX}{value}")),
67            64 => Self::parse(&format!("{GIT_SHA256_SOURCE_COMMIT_PREFIX}{value}")),
68            _ => Err(SourceCommitParseError::UnsupportedObjectFormat),
69        }
70    }
71
72    pub const fn algorithm(&self) -> SourceCommitAlgorithm {
73        self.algorithm
74    }
75
76    pub fn as_bytes(&self) -> &[u8] {
77        &self.bytes
78    }
79
80    pub fn as_str(&self) -> &str {
81        &self.encoded
82    }
83}
84
85impl fmt::Display for SourceCommit {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        formatter.write_str(self.as_str())
88    }
89}
90
91impl FromStr for SourceCommit {
92    type Err = SourceCommitParseError;
93
94    fn from_str(value: &str) -> Result<Self, Self::Err> {
95        Self::parse(value)
96    }
97}
98
99impl TryFrom<&[u8]> for SourceCommit {
100    type Error = SourceCommitParseError;
101
102    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
103        let (algorithm, prefix) = match bytes.len() {
104            20 => (SourceCommitAlgorithm::Sha1, GIT_SHA1_SOURCE_COMMIT_PREFIX),
105            32 => (
106                SourceCommitAlgorithm::Sha256,
107                GIT_SHA256_SOURCE_COMMIT_PREFIX,
108            ),
109            _ => return Err(SourceCommitParseError::UnsupportedObjectFormat),
110        };
111        const HEX: &[u8; 16] = b"0123456789abcdef";
112        let mut encoded = String::with_capacity(prefix.len() + bytes.len() * 2);
113        encoded.push_str(prefix);
114        for byte in bytes {
115            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
116            encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
117        }
118        Ok(Self {
119            algorithm,
120            bytes: bytes.into(),
121            encoded: encoded.into_boxed_str(),
122        })
123    }
124}
125
126impl Serialize for SourceCommit {
127    fn serialize<SerializerType>(
128        &self,
129        serializer: SerializerType,
130    ) -> Result<SerializerType::Ok, SerializerType::Error>
131    where
132        SerializerType: Serializer,
133    {
134        serializer.serialize_str(self.as_str())
135    }
136}
137
138impl<'de> Deserialize<'de> for SourceCommit {
139    fn deserialize<DeserializerType>(
140        deserializer: DeserializerType,
141    ) -> Result<Self, DeserializerType::Error>
142    where
143        DeserializerType: Deserializer<'de>,
144    {
145        let value = String::deserialize(deserializer)?;
146        Self::parse(&value).map_err(de::Error::custom)
147    }
148}
149
150#[derive(Clone, Debug, Eq, Error, PartialEq)]
151pub enum SourceCommitParseError {
152    #[error("source commit must start with git-sha1: or git-sha256:")]
153    InvalidPrefix,
154    #[error("{algorithm:?} source commit has the wrong encoded length")]
155    InvalidLength { algorithm: SourceCommitAlgorithm },
156    #[error("{algorithm:?} source commit must use lowercase hexadecimal")]
157    InvalidEncoding { algorithm: SourceCommitAlgorithm },
158    #[error("Git object format is not supported")]
159    UnsupportedObjectFormat,
160}
161
162const fn decode_nibble(byte: u8) -> Option<u8> {
163    match byte {
164        b'0'..=b'9' => Some(byte - b'0'),
165        b'a'..=b'f' => Some(byte - b'a' + 10),
166        _ => None,
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn source_commits_are_strict_and_algorithm_typed() {
176        let sha1 = SourceCommit::parse(&format!("git-sha1:{}", "ab".repeat(20))).unwrap();
177        assert_eq!(sha1.algorithm(), SourceCommitAlgorithm::Sha1);
178        assert_eq!(sha1.as_bytes().len(), 20);
179
180        let sha256 = SourceCommit::parse(&format!("git-sha256:{}", "cd".repeat(32))).unwrap();
181        assert_eq!(sha256.algorithm(), SourceCommitAlgorithm::Sha256);
182        assert_eq!(sha256.as_bytes().len(), 32);
183
184        for invalid in [
185            "ab".repeat(20),
186            format!("git-sha1:{}", "AB".repeat(20)),
187            format!("git-sha1:{}", "ab".repeat(19)),
188            format!("git-sha256:{}", "gg".repeat(32)),
189        ] {
190            assert!(SourceCommit::parse(&invalid).is_err(), "accepted {invalid}");
191        }
192    }
193
194    #[test]
195    fn source_commit_serde_preserves_the_versioned_wire_value() {
196        let value = format!("git-sha1:{}", "01".repeat(20));
197        let commit = SourceCommit::parse(&value).unwrap();
198        assert_eq!(serde_json::to_value(&commit).unwrap(), value);
199        assert_eq!(
200            serde_json::from_value::<SourceCommit>(serde_json::json!(value)).unwrap(),
201            commit
202        );
203    }
204
205    #[test]
206    fn stored_source_commit_bytes_preserve_the_canonical_encoding() {
207        for width in [20, 32] {
208            let bytes: Vec<_> = (0..width).map(|byte| byte * 7).collect();
209            let commit = SourceCommit::try_from(bytes.as_slice()).unwrap();
210            assert_eq!(commit.as_bytes(), bytes);
211            assert_eq!(SourceCommit::parse(commit.as_str()).unwrap(), commit);
212        }
213        for width in [0, 19, 21, 31, 33] {
214            assert_eq!(
215                SourceCommit::try_from(vec![0; width].as_slice()),
216                Err(SourceCommitParseError::UnsupportedObjectFormat),
217            );
218        }
219    }
220
221    #[test]
222    fn source_commit_algorithm_wire_names_are_stable() {
223        for (value, expected) in [
224            (
225                serde_json::to_value(SourceCommitAlgorithm::Sha1).unwrap(),
226                "sha1",
227            ),
228            (
229                serde_json::to_value(SourceCommitAlgorithm::Sha256).unwrap(),
230                "sha256",
231            ),
232        ] {
233            assert_eq!(value, serde_json::json!(expected));
234        }
235    }
236}