use serde::{Deserialize, Serialize};
use thiserror::Error;
use time::OffsetDateTime;
use crate::{DefaultPostTipPolicy, transcript::Transcript};
use super::{
AssetDigest, AssetRevisionReference, DigestedAsset, DraftStatus, ExternalAssetOrigin,
PostDocument, PostId, PostRevisionDigest, PostTipPolicy, PreviewDigest, PublicationSettings,
ResolvedPostAssets, ResolvedSiteAssets, SiteSnapshotDigest,
};
const ASSET_CONTEXT: &str = "maincopy asset digest v1";
const POST_CONTENT_CONTEXT: &str = "maincopy post content digest v1";
const PUBLICATION_CONTENT_CONTEXT: &str = "maincopy publication content digest v1";
const POST_ASSET_SOURCE_BINDING_CONTEXT: &str = "maincopy post asset source binding v1";
const PUBLICATION_ASSET_SOURCE_BINDING_CONTEXT: &str =
"maincopy publication asset source binding v1";
const ASSET_RESOLUTION_POLICY_BINDING_CONTEXT: &str = "maincopy asset resolution policy binding v1";
const POST_REVISION_CONTEXT: &str = "maincopy post revision digest v1";
const PREVIEW_CONTEXT: &str = "maincopy preview digest v1";
const SITE_SHELL_OUTPUT_CONTEXT: &str = "maincopy site shell output digest v1";
const SITE_SNAPSHOT_CONTEXT: &str = "maincopy site snapshot digest v1";
const COMMONMARK_DIALECT_TAG: u8 = 0;
const RAW_HTML_DISABLED_TAG: u8 = 0;
const CODE_LANGUAGE_CLASS_POLICY_TAG: u8 = 1;
const MERMAID_SANITIZED_SVG_TAG: u8 = 1;
const POST_RENDERER_VERSION_TAG: u8 = 2;
const SANITIZER_VERSION_TAG: u8 = 1;
const SITE_SHELL_RENDERER_VERSION_TAG: u8 = 1;
const PUBLIC_ASSET_DELIVERY_POLICY_TAG: u8 = 0;
const LOCAL_PROFILE_KIND_TAG: u8 = 0;
const LOCAL_PROFILE_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PostContentDigest([u8; 32]);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PublicationContentDigest([u8; 32]);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct PostAssetSourceBinding([u8; 32]);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct PublicationAssetSourceBinding([u8; 32]);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct AssetResolutionPolicyBinding([u8; 32]);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PostRendererIdentity {
commonmark_dialect: u8,
raw_html_disabled: u8,
code_language_classes: u8,
mermaid_sanitized_svg: u8,
renderer_version: u8,
sanitizer_version: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct PreInjectionRenderedArticle<'bytes>(&'bytes [u8]);
impl<'bytes> PreInjectionRenderedArticle<'bytes> {
pub(super) const fn new(bytes: &'bytes [u8]) -> Self {
Self(bytes)
}
pub const fn as_bytes(self) -> &'bytes [u8] {
self.0
}
}
impl PostRendererIdentity {
pub const fn baseline() -> Self {
Self {
commonmark_dialect: COMMONMARK_DIALECT_TAG,
raw_html_disabled: RAW_HTML_DISABLED_TAG,
code_language_classes: CODE_LANGUAGE_CLASS_POLICY_TAG,
mermaid_sanitized_svg: MERMAID_SANITIZED_SVG_TAG,
renderer_version: POST_RENDERER_VERSION_TAG,
sanitizer_version: SANITIZER_VERSION_TAG,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SiteShellRendererIdentity {
pub frontend_bundle: [u8; 32],
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SiteShellOutputDigest([u8; 32]);
pub struct SiteShellOutputHasher(Transcript);
impl SiteShellOutputHasher {
pub fn new(page_count: usize) -> Self {
let mut transcript =
Transcript::new(SITE_SHELL_OUTPUT_CONTEXT, b"maincopy-site-shell-output", 1);
transcript.sequence_len(page_count);
Self(transcript)
}
pub fn page(&mut self, route: &str, bytes: &[u8]) {
self.0.string(route);
self.0.bytes(bytes);
}
pub fn finish(self) -> SiteShellOutputDigest {
SiteShellOutputDigest(*self.0.finish().as_bytes())
}
}
impl SiteShellRendererIdentity {
pub const fn new(frontend_bundle: [u8; 32]) -> Self {
Self { frontend_bundle }
}
}
struct PostRevisionInput<'input> {
document: &'input PostDocument,
assets: &'input ResolvedPostAssets,
site_assets: Option<&'input ResolvedSiteAssets>,
renderer: &'input PostRendererIdentity,
pre_injection_article: PreInjectionRenderedArticle<'input>,
generated_assets: &'input [DigestedAsset],
}
impl<'input> PostRevisionInput<'input> {
const fn new(
document: &'input PostDocument,
assets: &'input ResolvedPostAssets,
site_assets: &'input ResolvedSiteAssets,
renderer: &'input PostRendererIdentity,
pre_injection_article: PreInjectionRenderedArticle<'input>,
generated_assets: &'input [DigestedAsset],
) -> Self {
Self {
document,
assets,
site_assets: Some(site_assets),
renderer,
pre_injection_article,
generated_assets,
}
}
#[cfg(test)]
const fn new_unchecked(
document: &'input PostDocument,
assets: &'input ResolvedPostAssets,
renderer: &'input PostRendererIdentity,
pre_injection_article: PreInjectionRenderedArticle<'input>,
generated_assets: &'input [DigestedAsset],
) -> Self {
Self {
document,
assets,
site_assets: None,
renderer,
pre_injection_article,
generated_assets,
}
}
}
pub struct PublishedPostIdentityInput<'input> {
pub post_id: &'input PostId,
pub revision: &'input PostRevisionDigest,
pub published_at: OffsetDateTime,
}
impl<'input> PublishedPostIdentityInput<'input> {
pub const fn new(
post_id: &'input PostId,
revision: &'input PostRevisionDigest,
published_at: OffsetDateTime,
) -> Self {
Self {
post_id,
revision,
published_at,
}
}
}
struct SiteSnapshotInput<'input> {
publication: &'input PublicationSettings,
assets: &'input ResolvedSiteAssets,
renderer: &'input SiteShellRendererIdentity,
pre_injection_shell: &'input SiteShellOutputDigest,
public_posts: &'input [PublishedPostIdentityInput<'input>],
}
impl<'input> SiteSnapshotInput<'input> {
const fn new(
publication: &'input PublicationSettings,
assets: &'input ResolvedSiteAssets,
renderer: &'input SiteShellRendererIdentity,
pre_injection_shell: &'input SiteShellOutputDigest,
public_posts: &'input [PublishedPostIdentityInput<'input>],
) -> Self {
Self {
publication,
assets,
renderer,
pre_injection_shell,
public_posts,
}
}
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum RevisionIdentityError {
#[error("resolved asset inputs are bound to different {target:?} content")]
ResolvedAssetBindingMismatch { target: AssetBindingTarget },
#[error("post assets were approved under a different external-asset policy")]
ResolvedAssetPolicyMismatch,
#[error("asset reference is duplicated: {value}")]
DuplicateAssetReference { value: String },
#[error("generated asset path is duplicated: {path}")]
DuplicateGeneratedAsset { path: String },
#[error("effective asset origin is duplicated: {origin}")]
DuplicateAllowedOrigin { origin: String },
#[error("public post identity is duplicated: {post_id}")]
DuplicatePublicPost { post_id: PostId },
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetBindingTarget {
Post,
Publication,
}
pub fn digest_asset(bytes: &[u8]) -> AssetDigest {
let mut transcript = Transcript::new(ASSET_CONTEXT, b"maincopy-asset", 1);
transcript.bytes(bytes);
AssetDigest::from_hash(transcript.finish())
}
pub(crate) fn digest_post_content(document: &PostDocument) -> PostContentDigest {
let mut transcript = Transcript::new(POST_CONTENT_CONTEXT, b"maincopy-post-content", 1);
encode_post_document(&mut transcript, document);
PostContentDigest(*transcript.finish().as_bytes())
}
pub(crate) fn digest_publication_content(
publication: &PublicationSettings,
) -> PublicationContentDigest {
let mut transcript = Transcript::new(
PUBLICATION_CONTENT_CONTEXT,
b"maincopy-publication-content",
1,
);
encode_publication(&mut transcript, publication);
PublicationContentDigest(*transcript.finish().as_bytes())
}
pub(super) fn bind_post_asset_source_with_content(
document: &PostDocument,
canonical_content: &PostContentDigest,
) -> PostAssetSourceBinding {
let mut transcript = Transcript::new(
POST_ASSET_SOURCE_BINDING_CONTEXT,
b"maincopy-post-asset-source-binding",
1,
);
transcript.fixed_bytes(&canonical_content.0);
transcript.optional(document.metadata.image.as_ref(), |transcript, image| {
transcript.string(image.as_str());
});
PostAssetSourceBinding(*transcript.finish().as_bytes())
}
pub(super) fn bind_publication_asset_source(
publication: &PublicationSettings,
) -> PublicationAssetSourceBinding {
let canonical_content = digest_publication_content(publication);
let mut transcript = Transcript::new(
PUBLICATION_ASSET_SOURCE_BINDING_CONTEXT,
b"maincopy-publication-asset-source-binding",
1,
);
transcript.fixed_bytes(&canonical_content.0);
transcript.optional(publication.site.favicon.as_ref(), |transcript, favicon| {
transcript.string(favicon.as_str());
});
transcript.optional(publication.site.image.as_ref(), |transcript, image| {
transcript.string(image.as_str());
});
let authored_origins = &publication.assets.allowed_https_origins;
transcript.sequence_len(authored_origins.len());
for origin in authored_origins {
transcript.string(origin.as_str());
}
PublicationAssetSourceBinding(*transcript.finish().as_bytes())
}
pub(super) fn bind_asset_resolution_policy(
allowed_origins: &[ExternalAssetOrigin],
) -> AssetResolutionPolicyBinding {
let mut origins: Vec<_> = allowed_origins.iter().collect();
origins.sort_by(|left, right| left.as_str().cmp(right.as_str()));
let mut transcript = Transcript::new(
ASSET_RESOLUTION_POLICY_BINDING_CONTEXT,
b"maincopy-asset-resolution-policy-binding",
1,
);
transcript.sequence_len(origins.len());
for origin in origins {
transcript.string(origin.as_str());
}
AssetResolutionPolicyBinding(*transcript.finish().as_bytes())
}
fn digest_post_revision(
input: &PostRevisionInput<'_>,
) -> Result<PostRevisionDigest, RevisionIdentityError> {
let referenced_assets = sorted_asset_references(&input.assets.references)?;
let generated_assets = sorted_generated_assets(input.generated_assets)?;
let content = digest_post_content(input.document);
if input.assets.source_binding != bind_post_asset_source_with_content(input.document, &content)
{
return Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Post,
});
}
if input
.site_assets
.is_some_and(|site_assets| input.assets.policy_binding != site_assets.policy_binding)
{
return Err(RevisionIdentityError::ResolvedAssetPolicyMismatch);
}
Ok(digest_post_revision_components(
&content,
input.assets,
referenced_assets.into_iter(),
input.renderer,
input.pre_injection_article.as_bytes(),
&generated_assets,
))
}
pub(super) fn digest_prepared_post_revision(
content: &PostContentDigest,
assets: &ResolvedPostAssets,
renderer: &PostRendererIdentity,
pre_injection_article: &[u8],
) -> PostRevisionDigest {
digest_post_revision_components(
content,
assets,
assets.references.iter(),
renderer,
pre_injection_article,
&[],
)
}
fn digest_post_revision_components<'assets>(
content: &PostContentDigest,
assets: &ResolvedPostAssets,
references: impl ExactSizeIterator<Item = &'assets AssetRevisionReference>,
renderer: &PostRendererIdentity,
pre_injection_article: &[u8],
generated_assets: &[&DigestedAsset],
) -> PostRevisionDigest {
let mut transcript = Transcript::new(POST_REVISION_CONTEXT, b"maincopy-post-revision", 1);
transcript.fixed_bytes(&content.0);
transcript.optional(assets.image.as_ref(), encode_asset_reference);
encode_asset_references(&mut transcript, references);
encode_post_renderer(&mut transcript, renderer);
transcript.bytes(pre_injection_article);
encode_generated_assets(&mut transcript, generated_assets);
PostRevisionDigest::from_hash(transcript.finish())
}
pub fn finalize_post_revision(
document: &PostDocument,
assets: &ResolvedPostAssets,
site_assets: &ResolvedSiteAssets,
renderer: &PostRendererIdentity,
pre_injection_article: &[u8],
generated_assets: &[DigestedAsset],
) -> Result<PostRevisionDigest, RevisionIdentityError> {
digest_post_revision(&PostRevisionInput::new(
document,
assets,
site_assets,
renderer,
PreInjectionRenderedArticle::new(pre_injection_article),
generated_assets,
))
}
pub fn finalize_preview_digest(
input: PreviewDigestInput<'_>,
) -> Result<PreviewDigest, RevisionIdentityError> {
if input.site_assets.source_binding != bind_publication_asset_source(input.publication) {
return Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Publication,
});
}
let references = sorted_asset_references(&input.site_assets.references)?;
let allowed_origins = sorted_allowed_origins(&input.site_assets.allowed_origins)?;
let publication_content = digest_publication_content(input.publication);
let mut transcript = Transcript::new(PREVIEW_CONTEXT, b"maincopy-preview", 1);
transcript.tag(0);
transcript.fixed_bytes(input.post_id.as_uuid().as_bytes());
transcript.tag(1);
transcript.fixed_bytes(input.post_revision.as_bytes());
transcript.tag(2);
transcript.bytes(input.article_identity_html);
transcript.tag(3);
encode_post_renderer(&mut transcript, input.post_renderer);
transcript.tag(4);
transcript.fixed_bytes(&publication_content.0);
transcript.optional(input.site_assets.favicon.as_ref(), encode_asset_reference);
transcript.optional(input.site_assets.image.as_ref(), encode_asset_reference);
transcript.sequence_len(allowed_origins.len());
for origin in allowed_origins {
transcript.string(origin.as_str());
}
encode_asset_references(&mut transcript, references.into_iter());
transcript.tag(5);
encode_site_renderer(&mut transcript, input.site_renderer);
transcript.bytes(input.pre_injection_post_shell);
transcript.bytes(input.response_policy);
transcript.tag(6);
transcript.tag(LOCAL_PROFILE_KIND_TAG);
transcript.fixed_bytes(&LOCAL_PROFILE_SCHEMA_VERSION.to_be_bytes());
transcript.bytes(input.profile_projection);
transcript.tag(7);
transcript.string(input.canonical_url);
Ok(PreviewDigest::from_hash(transcript.finish()))
}
pub struct PreviewDigestInput<'input> {
pub publication: &'input PublicationSettings,
pub site_assets: &'input ResolvedSiteAssets,
pub post_id: &'input PostId,
pub post_revision: &'input PostRevisionDigest,
pub post_renderer: &'input PostRendererIdentity,
pub article_identity_html: &'input [u8],
pub site_renderer: &'input SiteShellRendererIdentity,
pub pre_injection_post_shell: &'input [u8],
pub response_policy: &'input [u8],
pub profile_projection: &'input [u8],
pub canonical_url: &'input str,
}
fn digest_site_snapshot(
input: &SiteSnapshotInput<'_>,
) -> Result<SiteSnapshotDigest, RevisionIdentityError> {
let site_assets = sorted_asset_references(&input.assets.references)?;
let allowed_origins = sorted_allowed_origins(&input.assets.allowed_origins)?;
let public_posts = sorted_public_posts(input.public_posts)?;
let publication_content = digest_publication_content(input.publication);
if input.assets.source_binding != bind_publication_asset_source(input.publication) {
return Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Publication,
});
}
let mut transcript = Transcript::new(SITE_SNAPSHOT_CONTEXT, b"maincopy-site-snapshot", 1);
transcript.fixed_bytes(&publication_content.0);
transcript.optional(input.assets.favicon.as_ref(), encode_asset_reference);
transcript.optional(input.assets.image.as_ref(), encode_asset_reference);
transcript.sequence_len(allowed_origins.len());
for origin in allowed_origins {
transcript.string(origin.as_str());
}
encode_asset_references(&mut transcript, site_assets.into_iter());
encode_site_renderer(&mut transcript, input.renderer);
transcript.fixed_bytes(&input.pre_injection_shell.0);
transcript.sequence_len(public_posts.len());
for post in public_posts {
transcript.fixed_bytes(post.post_id.as_uuid().as_bytes());
transcript.fixed_bytes(post.revision.as_bytes());
transcript.utc_timestamp(post.published_at);
}
Ok(SiteSnapshotDigest::from_hash(transcript.finish()))
}
pub fn finalize_site_snapshot(
publication: &PublicationSettings,
assets: &ResolvedSiteAssets,
renderer: &SiteShellRendererIdentity,
pre_injection_shell: &SiteShellOutputDigest,
public_posts: &[PublishedPostIdentityInput<'_>],
) -> Result<SiteSnapshotDigest, RevisionIdentityError> {
digest_site_snapshot(&SiteSnapshotInput::new(
publication,
assets,
renderer,
pre_injection_shell,
public_posts,
))
}
fn encode_post_document(transcript: &mut Transcript, document: &PostDocument) {
let metadata = &document.metadata;
transcript.fixed_bytes(metadata.id.as_uuid().as_bytes());
transcript.string(metadata.title.as_str());
transcript.string(metadata.slug.as_str());
transcript.authored_timestamp(metadata.authored_at);
transcript.optional(metadata.updated_at, Transcript::authored_timestamp);
transcript.string(metadata.description.as_str());
transcript.sequence_len(metadata.tags.len());
for tag in &metadata.tags {
transcript.string(tag.as_str());
}
transcript.sequence_len(metadata.aliases.len());
for alias in &metadata.aliases {
transcript.string(alias.as_str());
}
transcript.tag(match metadata.draft {
DraftStatus::Publishable => 0,
DraftStatus::Draft => 1,
});
transcript.tag(match metadata.tips {
PostTipPolicy::InheritPublication => 0,
PostTipPolicy::Enabled => 1,
PostTipPolicy::Disabled => 2,
});
transcript.bytes(document.markdown.as_str().as_bytes());
}
fn encode_baseline_renderer_policy(transcript: &mut Transcript) {
transcript.tag(COMMONMARK_DIALECT_TAG);
transcript.tag(RAW_HTML_DISABLED_TAG);
transcript.tag(CODE_LANGUAGE_CLASS_POLICY_TAG);
transcript.tag(MERMAID_SANITIZED_SVG_TAG);
}
fn encode_post_renderer(transcript: &mut Transcript, renderer: &PostRendererIdentity) {
transcript.tag(renderer.commonmark_dialect);
transcript.tag(renderer.raw_html_disabled);
transcript.tag(renderer.code_language_classes);
transcript.tag(renderer.mermaid_sanitized_svg);
transcript.tag(renderer.renderer_version);
transcript.tag(renderer.sanitizer_version);
}
fn encode_site_renderer(transcript: &mut Transcript, renderer: &SiteShellRendererIdentity) {
transcript.tag(SITE_SHELL_RENDERER_VERSION_TAG);
transcript.tag(PUBLIC_ASSET_DELIVERY_POLICY_TAG);
transcript.fixed_bytes(&renderer.frontend_bundle);
}
fn encode_publication(transcript: &mut Transcript, publication: &PublicationSettings) {
let site = &publication.site;
transcript.string(site.title.as_str());
transcript.string(site.base_url.as_str());
transcript.string(site.description.as_str());
transcript.string(publication.author.name.as_str());
transcript.tag(match publication.tips {
DefaultPostTipPolicy::Disabled => 0,
DefaultPostTipPolicy::Enabled => 1,
});
encode_baseline_renderer_policy(transcript);
}
fn sorted_asset_references(
references: &[AssetRevisionReference],
) -> Result<Vec<&AssetRevisionReference>, RevisionIdentityError> {
let mut references: Vec<_> = references.iter().collect();
references.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
for pair in references.windows(2) {
if pair[0].sort_key() == pair[1].sort_key() {
let (kind, value) = pair[0].sort_key();
return Err(RevisionIdentityError::DuplicateAssetReference {
value: format!("{kind}:{value}"),
});
}
}
Ok(references)
}
fn encode_asset_references<'assets>(
transcript: &mut Transcript,
references: impl ExactSizeIterator<Item = &'assets AssetRevisionReference>,
) {
transcript.sequence_len(references.len());
for reference in references {
encode_asset_reference(transcript, reference);
}
}
fn encode_asset_reference(transcript: &mut Transcript, reference: &AssetRevisionReference) {
match reference {
AssetRevisionReference::Local(asset) => {
transcript.tag(0);
transcript.string(asset.path.as_str());
transcript.fixed_bytes(asset.digest.as_bytes());
}
AssetRevisionReference::External(url) => {
transcript.tag(1);
transcript.string(url.as_str());
}
}
}
fn sorted_allowed_origins(
origins: &[ExternalAssetOrigin],
) -> Result<Vec<&ExternalAssetOrigin>, RevisionIdentityError> {
let mut origins: Vec<_> = origins.iter().collect();
origins.sort_by(|left, right| left.as_str().cmp(right.as_str()));
for pair in origins.windows(2) {
if pair[0] == pair[1] {
return Err(RevisionIdentityError::DuplicateAllowedOrigin {
origin: pair[0].as_str().to_owned(),
});
}
}
Ok(origins)
}
fn sorted_generated_assets(
assets: &[DigestedAsset],
) -> Result<Vec<&DigestedAsset>, RevisionIdentityError> {
let mut assets: Vec<_> = assets.iter().collect();
assets.sort_by(|left, right| left.path.as_str().cmp(right.path.as_str()));
for pair in assets.windows(2) {
if pair[0].path == pair[1].path {
return Err(RevisionIdentityError::DuplicateGeneratedAsset {
path: pair[0].path.as_str().to_owned(),
});
}
}
Ok(assets)
}
fn encode_generated_assets(transcript: &mut Transcript, assets: &[&DigestedAsset]) {
transcript.sequence_len(assets.len());
for asset in assets {
transcript.string(asset.path.as_str());
transcript.fixed_bytes(asset.digest.as_bytes());
}
}
fn sorted_public_posts<'slice, 'input>(
posts: &'slice [PublishedPostIdentityInput<'input>],
) -> Result<Vec<&'slice PublishedPostIdentityInput<'input>>, RevisionIdentityError> {
let mut posts: Vec<_> = posts.iter().collect();
posts.sort_by(|left, right| left.post_id.cmp(right.post_id));
for pair in posts.windows(2) {
if pair[0].post_id == pair[1].post_id {
return Err(RevisionIdentityError::DuplicatePublicPost {
post_id: pair[0].post_id.clone(),
});
}
}
Ok(posts)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ExternalAssetUrl, LogicalAssetPath, PostSource, PublicationSource, validate_content,
};
#[derive(Clone)]
struct PublishedPostRevision {
post_id: PostId,
revision: PostRevisionDigest,
published_at: OffsetDateTime,
}
impl PublishedPostRevision {
fn new(
post_id: PostId,
revision: PostRevisionDigest,
published_at: OffsetDateTime,
) -> Self {
Self {
post_id,
revision,
published_at,
}
}
}
#[derive(Debug, Eq, PartialEq)]
struct SourceCommit(String);
impl SourceCommit {
fn parse(value: &str) -> Result<Self, ()> {
Ok(Self(value.to_owned()))
}
}
fn published_identity_inputs(
posts: &[PublishedPostRevision],
) -> Vec<PublishedPostIdentityInput<'_>> {
posts
.iter()
.map(|post| {
PublishedPostIdentityInput::new(&post.post_id, &post.revision, post.published_at)
})
.collect()
}
const PUBLICATION: &str = r#"
[site]
title = "Example"
base_url = "https://example.com/"
description = "An example publication."
[author]
name = "Example Author"
"#;
fn validate_post(frontmatter: &str, markdown: &str) -> (PublicationSettings, PostDocument) {
let source = format!("+++\n{frontmatter}+++\n{markdown}");
let content = validate_content(
PublicationSource::new("publication.toml", PUBLICATION),
[PostSource::in_posts("posts/example.md", &source)],
)
.expect("fixture content must validate");
(content.publication.clone(), content.posts[0].clone())
}
fn validate_publication(source: &str) -> PublicationSettings {
validate_content(
PublicationSource::new("publication.toml", source),
std::iter::empty::<PostSource<'_>>(),
)
.expect("fixture publication must validate")
.publication
.clone()
}
fn frontmatter(authored_at: &str) -> String {
format!(
"id = \"11111111-1111-4111-8111-111111111111\"\n\
title = \"Example Post\"\n\
slug = \"example-post\"\n\
authored_at = {authored_at}\n\
description = \"An example post.\"\n\
tags = [\"Rust\", \"sqlite\"]\n"
)
}
fn renderer() -> PostRendererIdentity {
PostRendererIdentity::baseline()
}
fn changed_renderer_policies(
baseline: &PostRendererIdentity,
) -> [(&'static str, PostRendererIdentity); 6] {
[
(
"CommonMark dialect",
PostRendererIdentity {
commonmark_dialect: baseline.commonmark_dialect ^ 1,
..baseline.clone()
},
),
(
"raw HTML policy",
PostRendererIdentity {
raw_html_disabled: baseline.raw_html_disabled ^ 1,
..baseline.clone()
},
),
(
"code-language class policy",
PostRendererIdentity {
code_language_classes: baseline.code_language_classes ^ 1,
..baseline.clone()
},
),
(
"Mermaid policy",
PostRendererIdentity {
mermaid_sanitized_svg: baseline.mermaid_sanitized_svg ^ 1,
..baseline.clone()
},
),
(
"post-renderer version",
PostRendererIdentity {
renderer_version: baseline.renderer_version ^ 1,
..baseline.clone()
},
),
(
"SVG-sanitizer version",
PostRendererIdentity {
sanitizer_version: baseline.sanitizer_version ^ 1,
..baseline.clone()
},
),
]
}
const fn frontend_bundle(byte: u8) -> [u8; 32] {
[byte; 32]
}
fn shell_output(bytes: &[u8]) -> SiteShellOutputDigest {
let mut output = SiteShellOutputHasher::new(1);
output.page("/", bytes);
output.finish()
}
#[test]
fn identity_enum_wire_names_are_stable() {
for (value, expected) in [
(
serde_json::to_value(AssetBindingTarget::Post).unwrap(),
"post",
),
(
serde_json::to_value(AssetBindingTarget::Publication).unwrap(),
"publication",
),
] {
assert_eq!(value, serde_json::json!(expected));
}
}
#[test]
fn typed_post_content_is_canonical_but_preserves_markdown_and_authored_offset() {
let first_frontmatter = frontmatter("2026-08-29T12:00:00-04:00");
let reordered = first_frontmatter
.replace(
"title = \"Example Post\"\nslug = \"example-post\"",
"slug = \"example-post\"\n# equivalent comment\ntitle = 'Example Post'",
)
.replace(
"tags = [\"Rust\", \"sqlite\"]",
"tags = [\"rust\", \"sqlite\"]",
)
+ "draft = false\n";
let (_, first) = validate_post(&first_frontmatter, "# Body\n");
let (_, equivalent) = validate_post(&reordered, "# Body\n");
let (_, offset_changed) = validate_post(&frontmatter("2026-08-29T16:00:00Z"), "# Body\n");
let (_, markdown_changed) = validate_post(&first_frontmatter, "# Body\r\n");
assert_eq!(
digest_post_content(&first),
digest_post_content(&equivalent)
);
assert_ne!(
digest_post_content(&first),
digest_post_content(&offset_changed)
);
assert_ne!(
digest_post_content(&first),
digest_post_content(&markdown_changed)
);
}
#[test]
fn preview_digest_binds_article_shell_renderer_and_canonical_url() {
let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let site_assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
let post_id = post.metadata.id.clone();
let post_revision = PostRevisionDigest::from_bytes([0x11; 32]);
let post_renderer = renderer();
let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x22));
let digest = |revision: &PostRevisionDigest,
article: &[u8],
renderer: &SiteShellRendererIdentity,
shell: &[u8],
profile: &[u8],
canonical_url: &str| {
finalize_preview_digest(PreviewDigestInput {
publication: &publication,
site_assets: &site_assets,
post_id: &post_id,
post_revision: revision,
post_renderer: &post_renderer,
article_identity_html: article,
site_renderer: renderer,
pre_injection_post_shell: shell,
response_policy: b"fixture-policy",
profile_projection: profile,
canonical_url,
})
.unwrap()
};
let baseline = digest(
&post_revision,
b"<p>Rendered article</p>",
&site_renderer,
b"<html>Production shell</html>",
b"",
"https://example.com/posts/example-post",
);
assert_eq!(
baseline,
digest(
&post_revision,
b"<p>Rendered article</p>",
&site_renderer,
b"<html>Production shell</html>",
b"",
"https://example.com/posts/example-post",
)
);
assert_ne!(
baseline,
digest(
&PostRevisionDigest::from_bytes([0x33; 32]),
b"<p>Rendered article</p>",
&site_renderer,
b"<html>Production shell</html>",
b"",
"https://example.com/posts/example-post",
)
);
assert_ne!(
baseline,
digest(
&post_revision,
b"<p>Changed article</p>",
&site_renderer,
b"<html>Production shell</html>",
b"",
"https://example.com/posts/example-post",
)
);
assert_ne!(
baseline,
digest(
&post_revision,
b"<p>Rendered article</p>",
&SiteShellRendererIdentity::new(frontend_bundle(0x44)),
b"<html>Production shell</html>",
b"",
"https://example.com/posts/example-post",
)
);
assert_ne!(
baseline,
digest(
&post_revision,
b"<p>Rendered article</p>",
&site_renderer,
b"<html>Changed shell</html>",
b"",
"https://example.com/posts/example-post",
)
);
assert_ne!(
baseline,
digest(
&post_revision,
b"<p>Rendered article</p>",
&site_renderer,
b"<html>Production shell</html>",
b"alice@example.com\0LNURL1EXAMPLE",
"https://example.com/posts/example-post",
)
);
assert_ne!(
baseline,
digest(
&post_revision,
b"<p>Rendered article</p>",
&site_renderer,
b"<html>Production shell</html>",
b"",
"https://changed.example/posts/example-post",
)
);
}
#[test]
fn every_post_renderer_policy_field_is_identity_bearing() {
let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let post_assets = ResolvedPostAssets::new(&post, None, Vec::new());
let site_assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
let baseline_renderer = renderer();
let revision = |renderer: &PostRendererIdentity| {
finalize_post_revision(
&post,
&post_assets,
&site_assets,
renderer,
b"<h1>Body</h1>",
&[],
)
.unwrap()
};
let baseline_revision = revision(&baseline_renderer);
let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x22));
let preview = |renderer: &PostRendererIdentity| {
finalize_preview_digest(PreviewDigestInput {
publication: &publication,
site_assets: &site_assets,
post_id: &post.metadata.id,
post_revision: &baseline_revision,
post_renderer: renderer,
article_identity_html: b"<h1>Body</h1>",
site_renderer: &site_renderer,
pre_injection_post_shell: b"<html>Production shell</html>",
response_policy: b"fixture-policy",
profile_projection: b"",
canonical_url: "https://example.com/posts/example-post",
})
.unwrap()
};
let baseline_preview = preview(&baseline_renderer);
let published_at = OffsetDateTime::from_unix_timestamp(1_777_734_400).unwrap();
let snapshot = |revision: &PostRevisionDigest| {
let published = [PublishedPostIdentityInput::new(
&post.metadata.id,
revision,
published_at,
)];
finalize_site_snapshot(
&publication,
&site_assets,
&site_renderer,
&shell_output(b"shell"),
&published,
)
.unwrap()
};
let baseline_snapshot = snapshot(&baseline_revision);
for (field, changed_renderer) in changed_renderer_policies(&baseline_renderer) {
let changed_revision = revision(&changed_renderer);
assert_ne!(
baseline_revision, changed_revision,
"{field} was omitted from post revision identity"
);
assert_ne!(
baseline_preview,
preview(&changed_renderer),
"{field} was omitted from preview identity"
);
assert_ne!(
baseline_snapshot,
snapshot(&changed_revision),
"{field} did not reach site identity through the post revision"
);
}
}
#[test]
fn every_canonical_post_field_and_authored_order_is_identity_bearing() {
let baseline_frontmatter = frontmatter("2026-08-29T12:00:00Z");
let (_, baseline_post) = validate_post(&baseline_frontmatter, "# Body\n");
let baseline = digest_post_content(&baseline_post);
let variants = [
(
"id",
baseline_frontmatter.replace(
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222",
),
),
(
"title",
baseline_frontmatter.replace("Example Post", "Changed Post"),
),
(
"slug",
baseline_frontmatter.replace("example-post", "changed-post"),
),
(
"updated_at",
format!("{baseline_frontmatter}updated_at = 2026-08-30T12:00:00Z\n"),
),
(
"description",
baseline_frontmatter.replace("An example post.", "A changed post."),
),
(
"tags",
baseline_frontmatter.replace(
"tags = [\"Rust\", \"sqlite\"]",
"tags = [\"Rust\", \"wal\"]",
),
),
(
"aliases",
format!("{baseline_frontmatter}aliases = [\"old-example\"]\n"),
),
("draft", format!("{baseline_frontmatter}draft = true\n")),
("tips", format!("{baseline_frontmatter}tips = false\n")),
];
for (field, frontmatter) in variants {
let (_, changed) = validate_post(&frontmatter, "# Body\n");
assert_ne!(
baseline,
digest_post_content(&changed),
"{field} was omitted from canonical post identity"
);
}
let ordered = format!("{baseline_frontmatter}aliases = [\"first\", \"second\"]\n");
let reversed_aliases = format!("{baseline_frontmatter}aliases = [\"second\", \"first\"]\n");
let reversed_tags = baseline_frontmatter.replace(
"tags = [\"Rust\", \"sqlite\"]",
"tags = [\"sqlite\", \"Rust\"]",
);
let (_, ordered) = validate_post(&ordered, "# Body\n");
let (_, reversed_aliases) = validate_post(&reversed_aliases, "# Body\n");
let (_, reversed_tags) = validate_post(&reversed_tags, "# Body\n");
assert_ne!(
digest_post_content(&ordered),
digest_post_content(&reversed_aliases)
);
assert_ne!(baseline, digest_post_content(&reversed_tags));
}
#[test]
fn post_revision_requires_and_hashes_every_component() {
let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let local = DigestedAsset::new(
LogicalAssetPath::parse("assets/cover.webp").unwrap(),
digest_asset(b"cover"),
);
let refs = [AssetRevisionReference::local(local.clone())];
let generated = [DigestedAsset::new(
LogicalAssetPath::parse("assets/generated/diagram.png").unwrap(),
digest_asset(b"diagram"),
)];
let renderer = renderer();
let baseline = digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, refs.to_vec()),
&renderer,
PreInjectionRenderedArticle::new(b"<h1>Body</h1>"),
&generated,
))
.unwrap();
let without_generated = digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, refs.to_vec()),
&renderer,
PreInjectionRenderedArticle::new(b"<h1>Body</h1>"),
&[],
))
.unwrap();
assert_eq!(
without_generated.as_str(),
"post-b3-v1-5c38447a9dbbe61da77fe4ffadec4431f5a915d3e226f55e053df8af37c97607"
);
let changed = digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, refs.to_vec()),
&renderer,
PreInjectionRenderedArticle::new(b"<h1>Changed</h1>"),
&generated,
))
.unwrap();
assert_ne!(baseline, changed);
assert_eq!(
baseline.as_str(),
"post-b3-v1-19825bff7919a4338aa4cef37de8e2a1f6a0e42aaf786f3a8094cc42e4843178"
);
let duplicate_refs = [
AssetRevisionReference::local(local.clone()),
AssetRevisionReference::local(local),
];
assert!(matches!(
digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, duplicate_refs.to_vec()),
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
)),
Err(RevisionIdentityError::DuplicateAssetReference { .. })
));
drop(publication);
}
#[test]
fn asset_and_collection_order_cannot_change_revision_identity() {
let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let first = AssetRevisionReference::local(DigestedAsset::new(
LogicalAssetPath::parse("assets/a.bin").unwrap(),
digest_asset(b"a"),
));
let second = AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/b.bin").unwrap(),
);
let renderer = renderer();
let forward = [first.clone(), second.clone()];
let reverse = [second, first];
let digest = |assets: &[AssetRevisionReference]| {
digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, assets.to_vec()),
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
.unwrap()
};
assert_eq!(digest(&forward), digest(&reverse));
}
#[test]
fn generated_assets_are_complete_revision_components() {
let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let renderer = renderer();
let first = DigestedAsset::new(
LogicalAssetPath::parse("assets/generated/a.bin").unwrap(),
digest_asset(b"first"),
);
let second = DigestedAsset::new(
LogicalAssetPath::parse("assets/generated/b.bin").unwrap(),
digest_asset(b"second"),
);
let digest = |generated: &[DigestedAsset]| {
digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, Vec::new()),
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
generated,
))
};
let baseline = digest(&[first.clone(), second.clone()]).unwrap();
assert_eq!(baseline, digest(&[second.clone(), first.clone()]).unwrap());
assert_ne!(
baseline,
digest(&[
DigestedAsset::new(first.path.clone(), digest_asset(b"changed")),
second.clone()
])
.unwrap()
);
assert_ne!(
baseline,
digest(&[
DigestedAsset::new(
LogicalAssetPath::parse("assets/generated/renamed.bin").unwrap(),
first.digest.clone(),
),
second.clone(),
])
.unwrap()
);
assert!(matches!(
digest(&[first.clone(), first.clone()]),
Err(RevisionIdentityError::DuplicateGeneratedAsset { .. })
));
let digest_with_unreferenced_catalog_asset =
|_asset: &AssetDigest| digest(&[first.clone(), second.clone()]).unwrap();
assert_eq!(
digest_with_unreferenced_catalog_asset(&digest_asset(b"not referenced")),
digest_with_unreferenced_catalog_asset(&digest_asset(b"other bytes"))
);
}
#[test]
fn local_asset_path_bytes_and_external_url_are_separate_revision_inputs() {
let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let renderer = renderer();
let digest = |assets: &[AssetRevisionReference]| {
digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, assets.to_vec()),
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
.unwrap()
};
let local = |path: &str, bytes: &[u8]| {
AssetRevisionReference::local(DigestedAsset::new(
LogicalAssetPath::parse(path).unwrap(),
digest_asset(bytes),
))
};
let baseline = [local("assets/a.bin", b"same")];
let changed_path = [local("assets/b.bin", b"same")];
let changed_bytes = [local("assets/a.bin", b"changed")];
let external_v1 = [AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/a.bin?v=1").unwrap(),
)];
let external_v2 = [AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/a.bin?v=2").unwrap(),
)];
assert_ne!(digest(&baseline), digest(&changed_path));
assert_ne!(digest(&baseline), digest(&changed_bytes));
assert_ne!(digest(&external_v1), digest(&external_v2));
}
#[test]
fn resolved_image_normalization_is_stable_and_image_identity_is_required() {
let first_frontmatter = format!(
"{}image = \"HTTPS://CDN.EXAMPLE:443/image.png\"\n",
frontmatter("2026-08-29T12:00:00Z")
);
let second_frontmatter = format!(
"{}image = \"https://cdn.example/image.png\"\n",
frontmatter("2026-08-29T12:00:00Z")
);
let (_, first) = validate_post(&first_frontmatter, "# Body\n");
let (_, second) = validate_post(&second_frontmatter, "# Body\n");
let image = AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/image.png").unwrap(),
);
let first_resolved = ResolvedPostAssets::new(&first, Some(image.clone()), Vec::new());
let second_resolved = ResolvedPostAssets::new(&second, Some(image), Vec::new());
let unresolved = ResolvedPostAssets::new(&first, None, Vec::new());
let renderer = renderer();
let digest = |post: &PostDocument, assets: &ResolvedPostAssets| {
digest_post_revision(&PostRevisionInput::new_unchecked(
post,
assets,
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
.unwrap()
};
assert_eq!(
digest(&first, &first_resolved),
digest(&second, &second_resolved)
);
assert_ne!(digest(&first, &first_resolved), digest(&first, &unresolved));
}
#[test]
fn length_frames_prevent_adjacent_field_ambiguity() {
let hash = |parts: &[&[u8]]| {
let mut transcript = Transcript::new(
"maincopy transcript boundary test v1",
b"maincopy-boundary-test",
1,
);
for part in parts {
transcript.bytes(part);
}
transcript.finish()
};
assert_ne!(
hash(&[b"ab".as_slice(), b"c".as_slice()]),
hash(&[b"a".as_slice(), b"bc".as_slice()])
);
}
#[test]
fn site_identity_sorts_ledger_entries_and_normalizes_operational_time() {
let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let renderer = renderer();
let revision = digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, Vec::new()),
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
.unwrap();
let first = PublishedPostRevision::new(
PostId::parse("11111111-1111-4111-8111-111111111111").unwrap(),
revision.clone(),
OffsetDateTime::from_unix_timestamp(1_777_734_400)
.unwrap()
.to_offset(time::UtcOffset::from_hms(-4, 0, 0).unwrap()),
);
let second = PublishedPostRevision::new(
PostId::parse("22222222-2222-4222-8222-222222222222").unwrap(),
revision,
OffsetDateTime::from_unix_timestamp(1_777_734_400).unwrap(),
);
let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
let forward = [first.clone(), second.clone()];
let reverse = [second, first.clone()];
let digest = |posts: &[PublishedPostRevision]| {
let posts = published_identity_inputs(posts);
digest_site_snapshot(&SiteSnapshotInput::new(
&publication,
&ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new()),
&site_renderer,
&shell_output(b"shell"),
&posts,
))
.unwrap()
};
assert_eq!(digest(&forward), digest(&reverse));
assert_eq!(
digest(&forward).as_str(),
"site-b3-v1-bc150d340d0136acf65981bd06e2284c0c88c917261b6f293b1100cd0f0ddf09"
);
let duplicate = [first.clone(), first];
let duplicate = published_identity_inputs(&duplicate);
assert!(matches!(
digest_site_snapshot(&SiteSnapshotInput::new(
&publication,
&ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new()),
&site_renderer,
&shell_output(b"shell"),
&duplicate,
)),
Err(RevisionIdentityError::DuplicatePublicPost { .. })
));
}
#[test]
fn renderer_output_public_revision_and_activation_each_change_site_identity() {
let (publication, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let post_renderer = renderer();
let revision = digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, Vec::new()),
&post_renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
.unwrap();
let post_id = PostId::parse("11111111-1111-4111-8111-111111111111").unwrap();
let published_at = OffsetDateTime::from_unix_timestamp(1_777_734_400).unwrap();
let baseline_post =
PublishedPostRevision::new(post_id.clone(), revision.clone(), published_at);
let changed_revision = PublishedPostRevision::new(
post_id.clone(),
PostRevisionDigest::parse(&format!("post-b3-v1-{}", "22".repeat(32))).unwrap(),
published_at,
);
let changed_activation =
PublishedPostRevision::new(post_id, revision, published_at + time::Duration::SECOND);
let baseline_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
let changed_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x22));
let digest = |renderer: &SiteShellRendererIdentity,
shell: &'static [u8],
posts: &[PublishedPostRevision]| {
let posts = published_identity_inputs(posts);
digest_site_snapshot(&SiteSnapshotInput::new(
&publication,
&ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new()),
renderer,
&shell_output(shell),
&posts,
))
.unwrap()
};
let baseline = digest(
&baseline_renderer,
b"shell-a",
std::slice::from_ref(&baseline_post),
);
assert_ne!(
baseline,
digest(
&changed_renderer,
b"shell-a",
std::slice::from_ref(&baseline_post)
)
);
assert_ne!(
baseline,
digest(
&baseline_renderer,
b"shell-b",
std::slice::from_ref(&baseline_post)
)
);
assert_ne!(
baseline,
digest(
&baseline_renderer,
b"shell-a",
std::slice::from_ref(&changed_revision)
)
);
assert_ne!(
baseline,
digest(
&baseline_renderer,
b"shell-a",
std::slice::from_ref(&changed_activation)
)
);
}
#[test]
fn publication_tip_default_has_stable_canonical_digest() {
let digest = |source: &str| {
let publication = validate_publication(source);
blake3::Hash::from_bytes(digest_publication_content(&publication).0)
.to_hex()
.to_string()
};
let disabled = digest(PUBLICATION);
for source in [
format!("{PUBLICATION}\n[tips]\n"),
format!("{PUBLICATION}\n[tips]\nenabled = false\n"),
] {
assert_eq!(disabled, digest(&source));
}
let enabled = digest(&format!("{PUBLICATION}\n[tips]\nenabled = true\n"));
assert_eq!(
[disabled.as_str(), enabled.as_str()],
[
"5fc545f41d08ee9fdcd91297ea58a7c9323d3bc90c2ac29bf7d1feed71bd6196",
"fad5dcb2a50c69e1ff85de0de17a697d7578e3243648b875cac27ffcb169c497",
]
);
}
#[test]
fn publication_favicon_and_site_references_are_required_site_components() {
let renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
let baseline_publication = validate_publication(PUBLICATION);
let digest = |publication: &PublicationSettings, assets: &ResolvedSiteAssets| {
digest_site_snapshot(&SiteSnapshotInput::new(
publication,
assets,
&renderer,
&shell_output(b"shell"),
&[],
))
.unwrap()
};
let baseline_assets =
ResolvedSiteAssets::new(&baseline_publication, None, None, Vec::new(), Vec::new());
let baseline = digest(&baseline_publication, &baseline_assets);
let variants = [
PUBLICATION.replace("title = \"Example\"", "title = \"Changed\""),
PUBLICATION.replace(
"base_url = \"https://example.com/\"",
"base_url = \"https://changed.example/\"",
),
PUBLICATION.replace("An example publication.", "A changed publication."),
PUBLICATION.replace("Example Author", "Changed Author"),
];
for source in variants {
let publication = validate_publication(&source);
let assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
assert_ne!(baseline, digest(&publication, &assets));
}
let local = |path: &str, bytes: &[u8]| {
AssetRevisionReference::local(DigestedAsset::new(
LogicalAssetPath::parse(path).unwrap(),
digest_asset(bytes),
))
};
let favicon = local("assets/favicon.png", b"favicon");
let favicon_changed = local("assets/favicon.png", b"changed favicon");
let reference = local("assets/site/banner.png", b"banner");
let reference_changed_path = local("assets/site/renamed.png", b"banner");
let reference_changed_bytes = local("assets/site/banner.png", b"changed banner");
let favicon_identity = digest(
&baseline_publication,
&ResolvedSiteAssets::new(
&baseline_publication,
Some(favicon),
None,
Vec::new(),
Vec::new(),
),
);
let changed_favicon_identity = digest(
&baseline_publication,
&ResolvedSiteAssets::new(
&baseline_publication,
Some(favicon_changed),
None,
Vec::new(),
Vec::new(),
),
);
let reference_identity = digest(
&baseline_publication,
&ResolvedSiteAssets::new(
&baseline_publication,
None,
None,
Vec::new(),
vec![reference],
),
);
let changed_reference_path_identity = digest(
&baseline_publication,
&ResolvedSiteAssets::new(
&baseline_publication,
None,
None,
Vec::new(),
vec![reference_changed_path],
),
);
let changed_reference_bytes_identity = digest(
&baseline_publication,
&ResolvedSiteAssets::new(
&baseline_publication,
None,
None,
Vec::new(),
vec![reference_changed_bytes],
),
);
assert_ne!(baseline, favicon_identity);
assert_ne!(favicon_identity, changed_favicon_identity);
assert_ne!(baseline, reference_identity);
assert_ne!(reference_identity, changed_reference_path_identity);
assert_ne!(reference_identity, changed_reference_bytes_identity);
}
#[test]
fn resolved_asset_capabilities_are_bound_to_their_source_content() {
let baseline_frontmatter = frontmatter("2026-08-29T12:00:00Z");
let (_, baseline_post) = validate_post(&baseline_frontmatter, "# Body\n");
let changed_frontmatter = baseline_frontmatter.replace("Example Post", "Changed Post");
let (_, changed_post) = validate_post(&changed_frontmatter, "# Body\n");
let post_assets = ResolvedPostAssets::new(&baseline_post, None, Vec::new());
let post_renderer = renderer();
assert!(matches!(
digest_post_revision(&PostRevisionInput::new_unchecked(
&changed_post,
&post_assets,
&post_renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
)),
Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Post
})
));
let image_frontmatter =
format!("{baseline_frontmatter}image = \"https://cdn.example/a.png\"\n");
let changed_image_frontmatter =
image_frontmatter.replace("cdn.example/a.png", "cdn.example/b.png");
let (_, image_post) = validate_post(&image_frontmatter, "# Body\n");
let (_, changed_image_post) = validate_post(&changed_image_frontmatter, "# Body\n");
let image_assets = ResolvedPostAssets::new(
&image_post,
Some(AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/a.png").unwrap(),
)),
Vec::new(),
);
assert!(matches!(
digest_post_revision(&PostRevisionInput::new_unchecked(
&changed_image_post,
&image_assets,
&post_renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
)),
Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Post
})
));
let publication = validate_publication(PUBLICATION);
let changed_publication = validate_publication(
&PUBLICATION.replace("title = \"Example\"", "title = \"Changed\""),
);
let site_assets = ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
let site_renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
assert!(matches!(
digest_site_snapshot(&SiteSnapshotInput::new(
&changed_publication,
&site_assets,
&site_renderer,
&shell_output(b"shell"),
&[],
)),
Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Publication
})
));
let favicon_source = PUBLICATION.replace(
"description = \"An example publication.\"",
"description = \"An example publication.\"\nfavicon = \"https://cdn.example/a.png\"",
);
let changed_favicon_source =
favicon_source.replace("cdn.example/a.png", "cdn.example/b.png");
let favicon_publication = validate_publication(&favicon_source);
let changed_favicon_publication = validate_publication(&changed_favicon_source);
let favicon_assets = ResolvedSiteAssets::new(
&favicon_publication,
Some(AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/a.png").unwrap(),
)),
None,
Vec::new(),
Vec::new(),
);
assert!(matches!(
digest_site_snapshot(&SiteSnapshotInput::new(
&changed_favicon_publication,
&favicon_assets,
&site_renderer,
&shell_output(b"shell"),
&[],
)),
Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Publication
})
));
let origin_source = format!(
"{PUBLICATION}\n[assets]\nallowed_https_origins = [\"HTTPS://A.EXAMPLE:443\"]\n"
);
let equivalent_origin_source =
format!("{PUBLICATION}\n[assets]\nallowed_https_origins = [\"https://a.example/\"]\n");
let origin_publication = validate_publication(&origin_source);
let equivalent_origin_publication = validate_publication(&equivalent_origin_source);
let origin_assets = ResolvedSiteAssets::new(
&origin_publication,
None,
None,
vec![ExternalAssetOrigin::parse("https://a.example/").unwrap()],
Vec::new(),
);
assert!(matches!(
digest_site_snapshot(&SiteSnapshotInput::new(
&equivalent_origin_publication,
&origin_assets,
&site_renderer,
&shell_output(b"shell"),
&[],
)),
Err(RevisionIdentityError::ResolvedAssetBindingMismatch {
target: AssetBindingTarget::Publication
})
));
}
#[test]
fn post_revision_rejects_assets_approved_before_allowlist_revocation() {
let image_frontmatter = format!(
"{}image = \"https://cdn.example/cover-v1.png\"\n",
frontmatter("2026-08-29T12:00:00Z")
);
let (publication, post) = validate_post(&image_frontmatter, "# Body\n");
let allowed = vec![ExternalAssetOrigin::parse("https://cdn.example/").unwrap()];
let expanded = vec![
ExternalAssetOrigin::parse("https://cdn.example/").unwrap(),
ExternalAssetOrigin::parse("https://media.example/").unwrap(),
];
let image = AssetRevisionReference::external(
ExternalAssetUrl::parse("https://cdn.example/cover-v1.png").unwrap(),
);
let approved_assets = ResolvedPostAssets::from_resolution(
&post,
&digest_post_content(&post),
&allowed,
Some(image.clone()),
Vec::new(),
Vec::new(),
);
let expanded_assets = ResolvedPostAssets::from_resolution(
&post,
&digest_post_content(&post),
&expanded,
Some(image),
Vec::new(),
Vec::new(),
);
let approved_site = ResolvedSiteAssets::new(&publication, None, None, allowed, Vec::new());
let expanded_site = ResolvedSiteAssets::new(&publication, None, None, expanded, Vec::new());
let revoked_site =
ResolvedSiteAssets::new(&publication, None, None, Vec::new(), Vec::new());
let renderer = renderer();
let digest = |assets: &ResolvedPostAssets, site_assets: &ResolvedSiteAssets| {
digest_post_revision(&PostRevisionInput::new(
&post,
assets,
site_assets,
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
};
let approved = digest(&approved_assets, &approved_site).unwrap();
assert_eq!(approved, digest(&expanded_assets, &expanded_site).unwrap());
assert_eq!(
digest(&approved_assets, &revoked_site),
Err(RevisionIdentityError::ResolvedAssetPolicyMismatch)
);
assert_eq!(
digest(&approved_assets, &expanded_site),
Err(RevisionIdentityError::ResolvedAssetPolicyMismatch)
);
}
#[test]
fn effective_normalized_allowlist_is_order_independent_and_identity_bearing() {
let authored_first = format!(
"{PUBLICATION}\n[assets]\nallowed_https_origins = [\"HTTPS://A.EXAMPLE:443\"]\n"
);
let authored_equivalent =
format!("{PUBLICATION}\n[assets]\nallowed_https_origins = [\"https://a.example/\"]\n");
let publication = validate_publication(&authored_first);
let equivalent_publication = validate_publication(&authored_equivalent);
let renderer = SiteShellRendererIdentity::new(frontend_bundle(0x11));
let first = ExternalAssetOrigin::parse("https://a.example").unwrap();
let second = ExternalAssetOrigin::parse("HTTPS://B.EXAMPLE:443/").unwrap();
let changed = ExternalAssetOrigin::parse("https://c.example/").unwrap();
let digest = |publication: &PublicationSettings, origins: Vec<ExternalAssetOrigin>| {
digest_site_snapshot(&SiteSnapshotInput::new(
publication,
&ResolvedSiteAssets::new(publication, None, None, origins, Vec::new()),
&renderer,
&shell_output(b"shell"),
&[],
))
.unwrap()
};
assert_eq!(
digest(&publication, vec![first.clone(), second.clone()]),
digest(&publication, vec![second.clone(), first])
);
assert_eq!(
digest(
&publication,
vec![ExternalAssetOrigin::parse("https://a.example").unwrap()]
),
digest(
&equivalent_publication,
vec![ExternalAssetOrigin::parse("https://a.example/").unwrap()]
)
);
assert_ne!(
digest(&publication, vec![second]),
digest(&publication, vec![changed])
);
let duplicate = ExternalAssetOrigin::parse("https://duplicate.example").unwrap();
assert!(matches!(
digest_site_snapshot(&SiteSnapshotInput::new(
&publication,
&ResolvedSiteAssets::new(
&publication,
None,
None,
vec![duplicate.clone(), duplicate],
Vec::new(),
),
&renderer,
&shell_output(b"shell"),
&[],
)),
Err(RevisionIdentityError::DuplicateAllowedOrigin { .. })
));
}
#[test]
fn advisory_git_provenance_is_not_a_revision_input() {
let (_, post) = validate_post(&frontmatter("2026-08-29T12:00:00Z"), "# Body\n");
let renderer = renderer();
let digest = |_provenance: Option<&SourceCommit>| {
digest_post_revision(&PostRevisionInput::new_unchecked(
&post,
&ResolvedPostAssets::new(&post, None, Vec::new()),
&renderer,
PreInjectionRenderedArticle::new(b"rendered"),
&[],
))
.unwrap()
};
let first_commit = SourceCommit::parse(&format!("git-sha1:{}", "11".repeat(20))).unwrap();
let second_commit =
SourceCommit::parse(&format!("git-sha256:{}", "22".repeat(32))).unwrap();
assert_ne!(first_commit, second_commit);
assert_eq!(digest(None), digest(Some(&first_commit)));
assert_eq!(digest(Some(&first_commit)), digest(Some(&second_commit)));
}
#[test]
fn asset_digest_has_a_stable_domain_separated_golden_value() {
let asset = digest_asset(b"maincopy");
assert_eq!(
asset.as_str(),
"asset-b3-v1-20b8cb3fe0a1f1eae595a39939aef0e08660b7117e462d4d0b4f9510075681ae"
);
assert_ne!(asset.as_bytes(), &frontend_bundle(0x33));
}
}