Skip to main content

markdown_compiler/content/
identity.rs

1use 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}