1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3use time::OffsetDateTime;
4
5use crate::{DefaultPostTipPolicy, transcript::Transcript};
6
7use super::{
8 AssetDigest, AssetRevisionReference, DigestedAsset, DraftStatus, ExternalAssetOrigin,
9 PostDocument, PostId, PostRevisionDigest, PostTipPolicy, PreviewDigest, PublicationSettings,
10 ResolvedPostAssets, ResolvedSiteAssets, SiteSnapshotDigest,
11};
12
13const ASSET_CONTEXT: &str = "maincopy asset digest v1";
14const POST_CONTENT_CONTEXT: &str = "maincopy post content digest v1";
15const PUBLICATION_CONTENT_CONTEXT: &str = "maincopy publication content digest v1";
16const POST_ASSET_SOURCE_BINDING_CONTEXT: &str = "maincopy post asset source binding v1";
17const PUBLICATION_ASSET_SOURCE_BINDING_CONTEXT: &str =
18 "maincopy publication asset source binding v1";
19const ASSET_RESOLUTION_POLICY_BINDING_CONTEXT: &str = "maincopy asset resolution policy binding v1";
20const POST_REVISION_CONTEXT: &str = "maincopy post revision digest v1";
21const PREVIEW_CONTEXT: &str = "maincopy preview digest v1";
22const SITE_SHELL_OUTPUT_CONTEXT: &str = "maincopy site shell output digest v1";
23const SITE_SNAPSHOT_CONTEXT: &str = "maincopy site snapshot digest v1";
24
25const COMMONMARK_DIALECT_TAG: u8 = 0;
28const RAW_HTML_DISABLED_TAG: u8 = 0;
29const CODE_LANGUAGE_CLASS_POLICY_TAG: u8 = 1;
30const MERMAID_SANITIZED_SVG_TAG: u8 = 1;
31const POST_RENDERER_VERSION_TAG: u8 = 2;
32const SANITIZER_VERSION_TAG: u8 = 1;
33const SITE_SHELL_RENDERER_VERSION_TAG: u8 = 1;
34const PUBLIC_ASSET_DELIVERY_POLICY_TAG: u8 = 0;
37const LOCAL_PROFILE_KIND_TAG: u8 = 0;
38const LOCAL_PROFILE_SCHEMA_VERSION: u16 = 1;
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub(crate) struct PostContentDigest([u8; 32]);
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub(crate) struct PublicationContentDigest([u8; 32]);
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49pub(super) struct PostAssetSourceBinding([u8; 32]);
50
51#[derive(Clone, Debug, Eq, PartialEq)]
53pub(super) struct PublicationAssetSourceBinding([u8; 32]);
54
55#[derive(Clone, Debug, Eq, PartialEq)]
57pub(super) struct AssetResolutionPolicyBinding([u8; 32]);
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct PostRendererIdentity {
61 commonmark_dialect: u8,
62 raw_html_disabled: u8,
63 code_language_classes: u8,
64 mermaid_sanitized_svg: u8,
65 renderer_version: u8,
66 sanitizer_version: u8,
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70struct PreInjectionRenderedArticle<'bytes>(&'bytes [u8]);
71
72impl<'bytes> PreInjectionRenderedArticle<'bytes> {
73 pub(super) const fn new(bytes: &'bytes [u8]) -> Self {
74 Self(bytes)
75 }
76
77 pub const fn as_bytes(self) -> &'bytes [u8] {
78 self.0
79 }
80}
81
82impl PostRendererIdentity {
83 pub const fn baseline() -> Self {
84 Self {
85 commonmark_dialect: COMMONMARK_DIALECT_TAG,
86 raw_html_disabled: RAW_HTML_DISABLED_TAG,
87 code_language_classes: CODE_LANGUAGE_CLASS_POLICY_TAG,
88 mermaid_sanitized_svg: MERMAID_SANITIZED_SVG_TAG,
89 renderer_version: POST_RENDERER_VERSION_TAG,
90 sanitizer_version: SANITIZER_VERSION_TAG,
91 }
92 }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct SiteShellRendererIdentity {
97 pub frontend_bundle: [u8; 32],
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct SiteShellOutputDigest([u8; 32]);
102
103pub struct SiteShellOutputHasher(Transcript);
105
106impl SiteShellOutputHasher {
107 pub fn new(page_count: usize) -> Self {
108 let mut transcript =
109 Transcript::new(SITE_SHELL_OUTPUT_CONTEXT, b"maincopy-site-shell-output", 1);
110 transcript.sequence_len(page_count);
111 Self(transcript)
112 }
113
114 pub fn page(&mut self, route: &str, bytes: &[u8]) {
115 self.0.string(route);
116 self.0.bytes(bytes);
117 }
118
119 pub fn finish(self) -> SiteShellOutputDigest {
120 SiteShellOutputDigest(*self.0.finish().as_bytes())
121 }
122}
123
124impl SiteShellRendererIdentity {
125 pub const fn new(frontend_bundle: [u8; 32]) -> Self {
126 Self { frontend_bundle }
127 }
128}
129
130struct PostRevisionInput<'input> {
132 document: &'input PostDocument,
133 assets: &'input ResolvedPostAssets,
134 site_assets: Option<&'input ResolvedSiteAssets>,
135 renderer: &'input PostRendererIdentity,
136 pre_injection_article: PreInjectionRenderedArticle<'input>,
137 generated_assets: &'input [DigestedAsset],
138}
139
140impl<'input> PostRevisionInput<'input> {
141 const fn new(
142 document: &'input PostDocument,
143 assets: &'input ResolvedPostAssets,
144 site_assets: &'input ResolvedSiteAssets,
145 renderer: &'input PostRendererIdentity,
146 pre_injection_article: PreInjectionRenderedArticle<'input>,
147 generated_assets: &'input [DigestedAsset],
148 ) -> Self {
149 Self {
150 document,
151 assets,
152 site_assets: Some(site_assets),
153 renderer,
154 pre_injection_article,
155 generated_assets,
156 }
157 }
158
159 #[cfg(test)]
160 const fn new_unchecked(
161 document: &'input PostDocument,
162 assets: &'input ResolvedPostAssets,
163 renderer: &'input PostRendererIdentity,
164 pre_injection_article: PreInjectionRenderedArticle<'input>,
165 generated_assets: &'input [DigestedAsset],
166 ) -> Self {
167 Self {
168 document,
169 assets,
170 site_assets: None,
171 renderer,
172 pre_injection_article,
173 generated_assets,
174 }
175 }
176}
177
178pub struct PublishedPostIdentityInput<'input> {
180 pub post_id: &'input PostId,
181 pub revision: &'input PostRevisionDigest,
182 pub published_at: OffsetDateTime,
183}
184
185impl<'input> PublishedPostIdentityInput<'input> {
186 pub const fn new(
187 post_id: &'input PostId,
188 revision: &'input PostRevisionDigest,
189 published_at: OffsetDateTime,
190 ) -> Self {
191 Self {
192 post_id,
193 revision,
194 published_at,
195 }
196 }
197}
198
199struct SiteSnapshotInput<'input> {
201 publication: &'input PublicationSettings,
202 assets: &'input ResolvedSiteAssets,
203 renderer: &'input SiteShellRendererIdentity,
204 pre_injection_shell: &'input SiteShellOutputDigest,
205 public_posts: &'input [PublishedPostIdentityInput<'input>],
206}
207
208impl<'input> SiteSnapshotInput<'input> {
209 const fn new(
210 publication: &'input PublicationSettings,
211 assets: &'input ResolvedSiteAssets,
212 renderer: &'input SiteShellRendererIdentity,
213 pre_injection_shell: &'input SiteShellOutputDigest,
214 public_posts: &'input [PublishedPostIdentityInput<'input>],
215 ) -> Self {
216 Self {
217 publication,
218 assets,
219 renderer,
220 pre_injection_shell,
221 public_posts,
222 }
223 }
224}
225
226#[derive(Clone, Debug, Eq, Error, PartialEq)]
227pub enum RevisionIdentityError {
228 #[error("resolved asset inputs are bound to different {target:?} content")]
229 ResolvedAssetBindingMismatch { target: AssetBindingTarget },
230 #[error("post assets were approved under a different external-asset policy")]
231 ResolvedAssetPolicyMismatch,
232 #[error("asset reference is duplicated: {value}")]
233 DuplicateAssetReference { value: String },
234 #[error("generated asset path is duplicated: {path}")]
235 DuplicateGeneratedAsset { path: String },
236 #[error("effective asset origin is duplicated: {origin}")]
237 DuplicateAllowedOrigin { origin: String },
238 #[error("public post identity is duplicated: {post_id}")]
239 DuplicatePublicPost { post_id: PostId },
240}
241
242#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
243#[serde(rename_all = "snake_case")]
244pub enum AssetBindingTarget {
245 Post,
246 Publication,
247}
248
249pub fn digest_asset(bytes: &[u8]) -> AssetDigest {
250 let mut transcript = Transcript::new(ASSET_CONTEXT, b"maincopy-asset", 1);
251 transcript.bytes(bytes);
252 AssetDigest::from_hash(transcript.finish())
253}
254
255pub(crate) fn digest_post_content(document: &PostDocument) -> PostContentDigest {
256 let mut transcript = Transcript::new(POST_CONTENT_CONTEXT, b"maincopy-post-content", 1);
257 encode_post_document(&mut transcript, document);
258 PostContentDigest(*transcript.finish().as_bytes())
259}
260
261pub(crate) fn digest_publication_content(
262 publication: &PublicationSettings,
263) -> PublicationContentDigest {
264 let mut transcript = Transcript::new(
265 PUBLICATION_CONTENT_CONTEXT,
266 b"maincopy-publication-content",
267 1,
268 );
269 encode_publication(&mut transcript, publication);
270 PublicationContentDigest(*transcript.finish().as_bytes())
271}
272
273pub(super) fn bind_post_asset_source_with_content(
274 document: &PostDocument,
275 canonical_content: &PostContentDigest,
276) -> PostAssetSourceBinding {
277 let mut transcript = Transcript::new(
278 POST_ASSET_SOURCE_BINDING_CONTEXT,
279 b"maincopy-post-asset-source-binding",
280 1,
281 );
282 transcript.fixed_bytes(&canonical_content.0);
283 transcript.optional(document.metadata.image.as_ref(), |transcript, image| {
284 transcript.string(image.as_str());
285 });
286 PostAssetSourceBinding(*transcript.finish().as_bytes())
287}
288
289pub(super) fn bind_publication_asset_source(
290 publication: &PublicationSettings,
291) -> PublicationAssetSourceBinding {
292 let canonical_content = digest_publication_content(publication);
293 let mut transcript = Transcript::new(
294 PUBLICATION_ASSET_SOURCE_BINDING_CONTEXT,
295 b"maincopy-publication-asset-source-binding",
296 1,
297 );
298 transcript.fixed_bytes(&canonical_content.0);
299 transcript.optional(publication.site.favicon.as_ref(), |transcript, favicon| {
300 transcript.string(favicon.as_str());
301 });
302 transcript.optional(publication.site.image.as_ref(), |transcript, image| {
303 transcript.string(image.as_str());
304 });
305 let authored_origins = &publication.assets.allowed_https_origins;
306 transcript.sequence_len(authored_origins.len());
307 for origin in authored_origins {
308 transcript.string(origin.as_str());
309 }
310 PublicationAssetSourceBinding(*transcript.finish().as_bytes())
311}
312
313pub(super) fn bind_asset_resolution_policy(
314 allowed_origins: &[ExternalAssetOrigin],
315) -> AssetResolutionPolicyBinding {
316 let mut origins: Vec<_> = allowed_origins.iter().collect();
317 origins.sort_by(|left, right| left.as_str().cmp(right.as_str()));
318 let mut transcript = Transcript::new(
319 ASSET_RESOLUTION_POLICY_BINDING_CONTEXT,
320 b"maincopy-asset-resolution-policy-binding",
321 1,
322 );
323 transcript.sequence_len(origins.len());
324 for origin in origins {
325 transcript.string(origin.as_str());
326 }
327 AssetResolutionPolicyBinding(*transcript.finish().as_bytes())
328}
329
330fn digest_post_revision(
331 input: &PostRevisionInput<'_>,
332) -> Result<PostRevisionDigest, RevisionIdentityError> {
333 let referenced_assets = sorted_asset_references(&input.assets.references)?;
334 let generated_assets = sorted_generated_assets(input.generated_assets)?;
335 let content = digest_post_content(input.document);
336 if input.assets.source_binding != bind_post_asset_source_with_content(input.document, &content)
337 {
338 return Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
339 target: AssetBindingTarget::Post,
340 });
341 }
342 if input
343 .site_assets
344 .is_some_and(|site_assets| input.assets.policy_binding != site_assets.policy_binding)
345 {
346 return Err(RevisionIdentityError::ResolvedAssetPolicyMismatch);
347 }
348 Ok(digest_post_revision_components(
349 &content,
350 input.assets,
351 referenced_assets.into_iter(),
352 input.renderer,
353 input.pre_injection_article.as_bytes(),
354 &generated_assets,
355 ))
356}
357
358pub(super) fn digest_prepared_post_revision(
359 content: &PostContentDigest,
360 assets: &ResolvedPostAssets,
361 renderer: &PostRendererIdentity,
362 pre_injection_article: &[u8],
363) -> PostRevisionDigest {
364 digest_post_revision_components(
365 content,
366 assets,
367 assets.references.iter(),
368 renderer,
369 pre_injection_article,
370 &[],
371 )
372}
373
374fn digest_post_revision_components<'assets>(
375 content: &PostContentDigest,
376 assets: &ResolvedPostAssets,
377 references: impl ExactSizeIterator<Item = &'assets AssetRevisionReference>,
378 renderer: &PostRendererIdentity,
379 pre_injection_article: &[u8],
380 generated_assets: &[&DigestedAsset],
381) -> PostRevisionDigest {
382 let mut transcript = Transcript::new(POST_REVISION_CONTEXT, b"maincopy-post-revision", 1);
383 transcript.fixed_bytes(&content.0);
384 transcript.optional(assets.image.as_ref(), encode_asset_reference);
385 encode_asset_references(&mut transcript, references);
386 encode_post_renderer(&mut transcript, renderer);
387 transcript.bytes(pre_injection_article);
388 encode_generated_assets(&mut transcript, generated_assets);
389 PostRevisionDigest::from_hash(transcript.finish())
390}
391
392pub fn finalize_post_revision(
397 document: &PostDocument,
398 assets: &ResolvedPostAssets,
399 site_assets: &ResolvedSiteAssets,
400 renderer: &PostRendererIdentity,
401 pre_injection_article: &[u8],
402 generated_assets: &[DigestedAsset],
403) -> Result<PostRevisionDigest, RevisionIdentityError> {
404 digest_post_revision(&PostRevisionInput::new(
405 document,
406 assets,
407 site_assets,
408 renderer,
409 PreInjectionRenderedArticle::new(pre_injection_article),
410 generated_assets,
411 ))
412}
413
414pub fn finalize_preview_digest(
420 input: PreviewDigestInput<'_>,
421) -> Result<PreviewDigest, RevisionIdentityError> {
422 if input.site_assets.source_binding != bind_publication_asset_source(input.publication) {
423 return Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
424 target: AssetBindingTarget::Publication,
425 });
426 }
427 let references = sorted_asset_references(&input.site_assets.references)?;
428 let allowed_origins = sorted_allowed_origins(&input.site_assets.allowed_origins)?;
429 let publication_content = digest_publication_content(input.publication);
430
431 let mut transcript = Transcript::new(PREVIEW_CONTEXT, b"maincopy-preview", 1);
432 transcript.tag(0);
433 transcript.fixed_bytes(input.post_id.as_uuid().as_bytes());
434 transcript.tag(1);
435 transcript.fixed_bytes(input.post_revision.as_bytes());
436 transcript.tag(2);
437 transcript.bytes(input.article_identity_html);
438 transcript.tag(3);
439 encode_post_renderer(&mut transcript, input.post_renderer);
440 transcript.tag(4);
441 transcript.fixed_bytes(&publication_content.0);
442 transcript.optional(input.site_assets.favicon.as_ref(), encode_asset_reference);
443 transcript.optional(input.site_assets.image.as_ref(), encode_asset_reference);
444 transcript.sequence_len(allowed_origins.len());
445 for origin in allowed_origins {
446 transcript.string(origin.as_str());
447 }
448 encode_asset_references(&mut transcript, references.into_iter());
449 transcript.tag(5);
450 encode_site_renderer(&mut transcript, input.site_renderer);
451 transcript.bytes(input.pre_injection_post_shell);
452 transcript.bytes(input.response_policy);
453 transcript.tag(6);
454 transcript.tag(LOCAL_PROFILE_KIND_TAG);
455 transcript.fixed_bytes(&LOCAL_PROFILE_SCHEMA_VERSION.to_be_bytes());
456 transcript.bytes(input.profile_projection);
457 transcript.tag(7);
458 transcript.string(input.canonical_url);
459 Ok(PreviewDigest::from_hash(transcript.finish()))
460}
461
462pub struct PreviewDigestInput<'input> {
464 pub publication: &'input PublicationSettings,
465 pub site_assets: &'input ResolvedSiteAssets,
466 pub post_id: &'input PostId,
467 pub post_revision: &'input PostRevisionDigest,
468 pub post_renderer: &'input PostRendererIdentity,
469 pub article_identity_html: &'input [u8],
470 pub site_renderer: &'input SiteShellRendererIdentity,
471 pub pre_injection_post_shell: &'input [u8],
472 pub response_policy: &'input [u8],
474 pub profile_projection: &'input [u8],
479 pub canonical_url: &'input str,
480}
481
482fn digest_site_snapshot(
483 input: &SiteSnapshotInput<'_>,
484) -> Result<SiteSnapshotDigest, RevisionIdentityError> {
485 let site_assets = sorted_asset_references(&input.assets.references)?;
486 let allowed_origins = sorted_allowed_origins(&input.assets.allowed_origins)?;
487 let public_posts = sorted_public_posts(input.public_posts)?;
488 let publication_content = digest_publication_content(input.publication);
489 if input.assets.source_binding != bind_publication_asset_source(input.publication) {
490 return Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
491 target: AssetBindingTarget::Publication,
492 });
493 }
494 let mut transcript = Transcript::new(SITE_SNAPSHOT_CONTEXT, b"maincopy-site-snapshot", 1);
495 transcript.fixed_bytes(&publication_content.0);
496 transcript.optional(input.assets.favicon.as_ref(), encode_asset_reference);
497 transcript.optional(input.assets.image.as_ref(), encode_asset_reference);
498 transcript.sequence_len(allowed_origins.len());
499 for origin in allowed_origins {
500 transcript.string(origin.as_str());
501 }
502 encode_asset_references(&mut transcript, site_assets.into_iter());
503 encode_site_renderer(&mut transcript, input.renderer);
504 transcript.fixed_bytes(&input.pre_injection_shell.0);
505 transcript.sequence_len(public_posts.len());
506 for post in public_posts {
507 transcript.fixed_bytes(post.post_id.as_uuid().as_bytes());
508 transcript.fixed_bytes(post.revision.as_bytes());
509 transcript.utc_timestamp(post.published_at);
510 }
511 Ok(SiteSnapshotDigest::from_hash(transcript.finish()))
512}
513
514pub fn finalize_site_snapshot(
520 publication: &PublicationSettings,
521 assets: &ResolvedSiteAssets,
522 renderer: &SiteShellRendererIdentity,
523 pre_injection_shell: &SiteShellOutputDigest,
524 public_posts: &[PublishedPostIdentityInput<'_>],
525) -> Result<SiteSnapshotDigest, RevisionIdentityError> {
526 digest_site_snapshot(&SiteSnapshotInput::new(
527 publication,
528 assets,
529 renderer,
530 pre_injection_shell,
531 public_posts,
532 ))
533}
534
535fn encode_post_document(transcript: &mut Transcript, document: &PostDocument) {
536 let metadata = &document.metadata;
537 transcript.fixed_bytes(metadata.id.as_uuid().as_bytes());
538 transcript.string(metadata.title.as_str());
539 transcript.string(metadata.slug.as_str());
540 transcript.authored_timestamp(metadata.authored_at);
541 transcript.optional(metadata.updated_at, Transcript::authored_timestamp);
542 transcript.string(metadata.description.as_str());
543 transcript.sequence_len(metadata.tags.len());
546 for tag in &metadata.tags {
547 transcript.string(tag.as_str());
548 }
549 transcript.sequence_len(metadata.aliases.len());
550 for alias in &metadata.aliases {
551 transcript.string(alias.as_str());
552 }
553 transcript.tag(match metadata.draft {
554 DraftStatus::Publishable => 0,
555 DraftStatus::Draft => 1,
556 });
557 transcript.tag(match metadata.tips {
558 PostTipPolicy::InheritPublication => 0,
559 PostTipPolicy::Enabled => 1,
560 PostTipPolicy::Disabled => 2,
561 });
562 transcript.bytes(document.markdown.as_str().as_bytes());
563}
564
565fn encode_baseline_renderer_policy(transcript: &mut Transcript) {
566 transcript.tag(COMMONMARK_DIALECT_TAG);
567 transcript.tag(RAW_HTML_DISABLED_TAG);
568 transcript.tag(CODE_LANGUAGE_CLASS_POLICY_TAG);
569 transcript.tag(MERMAID_SANITIZED_SVG_TAG);
570}
571
572fn encode_post_renderer(transcript: &mut Transcript, renderer: &PostRendererIdentity) {
573 transcript.tag(renderer.commonmark_dialect);
574 transcript.tag(renderer.raw_html_disabled);
575 transcript.tag(renderer.code_language_classes);
576 transcript.tag(renderer.mermaid_sanitized_svg);
577 transcript.tag(renderer.renderer_version);
578 transcript.tag(renderer.sanitizer_version);
579}
580
581fn encode_site_renderer(transcript: &mut Transcript, renderer: &SiteShellRendererIdentity) {
582 transcript.tag(SITE_SHELL_RENDERER_VERSION_TAG);
583 transcript.tag(PUBLIC_ASSET_DELIVERY_POLICY_TAG);
584 transcript.fixed_bytes(&renderer.frontend_bundle);
585}
586
587fn encode_publication(transcript: &mut Transcript, publication: &PublicationSettings) {
588 let site = &publication.site;
589 transcript.string(site.title.as_str());
590 transcript.string(site.base_url.as_str());
591 transcript.string(site.description.as_str());
592 transcript.string(publication.author.name.as_str());
595 transcript.tag(match publication.tips {
596 DefaultPostTipPolicy::Disabled => 0,
597 DefaultPostTipPolicy::Enabled => 1,
598 });
599 encode_baseline_renderer_policy(transcript);
600}
601
602fn sorted_asset_references(
603 references: &[AssetRevisionReference],
604) -> Result<Vec<&AssetRevisionReference>, RevisionIdentityError> {
605 let mut references: Vec<_> = references.iter().collect();
606 references.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
607 for pair in references.windows(2) {
608 if pair[0].sort_key() == pair[1].sort_key() {
609 let (kind, value) = pair[0].sort_key();
610 return Err(RevisionIdentityError::DuplicateAssetReference {
611 value: format!("{kind}:{value}"),
612 });
613 }
614 }
615 Ok(references)
616}
617
618fn encode_asset_references<'assets>(
619 transcript: &mut Transcript,
620 references: impl ExactSizeIterator<Item = &'assets AssetRevisionReference>,
621) {
622 transcript.sequence_len(references.len());
623 for reference in references {
624 encode_asset_reference(transcript, reference);
625 }
626}
627
628fn encode_asset_reference(transcript: &mut Transcript, reference: &AssetRevisionReference) {
629 match reference {
630 AssetRevisionReference::Local(asset) => {
631 transcript.tag(0);
632 transcript.string(asset.path.as_str());
633 transcript.fixed_bytes(asset.digest.as_bytes());
634 }
635 AssetRevisionReference::External(url) => {
636 transcript.tag(1);
637 transcript.string(url.as_str());
638 }
639 }
640}
641
642fn sorted_allowed_origins(
643 origins: &[ExternalAssetOrigin],
644) -> Result<Vec<&ExternalAssetOrigin>, RevisionIdentityError> {
645 let mut origins: Vec<_> = origins.iter().collect();
646 origins.sort_by(|left, right| left.as_str().cmp(right.as_str()));
647 for pair in origins.windows(2) {
648 if pair[0] == pair[1] {
649 return Err(RevisionIdentityError::DuplicateAllowedOrigin {
650 origin: pair[0].as_str().to_owned(),
651 });
652 }
653 }
654 Ok(origins)
655}
656
657fn sorted_generated_assets(
658 assets: &[DigestedAsset],
659) -> Result<Vec<&DigestedAsset>, RevisionIdentityError> {
660 let mut assets: Vec<_> = assets.iter().collect();
661 assets.sort_by(|left, right| left.path.as_str().cmp(right.path.as_str()));
662 for pair in assets.windows(2) {
663 if pair[0].path == pair[1].path {
664 return Err(RevisionIdentityError::DuplicateGeneratedAsset {
665 path: pair[0].path.as_str().to_owned(),
666 });
667 }
668 }
669 Ok(assets)
670}
671
672fn encode_generated_assets(transcript: &mut Transcript, assets: &[&DigestedAsset]) {
673 transcript.sequence_len(assets.len());
674 for asset in assets {
675 transcript.string(asset.path.as_str());
676 transcript.fixed_bytes(asset.digest.as_bytes());
677 }
678}
679
680fn sorted_public_posts<'slice, 'input>(
681 posts: &'slice [PublishedPostIdentityInput<'input>],
682) -> Result<Vec<&'slice PublishedPostIdentityInput<'input>>, RevisionIdentityError> {
683 let mut posts: Vec<_> = posts.iter().collect();
684 posts.sort_by(|left, right| left.post_id.cmp(right.post_id));
685 for pair in posts.windows(2) {
686 if pair[0].post_id == pair[1].post_id {
687 return Err(RevisionIdentityError::DuplicatePublicPost {
688 post_id: pair[0].post_id.clone(),
689 });
690 }
691 }
692 Ok(posts)
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use crate::{
699 ExternalAssetUrl, LogicalAssetPath, PostSource, PublicationSource, validate_content,
700 };
701
702 #[derive(Clone)]
703 struct PublishedPostRevision {
704 post_id: PostId,
705 revision: PostRevisionDigest,
706 published_at: OffsetDateTime,
707 }
708
709 impl PublishedPostRevision {
710 fn new(
711 post_id: PostId,
712 revision: PostRevisionDigest,
713 published_at: OffsetDateTime,
714 ) -> Self {
715 Self {
716 post_id,
717 revision,
718 published_at,
719 }
720 }
721 }
722
723 #[derive(Debug, Eq, PartialEq)]
724 struct SourceCommit(String);
725
726 impl SourceCommit {
727 fn parse(value: &str) -> Result<Self, ()> {
728 Ok(Self(value.to_owned()))
729 }
730 }
731
732 fn published_identity_inputs(
733 posts: &[PublishedPostRevision],
734 ) -> Vec<PublishedPostIdentityInput<'_>> {
735 posts
736 .iter()
737 .map(|post| {
738 PublishedPostIdentityInput::new(&post.post_id, &post.revision, post.published_at)
739 })
740 .collect()
741 }
742
743 const PUBLICATION: &str = r#"
744[site]
745title = "Example"
746base_url = "https://example.com/"
747description = "An example publication."
748
749[author]
750name = "Example Author"
751"#;
752
753 fn validate_post(frontmatter: &str, markdown: &str) -> (PublicationSettings, PostDocument) {
754 let source = format!("+++\n{frontmatter}+++\n{markdown}");
755 let content = validate_content(
756 PublicationSource::new("publication.toml", PUBLICATION),
757 [PostSource::in_posts("posts/example.md", &source)],
758 )
759 .expect("fixture content must validate");
760 (content.publication.clone(), content.posts[0].clone())
761 }
762
763 fn validate_publication(source: &str) -> PublicationSettings {
764 validate_content(
765 PublicationSource::new("publication.toml", source),
766 std::iter::empty::<PostSource<'_>>(),
767 )
768 .expect("fixture publication must validate")
769 .publication
770 .clone()
771 }
772
773 fn frontmatter(authored_at: &str) -> String {
774 format!(
775 "id = \"11111111-1111-4111-8111-111111111111\"\n\
776 title = \"Example Post\"\n\
777 slug = \"example-post\"\n\
778 authored_at = {authored_at}\n\
779 description = \"An example post.\"\n\
780 tags = [\"Rust\", \"sqlite\"]\n"
781 )
782 }
783
784 fn renderer() -> PostRendererIdentity {
785 PostRendererIdentity::baseline()
786 }
787
788 fn changed_renderer_policies(
789 baseline: &PostRendererIdentity,
790 ) -> [(&'static str, PostRendererIdentity); 6] {
791 [
792 (
793 "CommonMark dialect",
794 PostRendererIdentity {
795 commonmark_dialect: baseline.commonmark_dialect ^ 1,
796 ..baseline.clone()
797 },
798 ),
799 (
800 "raw HTML policy",
801 PostRendererIdentity {
802 raw_html_disabled: baseline.raw_html_disabled ^ 1,
803 ..baseline.clone()
804 },
805 ),
806 (
807 "code-language class policy",
808 PostRendererIdentity {
809 code_language_classes: baseline.code_language_classes ^ 1,
810 ..baseline.clone()
811 },
812 ),
813 (
814 "Mermaid policy",
815 PostRendererIdentity {
816 mermaid_sanitized_svg: baseline.mermaid_sanitized_svg ^ 1,
817 ..baseline.clone()
818 },
819 ),
820 (
821 "post-renderer version",
822 PostRendererIdentity {
823 renderer_version: baseline.renderer_version ^ 1,
824 ..baseline.clone()
825 },
826 ),
827 (
828 "SVG-sanitizer version",
829 PostRendererIdentity {
830 sanitizer_version: baseline.sanitizer_version ^ 1,
831 ..baseline.clone()
832 },
833 ),
834 ]
835 }
836
837 const fn frontend_bundle(byte: u8) -> [u8; 32] {
838 [byte; 32]
839 }
840
841 fn shell_output(bytes: &[u8]) -> SiteShellOutputDigest {
842 let mut output = SiteShellOutputHasher::new(1);
843 output.page("/", bytes);
844 output.finish()
845 }
846
847 #[test]
848 fn identity_enum_wire_names_are_stable() {
849 for (value, expected) in [
850 (
851 serde_json::to_value(AssetBindingTarget::Post).unwrap(),
852 "post",
853 ),
854 (
855 serde_json::to_value(AssetBindingTarget::Publication).unwrap(),
856 "publication",
857 ),
858 ] {
859 assert_eq!(value, serde_json::json!(expected));
860 }
861 }
862
863 #[test]
864 fn typed_post_content_is_canonical_but_preserves_markdown_and_authored_offset() {
865 let first_frontmatter = frontmatter("2026-08-29T12:00:00-04:00");
866 let reordered = first_frontmatter
867 .replace(
868 "title = \"Example Post\"\nslug = \"example-post\"",
869 "slug = \"example-post\"\n# equivalent comment\ntitle = 'Example Post'",
870 )
871 .replace(
872 "tags = [\"Rust\", \"sqlite\"]",
873 "tags = [\"rust\", \"sqlite\"]",
874 )
875 + "draft = false\n";
876 let (_, first) = validate_post(&first_frontmatter, "# Body\n");
877 let (_, equivalent) = validate_post(&reordered, "# Body\n");
878 let (_, offset_changed) = validate_post(&frontmatter("2026-08-29T16:00:00Z"), "# Body\n");
879 let (_, markdown_changed) = validate_post(&first_frontmatter, "# Body\r\n");
880
881 assert_eq!(
882 digest_post_content(&first),
883 digest_post_content(&equivalent)
884 );
885 assert_ne!(
886 digest_post_content(&first),
887 digest_post_content(&offset_changed)
888 );
889 assert_ne!(
890 digest_post_content(&first),
891 digest_post_content(&markdown_changed)
892 );
893 }
894
895 #[test]
896 fn preview_digest_binds_article_shell_renderer_and_canonical_url() {
897 let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
898 let site_assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
899 let post_id = post.metadata.id.clone();
900 let post_revision = PostRevisionDigest::from_bytes([0x11; 32]);
901 let post_renderer = renderer();
902 let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x22));
903 let digest = |revision: &PostRevisionDigest,
904 article: &[u8],
905 renderer: &SiteShellRendererIdentity,
906 shell: &[u8],
907 profile: &[u8],
908 canonical_url: &str| {
909 finalize_preview_digest(PreviewDigestInput {
910 publication: &publication,
911 site_assets: &site_assets,
912 post_id: &post_id,
913 post_revision: revision,
914 post_renderer: &post_renderer,
915 article_identity_html: article,
916 site_renderer: renderer,
917 pre_injection_post_shell: shell,
918 response_policy: b"fixture-policy",
919 profile_projection: profile,
920 canonical_url,
921 })
922 .unwrap()
923 };
924 let baseline = digest(
925 &post_revision,
926 b"<p>Rendered article</p>",
927 &site_renderer,
928 b"<html>Production shell</html>",
929 b"",
930 "https://example.com/posts/example-post",
931 );
932 assert_eq!(
933 baseline,
934 digest(
935 &post_revision,
936 b"<p>Rendered article</p>",
937 &site_renderer,
938 b"<html>Production shell</html>",
939 b"",
940 "https://example.com/posts/example-post",
941 )
942 );
943 assert_ne!(
944 baseline,
945 digest(
946 &PostRevisionDigest::from_bytes([0x33; 32]),
947 b"<p>Rendered article</p>",
948 &site_renderer,
949 b"<html>Production shell</html>",
950 b"",
951 "https://example.com/posts/example-post",
952 )
953 );
954 assert_ne!(
955 baseline,
956 digest(
957 &post_revision,
958 b"<p>Changed article</p>",
959 &site_renderer,
960 b"<html>Production shell</html>",
961 b"",
962 "https://example.com/posts/example-post",
963 )
964 );
965 assert_ne!(
966 baseline,
967 digest(
968 &post_revision,
969 b"<p>Rendered article</p>",
970 &SiteShellRendererIdentity::new(frontend_bundle(0x44)),
971 b"<html>Production shell</html>",
972 b"",
973 "https://example.com/posts/example-post",
974 )
975 );
976 assert_ne!(
977 baseline,
978 digest(
979 &post_revision,
980 b"<p>Rendered article</p>",
981 &site_renderer,
982 b"<html>Changed shell</html>",
983 b"",
984 "https://example.com/posts/example-post",
985 )
986 );
987 assert_ne!(
988 baseline,
989 digest(
990 &post_revision,
991 b"<p>Rendered article</p>",
992 &site_renderer,
993 b"<html>Production shell</html>",
994 b"alice@example.com\0LNURL1EXAMPLE",
995 "https://example.com/posts/example-post",
996 )
997 );
998 assert_ne!(
999 baseline,
1000 digest(
1001 &post_revision,
1002 b"<p>Rendered article</p>",
1003 &site_renderer,
1004 b"<html>Production shell</html>",
1005 b"",
1006 "https://changed.example/posts/example-post",
1007 )
1008 );
1009 }
1010
1011 #[test]
1012 fn every_post_renderer_policy_field_is_identity_bearing() {
1013 let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1014 let post_assets = ResolvedPostAssets::new(&post, None, Vec::new());
1015 let site_assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
1016 let baseline_renderer = renderer();
1017 let revision = |renderer: &PostRendererIdentity| {
1018 finalize_post_revision(
1019 &post,
1020 &post_assets,
1021 &site_assets,
1022 renderer,
1023 b"<h1>Body</h1>",
1024 &[],
1025 )
1026 .unwrap()
1027 };
1028 let baseline_revision = revision(&baseline_renderer);
1029 let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x22));
1030 let preview = |renderer: &PostRendererIdentity| {
1031 finalize_preview_digest(PreviewDigestInput {
1032 publication: &publication,
1033 site_assets: &site_assets,
1034 post_id: &post.metadata.id,
1035 post_revision: &baseline_revision,
1036 post_renderer: renderer,
1037 article_identity_html: b"<h1>Body</h1>",
1038 site_renderer: &site_renderer,
1039 pre_injection_post_shell: b"<html>Production shell</html>",
1040 response_policy: b"fixture-policy",
1041 profile_projection: b"",
1042 canonical_url: "https://example.com/posts/example-post",
1043 })
1044 .unwrap()
1045 };
1046 let baseline_preview = preview(&baseline_renderer);
1047 let published_at = OffsetDateTime::from_unix_timestamp(1_777_734_400).unwrap();
1048 let snapshot = |revision: &PostRevisionDigest| {
1049 let published = [PublishedPostIdentityInput::new(
1050 &post.metadata.id,
1051 revision,
1052 published_at,
1053 )];
1054 finalize_site_snapshot(
1055 &publication,
1056 &site_assets,
1057 &site_renderer,
1058 &shell_output(b"shell"),
1059 &published,
1060 )
1061 .unwrap()
1062 };
1063 let baseline_snapshot = snapshot(&baseline_revision);
1064
1065 for (field, changed_renderer) in changed_renderer_policies(&baseline_renderer) {
1066 let changed_revision = revision(&changed_renderer);
1067 assert_ne!(
1068 baseline_revision, changed_revision,
1069 "{field} was omitted from post revision identity"
1070 );
1071 assert_ne!(
1072 baseline_preview,
1073 preview(&changed_renderer),
1074 "{field} was omitted from preview identity"
1075 );
1076 assert_ne!(
1077 baseline_snapshot,
1078 snapshot(&changed_revision),
1079 "{field} did not reach site identity through the post revision"
1080 );
1081 }
1082 }
1083
1084 #[test]
1085 fn every_canonical_post_field_and_authored_order_is_identity_bearing() {
1086 let baseline_frontmatter = frontmatter("2026-08-29T12:00:00Z");
1087 let (_, baseline_post) = validate_post(&baseline_frontmatter, "# Body\n");
1088 let baseline = digest_post_content(&baseline_post);
1089 let variants = [
1090 (
1091 "id",
1092 baseline_frontmatter.replace(
1093 "11111111-1111-4111-8111-111111111111",
1094 "22222222-2222-4222-8222-222222222222",
1095 ),
1096 ),
1097 (
1098 "title",
1099 baseline_frontmatter.replace("Example Post", "Changed Post"),
1100 ),
1101 (
1102 "slug",
1103 baseline_frontmatter.replace("example-post", "changed-post"),
1104 ),
1105 (
1106 "updated_at",
1107 format!("{baseline_frontmatter}updated_at = 2026-08-30T12:00:00Z\n"),
1108 ),
1109 (
1110 "description",
1111 baseline_frontmatter.replace("An example post.", "A changed post."),
1112 ),
1113 (
1114 "tags",
1115 baseline_frontmatter.replace(
1116 "tags = [\"Rust\", \"sqlite\"]",
1117 "tags = [\"Rust\", \"wal\"]",
1118 ),
1119 ),
1120 (
1121 "aliases",
1122 format!("{baseline_frontmatter}aliases = [\"old-example\"]\n"),
1123 ),
1124 ("draft", format!("{baseline_frontmatter}draft = true\n")),
1125 ("tips", format!("{baseline_frontmatter}tips = false\n")),
1126 ];
1127 for (field, frontmatter) in variants {
1128 let (_, changed) = validate_post(&frontmatter, "# Body\n");
1129 assert_ne!(
1130 baseline,
1131 digest_post_content(&changed),
1132 "{field} was omitted from canonical post identity"
1133 );
1134 }
1135
1136 let ordered = format!("{baseline_frontmatter}aliases = [\"first\", \"second\"]\n");
1137 let reversed_aliases = format!("{baseline_frontmatter}aliases = [\"second\", \"first\"]\n");
1138 let reversed_tags = baseline_frontmatter.replace(
1139 "tags = [\"Rust\", \"sqlite\"]",
1140 "tags = [\"sqlite\", \"Rust\"]",
1141 );
1142 let (_, ordered) = validate_post(&ordered, "# Body\n");
1143 let (_, reversed_aliases) = validate_post(&reversed_aliases, "# Body\n");
1144 let (_, reversed_tags) = validate_post(&reversed_tags, "# Body\n");
1145 assert_ne!(
1146 digest_post_content(&ordered),
1147 digest_post_content(&reversed_aliases)
1148 );
1149 assert_ne!(baseline, digest_post_content(&reversed_tags));
1150 }
1151
1152 #[test]
1153 fn post_revision_requires_and_hashes_every_component() {
1154 let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1155 let local = DigestedAsset::new(
1156 LogicalAssetPath::parse("assets/cover.webp").unwrap(),
1157 digest_asset(b"cover"),
1158 );
1159 let refs = [AssetRevisionReference::local(local.clone())];
1160 let generated = [DigestedAsset::new(
1161 LogicalAssetPath::parse("assets/generated/diagram.png").unwrap(),
1162 digest_asset(b"diagram"),
1163 )];
1164 let renderer = renderer();
1165 let baseline = digest_post_revision(&PostRevisionInput::new_unchecked(
1166 &post,
1167 &ResolvedPostAssets::new(&post, None, refs.to_vec()),
1168 &renderer,
1169 PreInjectionRenderedArticle::new(b"<h1>Body</h1>"),
1170 &generated,
1171 ))
1172 .unwrap();
1173 let without_generated = digest_post_revision(&PostRevisionInput::new_unchecked(
1174 &post,
1175 &ResolvedPostAssets::new(&post, None, refs.to_vec()),
1176 &renderer,
1177 PreInjectionRenderedArticle::new(b"<h1>Body</h1>"),
1178 &[],
1179 ))
1180 .unwrap();
1181 assert_eq!(
1182 without_generated.as_str(),
1183 "post-b3-v1-5c38447a9dbbe61da77fe4ffadec4431f5a915d3e226f55e053df8af37c97607"
1184 );
1185 let changed = digest_post_revision(&PostRevisionInput::new_unchecked(
1186 &post,
1187 &ResolvedPostAssets::new(&post, None, refs.to_vec()),
1188 &renderer,
1189 PreInjectionRenderedArticle::new(b"<h1>Changed</h1>"),
1190 &generated,
1191 ))
1192 .unwrap();
1193 assert_ne!(baseline, changed);
1194 assert_eq!(
1195 baseline.as_str(),
1196 "post-b3-v1-19825bff7919a4338aa4cef37de8e2a1f6a0e42aaf786f3a8094cc42e4843178"
1197 );
1198
1199 let duplicate_refs = [
1200 AssetRevisionReference::local(local.clone()),
1201 AssetRevisionReference::local(local),
1202 ];
1203 assert!(matches!(
1204 digest_post_revision(&PostRevisionInput::new_unchecked(
1205 &post,
1206 &ResolvedPostAssets::new(&post, None, duplicate_refs.to_vec()),
1207 &renderer,
1208 PreInjectionRenderedArticle::new(b"rendered"),
1209 &[],
1210 )),
1211 Err(RevisionIdentityError::DuplicateAssetReference { .. })
1212 ));
1213 drop(publication);
1214 }
1215
1216 #[test]
1217 fn asset_and_collection_order_cannot_change_revision_identity() {
1218 let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1219 let first = AssetRevisionReference::local(DigestedAsset::new(
1220 LogicalAssetPath::parse("assets/a.bin").unwrap(),
1221 digest_asset(b"a"),
1222 ));
1223 let second = AssetRevisionReference::external(
1224 ExternalAssetUrl::parse("https://cdn.example/b.bin").unwrap(),
1225 );
1226 let renderer = renderer();
1227 let forward = [first.clone(), second.clone()];
1228 let reverse = [second, first];
1229
1230 let digest = |assets: &[AssetRevisionReference]| {
1231 digest_post_revision(&PostRevisionInput::new_unchecked(
1232 &post,
1233 &ResolvedPostAssets::new(&post, None, assets.to_vec()),
1234 &renderer,
1235 PreInjectionRenderedArticle::new(b"rendered"),
1236 &[],
1237 ))
1238 .unwrap()
1239 };
1240 assert_eq!(digest(&forward), digest(&reverse));
1241 }
1242
1243 #[test]
1244 fn generated_assets_are_complete_revision_components() {
1245 let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1246 let renderer = renderer();
1247 let first = DigestedAsset::new(
1248 LogicalAssetPath::parse("assets/generated/a.bin").unwrap(),
1249 digest_asset(b"first"),
1250 );
1251 let second = DigestedAsset::new(
1252 LogicalAssetPath::parse("assets/generated/b.bin").unwrap(),
1253 digest_asset(b"second"),
1254 );
1255 let digest = |generated: &[DigestedAsset]| {
1256 digest_post_revision(&PostRevisionInput::new_unchecked(
1257 &post,
1258 &ResolvedPostAssets::new(&post, None, Vec::new()),
1259 &renderer,
1260 PreInjectionRenderedArticle::new(b"rendered"),
1261 generated,
1262 ))
1263 };
1264 let baseline = digest(&[first.clone(), second.clone()]).unwrap();
1265 assert_eq!(baseline, digest(&[second.clone(), first.clone()]).unwrap());
1266 assert_ne!(
1267 baseline,
1268 digest(&[
1269 DigestedAsset::new(first.path.clone(), digest_asset(b"changed")),
1270 second.clone()
1271 ])
1272 .unwrap()
1273 );
1274 assert_ne!(
1275 baseline,
1276 digest(&[
1277 DigestedAsset::new(
1278 LogicalAssetPath::parse("assets/generated/renamed.bin").unwrap(),
1279 first.digest.clone(),
1280 ),
1281 second.clone(),
1282 ])
1283 .unwrap()
1284 );
1285 assert!(matches!(
1286 digest(&[first.clone(), first.clone()]),
1287 Err(RevisionIdentityError::DuplicateGeneratedAsset { .. })
1288 ));
1289
1290 let digest_with_unreferenced_catalog_asset =
1291 |_asset: &AssetDigest| digest(&[first.clone(), second.clone()]).unwrap();
1292 assert_eq!(
1293 digest_with_unreferenced_catalog_asset(&digest_asset(b"not referenced")),
1294 digest_with_unreferenced_catalog_asset(&digest_asset(b"other bytes"))
1295 );
1296 }
1297
1298 #[test]
1299 fn local_asset_path_bytes_and_external_url_are_separate_revision_inputs() {
1300 let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1301 let renderer = renderer();
1302 let digest = |assets: &[AssetRevisionReference]| {
1303 digest_post_revision(&PostRevisionInput::new_unchecked(
1304 &post,
1305 &ResolvedPostAssets::new(&post, None, assets.to_vec()),
1306 &renderer,
1307 PreInjectionRenderedArticle::new(b"rendered"),
1308 &[],
1309 ))
1310 .unwrap()
1311 };
1312 let local = |path: &str, bytes: &[u8]| {
1313 AssetRevisionReference::local(DigestedAsset::new(
1314 LogicalAssetPath::parse(path).unwrap(),
1315 digest_asset(bytes),
1316 ))
1317 };
1318 let baseline = [local("assets/a.bin", b"same")];
1319 let changed_path = [local("assets/b.bin", b"same")];
1320 let changed_bytes = [local("assets/a.bin", b"changed")];
1321 let external_v1 = [AssetRevisionReference::external(
1322 ExternalAssetUrl::parse("https://cdn.example/a.bin?v=1").unwrap(),
1323 )];
1324 let external_v2 = [AssetRevisionReference::external(
1325 ExternalAssetUrl::parse("https://cdn.example/a.bin?v=2").unwrap(),
1326 )];
1327
1328 assert_ne!(digest(&baseline), digest(&changed_path));
1329 assert_ne!(digest(&baseline), digest(&changed_bytes));
1330 assert_ne!(digest(&external_v1), digest(&external_v2));
1331 }
1332
1333 #[test]
1334 fn resolved_image_normalization_is_stable_and_image_identity_is_required() {
1335 let first_frontmatter = format!(
1336 "{}image = \"HTTPS://CDN.EXAMPLE:443/image.png\"\n",
1337 frontmatter("2026-08-29T12:00:00Z")
1338 );
1339 let second_frontmatter = format!(
1340 "{}image = \"https://cdn.example/image.png\"\n",
1341 frontmatter("2026-08-29T12:00:00Z")
1342 );
1343 let (_, first) = validate_post(&first_frontmatter, "# Body\n");
1344 let (_, second) = validate_post(&second_frontmatter, "# Body\n");
1345 let image = AssetRevisionReference::external(
1346 ExternalAssetUrl::parse("https://cdn.example/image.png").unwrap(),
1347 );
1348 let first_resolved = ResolvedPostAssets::new(&first, Some(image.clone()), Vec::new());
1349 let second_resolved = ResolvedPostAssets::new(&second, Some(image), Vec::new());
1350 let unresolved = ResolvedPostAssets::new(&first, None, Vec::new());
1351 let renderer = renderer();
1352 let digest = |post: &PostDocument, assets: &ResolvedPostAssets| {
1353 digest_post_revision(&PostRevisionInput::new_unchecked(
1354 post,
1355 assets,
1356 &renderer,
1357 PreInjectionRenderedArticle::new(b"rendered"),
1358 &[],
1359 ))
1360 .unwrap()
1361 };
1362
1363 assert_eq!(
1364 digest(&first, &first_resolved),
1365 digest(&second, &second_resolved)
1366 );
1367 assert_ne!(digest(&first, &first_resolved), digest(&first, &unresolved));
1368 }
1369
1370 #[test]
1371 fn length_frames_prevent_adjacent_field_ambiguity() {
1372 let hash = |parts: &[&[u8]]| {
1373 let mut transcript = Transcript::new(
1374 "maincopy transcript boundary test v1",
1375 b"maincopy-boundary-test",
1376 1,
1377 );
1378 for part in parts {
1379 transcript.bytes(part);
1380 }
1381 transcript.finish()
1382 };
1383
1384 assert_ne!(
1385 hash(&[b"ab".as_slice(), b"c".as_slice()]),
1386 hash(&[b"a".as_slice(), b"bc".as_slice()])
1387 );
1388 }
1389
1390 #[test]
1391 fn site_identity_sorts_ledger_entries_and_normalizes_operational_time() {
1392 let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1393 let renderer = renderer();
1394 let revision = digest_post_revision(&PostRevisionInput::new_unchecked(
1395 &post,
1396 &ResolvedPostAssets::new(&post, None, Vec::new()),
1397 &renderer,
1398 PreInjectionRenderedArticle::new(b"rendered"),
1399 &[],
1400 ))
1401 .unwrap();
1402 let first = PublishedPostRevision::new(
1403 PostId::parse("11111111-1111-4111-8111-111111111111").unwrap(),
1404 revision.clone(),
1405 OffsetDateTime::from_unix_timestamp(1_777_734_400)
1406 .unwrap()
1407 .to_offset(time::UtcOffset::from_hms(-4, 0, 0).unwrap()),
1408 );
1409 let second = PublishedPostRevision::new(
1410 PostId::parse("22222222-2222-4222-8222-222222222222").unwrap(),
1411 revision,
1412 OffsetDateTime::from_unix_timestamp(1_777_734_400).unwrap(),
1413 );
1414 let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
1415 let forward = [first.clone(), second.clone()];
1416 let reverse = [second, first.clone()];
1417 let digest = |posts: &[PublishedPostRevision]| {
1418 let posts = published_identity_inputs(posts);
1419 digest_site_snapshot(&SiteSnapshotInput::new(
1420 &publication,
1421 &ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new()),
1422 &site_renderer,
1423 &shell_output(b"shell"),
1424 &posts,
1425 ))
1426 .unwrap()
1427 };
1428 assert_eq!(digest(&forward), digest(&reverse));
1429 assert_eq!(
1430 digest(&forward).as_str(),
1431 "site-b3-v1-bc150d340d0136acf65981bd06e2284c0c88c917261b6f293b1100cd0f0ddf09"
1432 );
1433
1434 let duplicate = [first.clone(), first];
1435 let duplicate = published_identity_inputs(&duplicate);
1436 assert!(matches!(
1437 digest_site_snapshot(&SiteSnapshotInput::new(
1438 &publication,
1439 &ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new()),
1440 &site_renderer,
1441 &shell_output(b"shell"),
1442 &duplicate,
1443 )),
1444 Err(RevisionIdentityError::DuplicatePublicPost { .. })
1445 ));
1446 }
1447
1448 #[test]
1449 fn renderer_output_public_revision_and_activation_each_change_site_identity() {
1450 let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1451 let post_renderer = renderer();
1452 let revision = digest_post_revision(&PostRevisionInput::new_unchecked(
1453 &post,
1454 &ResolvedPostAssets::new(&post, None, Vec::new()),
1455 &post_renderer,
1456 PreInjectionRenderedArticle::new(b"rendered"),
1457 &[],
1458 ))
1459 .unwrap();
1460 let post_id = PostId::parse("11111111-1111-4111-8111-111111111111").unwrap();
1461 let published_at = OffsetDateTime::from_unix_timestamp(1_777_734_400).unwrap();
1462 let baseline_post =
1463 PublishedPostRevision::new(post_id.clone(), revision.clone(), published_at);
1464 let changed_revision = PublishedPostRevision::new(
1465 post_id.clone(),
1466 PostRevisionDigest::parse(&format!("post-b3-v1-{}", "22".repeat(32))).unwrap(),
1467 published_at,
1468 );
1469 let changed_activation =
1470 PublishedPostRevision::new(post_id, revision, published_at + time::Duration::SECOND);
1471 let baseline_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
1472 let changed_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x22));
1473 let digest = |renderer: &SiteShellRendererIdentity,
1474 shell: &'static [u8],
1475 posts: &[PublishedPostRevision]| {
1476 let posts = published_identity_inputs(posts);
1477 digest_site_snapshot(&SiteSnapshotInput::new(
1478 &publication,
1479 &ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new()),
1480 renderer,
1481 &shell_output(shell),
1482 &posts,
1483 ))
1484 .unwrap()
1485 };
1486 let baseline = digest(
1487 &baseline_renderer,
1488 b"shell-a",
1489 std::slice::from_ref(&baseline_post),
1490 );
1491
1492 assert_ne!(
1493 baseline,
1494 digest(
1495 &changed_renderer,
1496 b"shell-a",
1497 std::slice::from_ref(&baseline_post)
1498 )
1499 );
1500 assert_ne!(
1501 baseline,
1502 digest(
1503 &baseline_renderer,
1504 b"shell-b",
1505 std::slice::from_ref(&baseline_post)
1506 )
1507 );
1508 assert_ne!(
1509 baseline,
1510 digest(
1511 &baseline_renderer,
1512 b"shell-a",
1513 std::slice::from_ref(&changed_revision)
1514 )
1515 );
1516 assert_ne!(
1517 baseline,
1518 digest(
1519 &baseline_renderer,
1520 b"shell-a",
1521 std::slice::from_ref(&changed_activation)
1522 )
1523 );
1524 }
1525
1526 #[test]
1527 fn publication_tip_default_has_stable_canonical_digest() {
1528 let digest = |source: &str| {
1529 let publication = validate_publication(source);
1530 blake3::Hash::from_bytes(digest_publication_content(&publication).0)
1531 .to_hex()
1532 .to_string()
1533 };
1534 let disabled = digest(PUBLICATION);
1535 for source in [
1536 format!("{PUBLICATION}\n[tips]\n"),
1537 format!("{PUBLICATION}\n[tips]\nenabled = false\n"),
1538 ] {
1539 assert_eq!(disabled, digest(&source));
1540 }
1541 let enabled = digest(&format!("{PUBLICATION}\n[tips]\nenabled = true\n"));
1542
1543 assert_eq!(
1544 [disabled.as_str(), enabled.as_str()],
1545 [
1546 "5fc545f41d08ee9fdcd91297ea58a7c9323d3bc90c2ac29bf7d1feed71bd6196",
1547 "fad5dcb2a50c69e1ff85de0de17a697d7578e3243648b875cac27ffcb169c497",
1548 ]
1549 );
1550 }
1551
1552 #[test]
1553 fn publication_favicon_and_site_references_are_required_site_components() {
1554 let renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
1555 let baseline_publication = validate_publication(PUBLICATION);
1556 let digest = |publication: &PublicationSettings, assets: &ResolvedSiteAssets| {
1557 digest_site_snapshot(&SiteSnapshotInput::new(
1558 publication,
1559 assets,
1560 &renderer,
1561 &shell_output(b"shell"),
1562 &[],
1563 ))
1564 .unwrap()
1565 };
1566 let baseline_assets =
1567 ResolvedSiteAssets::new(&baseline_publication, None, None, Vec::new(), Vec::new());
1568 let baseline = digest(&baseline_publication, &baseline_assets);
1569
1570 let variants = [
1571 PUBLICATION.replace("title = \"Example\"", "title = \"Changed\""),
1572 PUBLICATION.replace(
1573 "base_url = \"https://example.com/\"",
1574 "base_url = \"https://changed.example/\"",
1575 ),
1576 PUBLICATION.replace("An example publication.", "A changed publication."),
1577 PUBLICATION.replace("Example Author", "Changed Author"),
1578 ];
1579 for source in variants {
1580 let publication = validate_publication(&source);
1581 let assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
1582 assert_ne!(baseline, digest(&publication, &assets));
1583 }
1584
1585 let local = |path: &str, bytes: &[u8]| {
1586 AssetRevisionReference::local(DigestedAsset::new(
1587 LogicalAssetPath::parse(path).unwrap(),
1588 digest_asset(bytes),
1589 ))
1590 };
1591 let favicon = local("assets/favicon.png", b"favicon");
1592 let favicon_changed = local("assets/favicon.png", b"changed favicon");
1593 let reference = local("assets/site/banner.png", b"banner");
1594 let reference_changed_path = local("assets/site/renamed.png", b"banner");
1595 let reference_changed_bytes = local("assets/site/banner.png", b"changed banner");
1596 let favicon_identity = digest(
1597 &baseline_publication,
1598 &ResolvedSiteAssets::new(
1599 &baseline_publication,
1600 Some(favicon),
1601 None,
1602 Vec::new(),
1603 Vec::new(),
1604 ),
1605 );
1606 let changed_favicon_identity = digest(
1607 &baseline_publication,
1608 &ResolvedSiteAssets::new(
1609 &baseline_publication,
1610 Some(favicon_changed),
1611 None,
1612 Vec::new(),
1613 Vec::new(),
1614 ),
1615 );
1616 let reference_identity = digest(
1617 &baseline_publication,
1618 &ResolvedSiteAssets::new(
1619 &baseline_publication,
1620 None,
1621 None,
1622 Vec::new(),
1623 vec![reference],
1624 ),
1625 );
1626 let changed_reference_path_identity = digest(
1627 &baseline_publication,
1628 &ResolvedSiteAssets::new(
1629 &baseline_publication,
1630 None,
1631 None,
1632 Vec::new(),
1633 vec![reference_changed_path],
1634 ),
1635 );
1636 let changed_reference_bytes_identity = digest(
1637 &baseline_publication,
1638 &ResolvedSiteAssets::new(
1639 &baseline_publication,
1640 None,
1641 None,
1642 Vec::new(),
1643 vec![reference_changed_bytes],
1644 ),
1645 );
1646 assert_ne!(baseline, favicon_identity);
1647 assert_ne!(favicon_identity, changed_favicon_identity);
1648 assert_ne!(baseline, reference_identity);
1649 assert_ne!(reference_identity, changed_reference_path_identity);
1650 assert_ne!(reference_identity, changed_reference_bytes_identity);
1651 }
1652
1653 #[test]
1654 fn resolved_asset_capabilities_are_bound_to_their_source_content() {
1655 let baseline_frontmatter = frontmatter("2026-08-29T12:00:00Z");
1656 let (_, baseline_post) = validate_post(&baseline_frontmatter, "# Body\n");
1657 let changed_frontmatter = baseline_frontmatter.replace("Example Post", "Changed Post");
1658 let (_, changed_post) = validate_post(&changed_frontmatter, "# Body\n");
1659 let post_assets = ResolvedPostAssets::new(&baseline_post, None, Vec::new());
1660 let post_renderer = renderer();
1661 assert!(matches!(
1662 digest_post_revision(&PostRevisionInput::new_unchecked(
1663 &changed_post,
1664 &post_assets,
1665 &post_renderer,
1666 PreInjectionRenderedArticle::new(b"rendered"),
1667 &[],
1668 )),
1669 Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
1670 target: AssetBindingTarget::Post
1671 })
1672 ));
1673
1674 let image_frontmatter =
1675 format!("{baseline_frontmatter}image = \"https://cdn.example/a.png\"\n");
1676 let changed_image_frontmatter =
1677 image_frontmatter.replace("cdn.example/a.png", "cdn.example/b.png");
1678 let (_, image_post) = validate_post(&image_frontmatter, "# Body\n");
1679 let (_, changed_image_post) = validate_post(&changed_image_frontmatter, "# Body\n");
1680 let image_assets = ResolvedPostAssets::new(
1681 &image_post,
1682 Some(AssetRevisionReference::external(
1683 ExternalAssetUrl::parse("https://cdn.example/a.png").unwrap(),
1684 )),
1685 Vec::new(),
1686 );
1687 assert!(matches!(
1688 digest_post_revision(&PostRevisionInput::new_unchecked(
1689 &changed_image_post,
1690 &image_assets,
1691 &post_renderer,
1692 PreInjectionRenderedArticle::new(b"rendered"),
1693 &[],
1694 )),
1695 Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
1696 target: AssetBindingTarget::Post
1697 })
1698 ));
1699
1700 let publication = validate_publication(PUBLICATION);
1701 let changed_publication = validate_publication(
1702 &PUBLICATION.replace("title = \"Example\"", "title = \"Changed\""),
1703 );
1704 let site_assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
1705 let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
1706 assert!(matches!(
1707 digest_site_snapshot(&SiteSnapshotInput::new(
1708 &changed_publication,
1709 &site_assets,
1710 &site_renderer,
1711 &shell_output(b"shell"),
1712 &[],
1713 )),
1714 Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
1715 target: AssetBindingTarget::Publication
1716 })
1717 ));
1718
1719 let favicon_source = PUBLICATION.replace(
1720 "description = \"An example publication.\"",
1721 "description = \"An example publication.\"\nfavicon = \"https://cdn.example/a.png\"",
1722 );
1723 let changed_favicon_source =
1724 favicon_source.replace("cdn.example/a.png", "cdn.example/b.png");
1725 let favicon_publication = validate_publication(&favicon_source);
1726 let changed_favicon_publication = validate_publication(&changed_favicon_source);
1727 let favicon_assets = ResolvedSiteAssets::new(
1728 &favicon_publication,
1729 Some(AssetRevisionReference::external(
1730 ExternalAssetUrl::parse("https://cdn.example/a.png").unwrap(),
1731 )),
1732 None,
1733 Vec::new(),
1734 Vec::new(),
1735 );
1736 assert!(matches!(
1737 digest_site_snapshot(&SiteSnapshotInput::new(
1738 &changed_favicon_publication,
1739 &favicon_assets,
1740 &site_renderer,
1741 &shell_output(b"shell"),
1742 &[],
1743 )),
1744 Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
1745 target: AssetBindingTarget::Publication
1746 })
1747 ));
1748
1749 let origin_source = format!(
1750 "{PUBLICATION}\n[assets]\nallowed_https_origins = [\"HTTPS://A.EXAMPLE:443\"]\n"
1751 );
1752 let equivalent_origin_source =
1753 format!("{PUBLICATION}\n[assets]\nallowed_https_origins = [\"https://a.example/\"]\n");
1754 let origin_publication = validate_publication(&origin_source);
1755 let equivalent_origin_publication = validate_publication(&equivalent_origin_source);
1756 let origin_assets = ResolvedSiteAssets::new(
1757 &origin_publication,
1758 None,
1759 None,
1760 vec![ExternalAssetOrigin::parse("https://a.example/").unwrap()],
1761 Vec::new(),
1762 );
1763 assert!(matches!(
1764 digest_site_snapshot(&SiteSnapshotInput::new(
1765 &equivalent_origin_publication,
1766 &origin_assets,
1767 &site_renderer,
1768 &shell_output(b"shell"),
1769 &[],
1770 )),
1771 Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
1772 target: AssetBindingTarget::Publication
1773 })
1774 ));
1775 }
1776
1777 #[test]
1778 fn post_revision_rejects_assets_approved_before_allowlist_revocation() {
1779 let image_frontmatter = format!(
1780 "{}image = \"https://cdn.example/cover-v1.png\"\n",
1781 frontmatter("2026-08-29T12:00:00Z")
1782 );
1783 let (publication, post) = validate_post(&image_frontmatter, "# Body\n");
1784 let allowed = vec![ExternalAssetOrigin::parse("https://cdn.example/").unwrap()];
1785 let expanded = vec![
1786 ExternalAssetOrigin::parse("https://cdn.example/").unwrap(),
1787 ExternalAssetOrigin::parse("https://media.example/").unwrap(),
1788 ];
1789 let image = AssetRevisionReference::external(
1790 ExternalAssetUrl::parse("https://cdn.example/cover-v1.png").unwrap(),
1791 );
1792 let approved_assets = ResolvedPostAssets::from_resolution(
1793 &post,
1794 &digest_post_content(&post),
1795 &allowed,
1796 Some(image.clone()),
1797 Vec::new(),
1798 Vec::new(),
1799 );
1800 let expanded_assets = ResolvedPostAssets::from_resolution(
1801 &post,
1802 &digest_post_content(&post),
1803 &expanded,
1804 Some(image),
1805 Vec::new(),
1806 Vec::new(),
1807 );
1808 let approved_site = ResolvedSiteAssets::new(&publication, None, None, allowed, Vec::new());
1809 let expanded_site = ResolvedSiteAssets::new(&publication, None, None, expanded, Vec::new());
1810 let revoked_site =
1811 ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
1812 let renderer = renderer();
1813 let digest = |assets: &ResolvedPostAssets, site_assets: &ResolvedSiteAssets| {
1814 digest_post_revision(&PostRevisionInput::new(
1815 &post,
1816 assets,
1817 site_assets,
1818 &renderer,
1819 PreInjectionRenderedArticle::new(b"rendered"),
1820 &[],
1821 ))
1822 };
1823
1824 let approved = digest(&approved_assets, &approved_site).unwrap();
1825 assert_eq!(approved, digest(&expanded_assets, &expanded_site).unwrap());
1826 assert_eq!(
1827 digest(&approved_assets, &revoked_site),
1828 Err(RevisionIdentityError::ResolvedAssetPolicyMismatch)
1829 );
1830 assert_eq!(
1831 digest(&approved_assets, &expanded_site),
1832 Err(RevisionIdentityError::ResolvedAssetPolicyMismatch)
1833 );
1834 }
1835
1836 #[test]
1837 fn effective_normalized_allowlist_is_order_independent_and_identity_bearing() {
1838 let authored_first = format!(
1839 "{PUBLICATION}\n[assets]\nallowed_https_origins = [\"HTTPS://A.EXAMPLE:443\"]\n"
1840 );
1841 let authored_equivalent =
1842 format!("{PUBLICATION}\n[assets]\nallowed_https_origins = [\"https://a.example/\"]\n");
1843 let publication = validate_publication(&authored_first);
1844 let equivalent_publication = validate_publication(&authored_equivalent);
1845 let renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
1846 let first = ExternalAssetOrigin::parse("https://a.example").unwrap();
1847 let second = ExternalAssetOrigin::parse("HTTPS://B.EXAMPLE:443/").unwrap();
1848 let changed = ExternalAssetOrigin::parse("https://c.example/").unwrap();
1849 let digest = |publication: &PublicationSettings, origins: Vec<ExternalAssetOrigin>| {
1850 digest_site_snapshot(&SiteSnapshotInput::new(
1851 publication,
1852 &ResolvedSiteAssets::new(publication, None, None, origins, Vec::new()),
1853 &renderer,
1854 &shell_output(b"shell"),
1855 &[],
1856 ))
1857 .unwrap()
1858 };
1859
1860 assert_eq!(
1861 digest(&publication, vec![first.clone(), second.clone()]),
1862 digest(&publication, vec![second.clone(), first])
1863 );
1864 assert_eq!(
1865 digest(
1866 &publication,
1867 vec![ExternalAssetOrigin::parse("https://a.example").unwrap()]
1868 ),
1869 digest(
1870 &equivalent_publication,
1871 vec![ExternalAssetOrigin::parse("https://a.example/").unwrap()]
1872 )
1873 );
1874 assert_ne!(
1875 digest(&publication, vec![second]),
1876 digest(&publication, vec![changed])
1877 );
1878 let duplicate = ExternalAssetOrigin::parse("https://duplicate.example").unwrap();
1879 assert!(matches!(
1880 digest_site_snapshot(&SiteSnapshotInput::new(
1881 &publication,
1882 &ResolvedSiteAssets::new(
1883 &publication,
1884 None,
1885 None,
1886 vec![duplicate.clone(), duplicate],
1887 Vec::new(),
1888 ),
1889 &renderer,
1890 &shell_output(b"shell"),
1891 &[],
1892 )),
1893 Err(RevisionIdentityError::DuplicateAllowedOrigin { .. })
1894 ));
1895 }
1896
1897 #[test]
1898 fn advisory_git_provenance_is_not_a_revision_input() {
1899 let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
1900 let renderer = renderer();
1901 let digest = |_provenance: Option<&SourceCommit>| {
1902 digest_post_revision(&PostRevisionInput::new_unchecked(
1903 &post,
1904 &ResolvedPostAssets::new(&post, None, Vec::new()),
1905 &renderer,
1906 PreInjectionRenderedArticle::new(b"rendered"),
1907 &[],
1908 ))
1909 .unwrap()
1910 };
1911 let first_commit = SourceCommit::parse(&format!("git-sha1:{}", "11".repeat(20))).unwrap();
1912 let second_commit =
1913 SourceCommit::parse(&format!("git-sha256:{}", "22".repeat(32))).unwrap();
1914
1915 assert_ne!(first_commit, second_commit);
1916 assert_eq!(digest(None), digest(Some(&first_commit)));
1917 assert_eq!(digest(Some(&first_commit)), digest(Some(&second_commit)));
1918 }
1919
1920 #[test]
1921 fn asset_digest_has_a_stable_domain_separated_golden_value() {
1922 let asset = digest_asset(b"maincopy");
1923 assert_eq!(
1924 asset.as_str(),
1925 "asset-b3-v1-20b8cb3fe0a1f1eae595a39939aef0e08660b7117e462d4d0b4f9510075681ae"
1926 );
1927 assert_ne!(asset.as_bytes(), &frontend_bundle(0x33));
1928 }
1929}