markdown_compiler/content/
identity.rs1use std::{fmt, str::FromStr};
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
4use thiserror::Error;
5
6const ASSET_PREFIX: &str = "asset-b3-v1-";
7const POST_PREFIX: &str = "post-b3-v1-";
8const PREVIEW_PREFIX: &str = "preview-b3-v1-";
9const SITE_PREFIX: &str = "site-b3-v1-";
10const DIGEST_HEX_LENGTH: usize = 64;
11
12#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DigestKind {
15 Asset,
16 PostRevision,
17 Preview,
18 SiteSnapshot,
19}
20
21impl DigestKind {
22 const fn prefix(self) -> &'static str {
23 match self {
24 Self::Asset => ASSET_PREFIX,
25 Self::PostRevision => POST_PREFIX,
26 Self::Preview => PREVIEW_PREFIX,
27 Self::SiteSnapshot => SITE_PREFIX,
28 }
29 }
30}
31
32#[derive(Clone, Debug, Eq, Error, PartialEq)]
33pub enum DigestParseError {
34 #[error("{kind:?} digest must start with {expected}")]
35 InvalidPrefix {
36 kind: DigestKind,
37 expected: &'static str,
38 },
39 #[error("{kind:?} digest must contain exactly 32 encoded bytes")]
40 InvalidLength { kind: DigestKind },
41 #[error("{kind:?} digest must use lowercase hexadecimal")]
42 InvalidEncoding { kind: DigestKind },
43}
44
45macro_rules! public_digest_type {
46 ($name:ident, $kind:expr) => {
47 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
48 pub struct $name {
49 bytes: [u8; 32],
50 encoded: Box<str>,
51 }
52
53 impl $name {
54 pub fn parse(value: &str) -> Result<Self, DigestParseError> {
55 let bytes = parse_digest(value, $kind)?;
56 Ok(Self::from_bytes(bytes))
57 }
58
59 pub fn as_str(&self) -> &str {
60 &self.encoded
61 }
62
63 pub const fn as_bytes(&self) -> &[u8; 32] {
64 &self.bytes
65 }
66
67 pub fn from_bytes(bytes: [u8; 32]) -> Self {
68 let hash = blake3::Hash::from_bytes(bytes);
69 let encoded = format!("{}{}", $kind.prefix(), hash.to_hex()).into_boxed_str();
70 Self { bytes, encoded }
71 }
72
73 pub(crate) fn from_hash(hash: blake3::Hash) -> Self {
74 Self::from_bytes(*hash.as_bytes())
75 }
76 }
77
78 impl fmt::Display for $name {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 formatter.write_str(self.as_str())
81 }
82 }
83
84 impl FromStr for $name {
85 type Err = DigestParseError;
86
87 fn from_str(value: &str) -> Result<Self, Self::Err> {
88 Self::parse(value)
89 }
90 }
91
92 impl Serialize for $name {
93 fn serialize<SerializerType>(
94 &self,
95 serializer: SerializerType,
96 ) -> Result<SerializerType::Ok, SerializerType::Error>
97 where
98 SerializerType: Serializer,
99 {
100 serializer.serialize_str(self.as_str())
101 }
102 }
103
104 impl<'de> Deserialize<'de> for $name {
105 fn deserialize<DeserializerType>(
106 deserializer: DeserializerType,
107 ) -> Result<Self, DeserializerType::Error>
108 where
109 DeserializerType: Deserializer<'de>,
110 {
111 let value = String::deserialize(deserializer)?;
112 Self::parse(&value).map_err(de::Error::custom)
113 }
114 }
115 };
116}
117
118public_digest_type!(AssetDigest, DigestKind::Asset);
119public_digest_type!(PostRevisionDigest, DigestKind::PostRevision);
120public_digest_type!(PreviewDigest, DigestKind::Preview);
121public_digest_type!(SiteSnapshotDigest, DigestKind::SiteSnapshot);
122
123fn parse_digest(value: &str, kind: DigestKind) -> Result<[u8; 32], DigestParseError> {
124 let Some(hex) = value.strip_prefix(kind.prefix()) else {
125 return Err(DigestParseError::InvalidPrefix {
126 kind,
127 expected: kind.prefix(),
128 });
129 };
130 if hex.len() != DIGEST_HEX_LENGTH {
131 return Err(DigestParseError::InvalidLength { kind });
132 }
133 let mut bytes = [0_u8; 32];
134 for (index, pair) in hex.as_bytes().as_chunks::<2>().0.iter().enumerate() {
135 let high = decode_nibble(pair[0]).ok_or(DigestParseError::InvalidEncoding { kind })?;
136 let low = decode_nibble(pair[1]).ok_or(DigestParseError::InvalidEncoding { kind })?;
137 bytes[index] = high << 4 | low;
138 }
139 Ok(bytes)
140}
141
142const fn decode_nibble(byte: u8) -> Option<u8> {
143 match byte {
144 b'0'..=b'9' => Some(byte - b'0'),
145 b'a'..=b'f' => Some(byte - b'a' + 10),
146 _ => None,
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn public_digests_require_exact_versioned_lowercase_encodings() {
156 for (valid, kind) in [
157 (
158 format!("asset-b3-v1-{}", "ab".repeat(32)),
159 DigestKind::Asset,
160 ),
161 (
162 format!("post-b3-v1-{}", "ab".repeat(32)),
163 DigestKind::PostRevision,
164 ),
165 (
166 format!("preview-b3-v1-{}", "ab".repeat(32)),
167 DigestKind::Preview,
168 ),
169 (
170 format!("site-b3-v1-{}", "ab".repeat(32)),
171 DigestKind::SiteSnapshot,
172 ),
173 ] {
174 let parsed = match kind {
175 DigestKind::Asset => {
176 AssetDigest::parse(&valid).map(|value| value.as_str().to_owned())
177 }
178 DigestKind::PostRevision => {
179 PostRevisionDigest::parse(&valid).map(|value| value.as_str().to_owned())
180 }
181 DigestKind::Preview => {
182 PreviewDigest::parse(&valid).map(|value| value.as_str().to_owned())
183 }
184 DigestKind::SiteSnapshot => {
185 SiteSnapshotDigest::parse(&valid).map(|value| value.as_str().to_owned())
186 }
187 };
188 assert_eq!(parsed.unwrap(), valid);
189 }
190
191 assert!(AssetDigest::parse(&format!("asset-b3-v1-{}", "AB".repeat(32))).is_err());
192 assert!(PostRevisionDigest::parse(&format!("post-b3-v1-{}", "aa".repeat(31))).is_err());
193 assert!(SiteSnapshotDigest::parse(&format!("post-b3-v1-{}", "aa".repeat(32))).is_err());
194 assert!(AssetDigest::parse(&format!("asset-b3-v2-{}", "aa".repeat(32))).is_err());
195 assert!(AssetDigest::parse(&format!("asset-sha256-v1-{}", "aa".repeat(32))).is_err());
196 assert!(AssetDigest::parse(&format!("asset-b3-v1-{}", "gg".repeat(32))).is_err());
197
198 let value = format!("post-b3-v1-{}", "12".repeat(32));
199 let digest = PostRevisionDigest::parse(&value).unwrap();
200 assert_eq!(PostRevisionDigest::from_bytes(*digest.as_bytes()), digest);
201 assert_eq!(serde_json::to_value(&digest).unwrap(), value);
202 assert_eq!(
203 serde_json::from_value::<PostRevisionDigest>(serde_json::json!(value)).unwrap(),
204 digest
205 );
206 }
207
208 #[test]
209 fn digest_kind_wire_names_are_stable() {
210 for (value, expected) in [
211 (serde_json::to_value(DigestKind::Asset).unwrap(), "asset"),
212 (
213 serde_json::to_value(DigestKind::PostRevision).unwrap(),
214 "post_revision",
215 ),
216 (
217 serde_json::to_value(DigestKind::Preview).unwrap(),
218 "preview",
219 ),
220 (
221 serde_json::to_value(DigestKind::SiteSnapshot).unwrap(),
222 "site_snapshot",
223 ),
224 ] {
225 assert_eq!(value, serde_json::json!(expected));
226 }
227 }
228}