1use std::{fmt, str::FromStr};
2
3use thiserror::Error;
4
5use super::{
6 DiscoveredAsset, DiscoveredContentTree, DiscoveredPost, PostCollection, transcript::Transcript,
7};
8
9const CONTENT_TREE_CONTEXT: &str = "maincopy content tree digest v1";
10const CONTENT_TREE_KIND: &[u8] = b"maincopy-content-tree";
11const CONTENT_TREE_VERSION: u16 = 1;
12const CONTENT_TREE_PREFIX: &str = "content-b3-v1-";
13
14const PUBLICATION_SECTION: u8 = 0;
15const POSTS_SECTION: u8 = 1;
16const ASSETS_SECTION: u8 = 2;
17pub(super) const POSTS_COLLECTION: u8 = 0;
19pub(super) const DRAFTS_COLLECTION: u8 = 1;
20
21#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub struct ContentTreeDigest([u8; 32]);
24
25impl ContentTreeDigest {
26 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
27 Self(bytes)
28 }
29
30 pub const fn as_bytes(&self) -> &[u8; 32] {
31 &self.0
32 }
33
34 pub fn parse(value: &str) -> Result<Self, ContentTreeDigestParseError> {
35 let encoded = value
36 .strip_prefix(CONTENT_TREE_PREFIX)
37 .ok_or(ContentTreeDigestParseError::InvalidPrefix)?;
38 if encoded.len() != 64 {
39 return Err(ContentTreeDigestParseError::InvalidLength);
40 }
41 let mut bytes = [0_u8; 32];
42 for (index, pair) in encoded.as_bytes().as_chunks::<2>().0.iter().enumerate() {
43 let high =
44 decode_nibble(pair[0]).ok_or(ContentTreeDigestParseError::InvalidEncoding)?;
45 let low = decode_nibble(pair[1]).ok_or(ContentTreeDigestParseError::InvalidEncoding)?;
46 bytes[index] = high << 4 | low;
47 }
48 Ok(Self(bytes))
49 }
50}
51
52impl FromStr for ContentTreeDigest {
53 type Err = ContentTreeDigestParseError;
54
55 fn from_str(value: &str) -> Result<Self, Self::Err> {
56 Self::parse(value)
57 }
58}
59
60#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
61pub enum ContentTreeDigestParseError {
62 #[error("content digest must start with content-b3-v1-")]
63 InvalidPrefix,
64 #[error("content digest must contain exactly 32 encoded bytes")]
65 InvalidLength,
66 #[error("content digest must use lowercase hexadecimal")]
67 InvalidEncoding,
68}
69
70impl fmt::Display for ContentTreeDigest {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(
73 formatter,
74 "{CONTENT_TREE_PREFIX}{}",
75 blake3::Hash::from_bytes(self.0).to_hex()
76 )
77 }
78}
79
80const fn decode_nibble(byte: u8) -> Option<u8> {
81 match byte {
82 b'0'..=b'9' => Some(byte - b'0'),
83 b'a'..=b'f' => Some(byte - b'a' + 10),
84 _ => None,
85 }
86}
87
88impl DiscoveredContentTree {
89 pub fn digest(&self) -> ContentTreeDigest {
91 let mut transcript = Transcript::new(
92 CONTENT_TREE_CONTEXT,
93 CONTENT_TREE_KIND,
94 CONTENT_TREE_VERSION,
95 );
96
97 transcript.tag(PUBLICATION_SECTION);
98 transcript.string(self.publication.path.as_str());
99 transcript.bytes(self.publication.source.as_bytes());
100
101 transcript.tag(POSTS_SECTION);
102 let mut posts: Vec<_> = self.posts.iter().collect();
103 posts.sort_unstable_by(compare_posts);
104 transcript.sequence_len(posts.len());
105 for post in posts {
106 transcript.string(post.path.as_str());
107 transcript.tag(collection_tag(post.collection));
108 transcript.bytes(post.source.as_bytes());
109 }
110
111 transcript.tag(ASSETS_SECTION);
112 let mut assets: Vec<_> = self.assets.iter().collect();
113 assets.sort_unstable_by(compare_assets);
114 transcript.sequence_len(assets.len());
115 for asset in assets {
116 transcript.string(asset.path.as_str());
117 transcript.bytes(&asset.bytes);
118 }
119
120 ContentTreeDigest(*transcript.finish().as_bytes())
121 }
122}
123
124pub(super) fn compare_posts(left: &&DiscoveredPost, right: &&DiscoveredPost) -> std::cmp::Ordering {
126 left.path
127 .cmp(&right.path)
128 .then_with(|| collection_tag(left.collection).cmp(&collection_tag(right.collection)))
129 .then_with(|| left.source.cmp(&right.source))
130}
131
132pub(super) fn compare_assets(
133 left: &&DiscoveredAsset,
134 right: &&DiscoveredAsset,
135) -> std::cmp::Ordering {
136 left.path
137 .cmp(&right.path)
138 .then_with(|| left.bytes.as_ref().cmp(right.bytes.as_ref()))
139}
140
141pub(super) const fn collection_tag(collection: PostCollection) -> u8 {
142 match collection {
143 PostCollection::Posts => POSTS_COLLECTION,
144 PostCollection::Drafts => DRAFTS_COLLECTION,
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::tree::{asset, post, publication};
152 use crate::{LogicalAssetPath, LogicalContentPath};
153
154 fn fixture() -> DiscoveredContentTree {
155 let posts = vec![
156 post(
157 "posts/first.md",
158 PostCollection::Posts,
159 "first post".to_owned(),
160 ),
161 post(
162 "drafts/second.md",
163 PostCollection::Drafts,
164 "second post".to_owned(),
165 ),
166 ];
167 let assets = vec![
168 asset(
169 LogicalAssetPath::parse("assets/first.bin").unwrap(),
170 b"first asset".to_vec(),
171 ),
172 asset(
173 LogicalAssetPath::parse("assets/second.bin").unwrap(),
174 b"second asset".to_vec(),
175 ),
176 ];
177 DiscoveredContentTree::new(
178 publication("publication.toml", "publication settings".to_owned()),
179 posts,
180 assets,
181 64,
182 )
183 }
184
185 #[test]
186 fn identical_logical_trees_have_one_order_independent_digest() {
187 let first = fixture();
188 assert_eq!(
189 first.digest().to_string(),
190 "content-b3-v1-09af660c29bbee4fc3011605979181aac9b6733a37a275ddc3e5f222030186af"
191 );
192 let mut reordered = first.clone();
193 reordered.posts.reverse();
194 reordered.assets.reverse();
195 reordered.total_bytes = u64::MAX;
196
197 assert_eq!(first.digest(), first.clone().digest());
198 assert_eq!(first.digest(), reordered.digest());
199 }
200
201 #[test]
202 fn digest_binds_publication_path_and_exact_bytes() {
203 let original = fixture().digest();
204 let mut changed_path = fixture();
205 changed_path.publication.path = LogicalContentPath::new("settings/publication.toml");
206 let mut changed_bytes = fixture();
207 changed_bytes.publication.source = "publication settings\n".into();
208
209 assert_ne!(original, changed_path.digest());
210 assert_ne!(original, changed_bytes.digest());
211 }
212
213 #[test]
214 fn digest_binds_post_path_collection_exact_bytes_and_cardinality() {
215 let original = fixture().digest();
216 let mut changed_path = fixture();
217 changed_path.posts[0].path = LogicalContentPath::new("posts/renamed.md");
218 let mut changed_collection = fixture();
219 changed_collection.posts[0].collection = PostCollection::Drafts;
220 let mut changed_bytes = fixture();
221 changed_bytes.posts[0].source = "first post\n".into();
222 let mut removed = fixture();
223 removed.posts.pop();
224
225 for changed in [changed_path, changed_collection, changed_bytes, removed] {
226 assert_ne!(original, changed.digest());
227 }
228 }
229
230 #[test]
231 fn digest_binds_asset_path_exact_bytes_and_cardinality() {
232 let original = fixture().digest();
233 let mut changed_path = fixture();
234 changed_path.assets[0].path = LogicalAssetPath::parse("assets/renamed.bin").unwrap();
235 let mut changed_bytes = fixture();
236 changed_bytes.assets[0].bytes = std::sync::Arc::from(b"first asset\n".as_slice());
237 let mut removed = fixture();
238 removed.assets.pop();
239
240 for changed in [changed_path, changed_bytes, removed] {
241 assert_ne!(original, changed.digest());
242 }
243 }
244
245 #[test]
246 fn length_framing_prevents_adjacent_field_ambiguity() {
247 let first = DiscoveredContentTree::new(
248 publication("ab", "c".to_owned()),
249 Vec::new(),
250 Vec::new(),
251 3,
252 );
253 let second = DiscoveredContentTree::new(
254 publication("a", "bc".to_owned()),
255 Vec::new(),
256 Vec::new(),
257 3,
258 );
259
260 assert_ne!(first.digest(), second.digest());
261 }
262
263 #[test]
264 fn display_uses_the_versioned_content_digest_format() {
265 let digest = fixture().digest();
266 let encoded = digest.to_string();
267 let hex = encoded.strip_prefix(CONTENT_TREE_PREFIX).unwrap();
268
269 assert_eq!(digest.as_bytes().len(), 32);
270 assert_eq!(hex.len(), 64);
271 assert!(
272 hex.bytes()
273 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
274 );
275 assert_eq!(ContentTreeDigest::parse(&encoded).unwrap(), digest);
276 assert_eq!(encoded.parse::<ContentTreeDigest>().unwrap(), digest);
277 assert_eq!(
278 ContentTreeDigest::parse(&encoded.to_uppercase()),
279 Err(ContentTreeDigestParseError::InvalidPrefix)
280 );
281 assert_eq!(
282 ContentTreeDigest::parse(&encoded[..encoded.len() - 1]),
283 Err(ContentTreeDigestParseError::InvalidLength)
284 );
285 assert_eq!(
286 ContentTreeDigest::parse(&format!("{CONTENT_TREE_PREFIX}{}", "gg".repeat(32))),
287 Err(ContentTreeDigestParseError::InvalidEncoding)
288 );
289 }
290}