maincopy_server/domain/publication/
provenance.rs1use 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}