Skip to main content

markdown_compiler/
identity.rs

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
25// These frozen tags are the review boundary for the closed v1 rendering
26// policy. Change the relevant tag when a policy or implementation changes.
27const 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;
34// This tag freezes public content-asset MIME, disposition, and security-header
35// behavior into the snapshot URL. Bump it whenever that delivery policy changes.
36const PUBLIC_ASSET_DELIVERY_POLICY_TAG: u8 = 0;
37const LOCAL_PROFILE_KIND_TAG: u8 = 0;
38const LOCAL_PROFILE_SCHEMA_VERSION: u16 = 1;
39
40/// Canonical typed post content excluding resolver-owned asset-valued fields.
41#[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/// A resolver capability binding, never an input to a public content digest.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub(super) struct PostAssetSourceBinding([u8; 32]);
50
51/// A resolver capability binding, never an input to a public content digest.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub(super) struct PublicationAssetSourceBinding([u8; 32]);
54
55/// A private capability binding for the effective external-asset allowlist.
56#[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
103/// Streaming, domain-separated identity for sorted pre-injection shell pages.
104pub 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
130/// Every component is required, even when an asset collection is empty.
131struct 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
178/// Render-neutral inputs used to close a public snapshot identity.
179pub 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
199/// Every component is required, including the public publication-ledger view.
200struct 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
392/// Close the post-rendering capability into its public revision identity.
393///
394/// Independently supplied source and asset inputs are checked before encoding.
395/// Prepared posts retain these relationships and finalize through their own capability.
396pub 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
414/// Closes the exact production-preview inputs into a schedule-independent approval binding.
415///
416/// The retained content-tree digest is deliberately not an input: it locates the artifacts used
417/// to reproduce this binding, while unrelated files in the same tree cannot invalidate a preview.
418/// Activation timestamps are also excluded so an accepted preview remains valid at its due time.
419pub 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
462/// Renderer-owned inputs to one preview approval binding.
463pub 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    /// Exact public response policy approved with the presentation.
473    pub response_policy: &'input [u8],
474    /// Opaque, canonical bytes for the SQLite-owned public profile projection.
475    ///
476    /// The profile domain owns this encoding. The identity layer only frames it
477    /// into the versioned preview transcript.
478    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
514/// Close the site-rendering capability into its public snapshot identity.
515///
516/// The site renderer is the sole production caller. Public callers cannot
517/// combine arbitrary shell bytes, frontend identity, or publication-ledger
518/// entries into a snapshot digest.
519pub 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    // Asset-valued fields are excluded here. The final revision transcript
544    // requires resolver-owned normalized asset inputs separately.
545    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    // Asset-valued fields are excluded here. The final site transcript
593    // requires resolver-owned favicon, image, allowlist, and reference inputs.
594    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}