Skip to main content

markdown_compiler/
resolver.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    num::NonZeroUsize,
4    sync::Arc,
5};
6
7use pulldown_cmark::{Event, Options, Parser, Tag};
8use serde::Serialize;
9use thiserror::Error;
10
11use super::{
12    AssetRevisionReference, AuthoredMarkdownDestination, ContentValidationErrors, DigestedAsset,
13    DiscoveredContentTree, ExternalAssetOrigin, ExternalAssetUrl, LogicalAssetPath,
14    LogicalContentPath, MarkdownDestinationKind, MarkdownDestinationOrdinal, MarkdownSourceRange,
15    PostDocument, PostRendererIdentity, PostRevisionDigest, PublicationSettings,
16    ResolvedMarkdownDestination, ResolvedPostAssets, ResolvedSiteAssets, ValidatedContent,
17    digest_asset,
18    identity::{PostContentDigest, digest_post_content, digest_prepared_post_revision},
19};
20
21/// Parse and resolve one owned content tree without network I/O.
22/// The result keeps each validated document paired with its approved assets.
23pub fn prepare_content(
24    tree: &DiscoveredContentTree,
25) -> Result<PreparedContent, PrepareContentError> {
26    let content = tree
27        .validate()
28        .map_err(PrepareContentError::InvalidContent)?;
29    Resolver::new(tree, content).resolve()
30}
31
32#[derive(Debug, Error)]
33pub enum PrepareContentError {
34    #[error("the discovered content tree is not valid")]
35    InvalidContent(#[source] ContentValidationErrors),
36    #[error(transparent)]
37    AssetResolution(#[from] AssetResolutionErrors),
38}
39
40/// An immutable, compiler-owned candidate with source-bound asset inputs.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct PreparedContent {
43    publication: PublicationSettings,
44    site: ResolvedSiteAssets,
45    posts: Vec<PreparedPost>,
46    local_assets: ResolvedLocalAssetStore,
47    warnings: AssetResolutionWarnings,
48}
49
50/// Read-only compiler inputs for composition in a renderer.
51#[derive(Clone, Copy)]
52pub struct PreparedContentView<'content> {
53    pub publication: &'content PublicationSettings,
54    pub site_assets: &'content ResolvedSiteAssets,
55    pub posts: &'content [PreparedPost],
56    pub local_assets: &'content ResolvedLocalAssetStore,
57    pub warnings: &'content AssetResolutionWarnings,
58}
59
60impl PreparedContent {
61    pub fn view(&self) -> PreparedContentView<'_> {
62        PreparedContentView {
63            publication: &self.publication,
64            site_assets: &self.site,
65            posts: &self.posts,
66            local_assets: &self.local_assets,
67            warnings: &self.warnings,
68        }
69    }
70}
71
72/// One validated document, its approved assets, and its cached content identity.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct PreparedPost {
75    document: PostDocument,
76    assets: ResolvedPostAssets,
77    content_digest: PostContentDigest,
78}
79
80/// A borrowed post/asset pair that cannot mutate the prepared candidate.
81#[derive(Clone, Copy)]
82pub struct PreparedPostView<'post> {
83    pub document: &'post PostDocument,
84    pub assets: &'post ResolvedPostAssets,
85}
86
87impl PreparedPost {
88    pub fn view(&self) -> PreparedPostView<'_> {
89        PreparedPostView {
90            document: &self.document,
91            assets: &self.assets,
92        }
93    }
94
95    /// Bind rendered bytes to the already validated source and asset identities.
96    pub fn finalize_revision(
97        &self,
98        renderer: &PostRendererIdentity,
99        pre_injection_article: &[u8],
100    ) -> PostRevisionDigest {
101        digest_prepared_post_revision(
102            &self.content_digest,
103            &self.assets,
104            renderer,
105            pre_injection_article,
106        )
107    }
108}
109
110/// Exact discovered bytes paired with the resolver-calculated identity.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct ResolvedLocalAsset {
113    pub asset: DigestedAsset,
114    pub bytes: Arc<[u8]>,
115}
116
117/// Immutable local bytes keyed by their portable logical path.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct ResolvedLocalAssetStore {
120    assets: BTreeMap<LogicalAssetPath, ResolvedLocalAsset>,
121}
122
123impl ResolvedLocalAssetStore {
124    pub fn resolve(
125        &self,
126        reference: &DigestedAsset,
127    ) -> Result<&ResolvedLocalAsset, ResolvedLocalAssetLookupError> {
128        let Some(asset) = self.assets.get(&reference.path) else {
129            return Err(ResolvedLocalAssetLookupError::Missing);
130        };
131        if asset.asset.path != reference.path || asset.asset.digest != reference.digest {
132            return Err(ResolvedLocalAssetLookupError::DigestMismatch);
133        }
134        Ok(asset)
135    }
136}
137
138/// Iterates resolved logical paths without bypassing reference-checked byte access.
139impl<'assets> IntoIterator for &'assets ResolvedLocalAssetStore {
140    type Item = &'assets LogicalAssetPath;
141    type IntoIter =
142        std::collections::btree_map::Keys<'assets, LogicalAssetPath, ResolvedLocalAsset>;
143
144    fn into_iter(self) -> Self::IntoIter {
145        self.assets.keys()
146    }
147}
148
149#[derive(Clone, Copy, Debug, Eq, Error, PartialEq, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ResolvedLocalAssetLookupError {
152    #[error("the local asset is not present in this resolution")]
153    Missing,
154    #[error("the local asset identity does not match the resolved reference")]
155    DigestMismatch,
156}
157
158#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
159#[serde(transparent)]
160pub struct AllowedOriginOrdinal(NonZeroUsize);
161
162impl AllowedOriginOrdinal {
163    pub const fn get(self) -> usize {
164        self.0.get()
165    }
166
167    const fn new(value: NonZeroUsize) -> Self {
168        Self(value)
169    }
170}
171
172#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
173#[serde(tag = "kind", rename_all = "snake_case")]
174pub enum AssetReferenceLocation {
175    ContentSource,
176    AllowedOrigin {
177        ordinal: AllowedOriginOrdinal,
178    },
179    PublicationFavicon,
180    PublicationImage,
181    PostPreviewImage,
182    Markdown {
183        ordinal: MarkdownDestinationOrdinal,
184        destination_kind: MarkdownDestinationKind,
185    },
186    LocalAsset,
187}
188
189#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
190#[serde(rename_all = "snake_case")]
191pub enum AssetResolutionCode {
192    AllowedOriginInvalid,
193    AllowedOriginDuplicated,
194    AllowedOriginCountExceeded,
195    LocalAssetDuplicated,
196    LocalAssetPathInvalid,
197    LocalAssetMissing,
198    ExternalAssetUrlInvalid,
199    ExternalAssetOriginNotAllowed,
200    MarkdownDestinationCountExceeded,
201}
202
203#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize)]
204#[error("{path}: {location:?}: {message}")]
205pub struct AssetResolutionError {
206    pub path: LogicalContentPath,
207    pub location: AssetReferenceLocation,
208    pub code: AssetResolutionCode,
209    pub message: Box<str>,
210}
211
212impl AssetResolutionError {
213    fn new(
214        path: LogicalContentPath,
215        location: AssetReferenceLocation,
216        code: AssetResolutionCode,
217        message: impl Into<Box<str>>,
218    ) -> Self {
219        Self {
220            path,
221            location,
222            code,
223            message: message.into(),
224        }
225    }
226
227    fn sort_key(&self) -> (&str, AssetReferenceLocation, AssetResolutionCode, &str) {
228        (self.path.as_str(), self.location, self.code, &self.message)
229    }
230}
231
232#[derive(Clone, Debug, Eq, Error, PartialEq)]
233#[error("asset resolution failed with {} error(s)", .0.len())]
234pub struct AssetResolutionErrors(Vec<AssetResolutionError>);
235
236impl AssetResolutionErrors {
237    pub fn errors(&self) -> &[AssetResolutionError] {
238        &self.0
239    }
240
241    pub fn into_errors(self) -> Vec<AssetResolutionError> {
242        self.0
243    }
244}
245
246#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
247#[serde(rename_all = "snake_case")]
248pub enum AssetResolutionWarningCode {
249    ExternalUrlMayBeMutable,
250}
251
252#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
253pub struct AssetResolutionWarning {
254    pub path: LogicalContentPath,
255    pub location: AssetReferenceLocation,
256    pub code: AssetResolutionWarningCode,
257    pub url: ExternalAssetUrl,
258}
259
260impl AssetResolutionWarning {
261    fn sort_key(
262        &self,
263    ) -> (
264        &str,
265        AssetReferenceLocation,
266        AssetResolutionWarningCode,
267        &str,
268    ) {
269        (
270            self.path.as_str(),
271            self.location,
272            self.code,
273            self.url.as_str(),
274        )
275    }
276}
277
278#[derive(Clone, Debug, Eq, PartialEq)]
279pub struct AssetResolutionWarnings(Vec<AssetResolutionWarning>);
280
281impl AssetResolutionWarnings {
282    pub fn warnings(&self) -> &[AssetResolutionWarning] {
283        &self.0
284    }
285
286    pub fn is_empty(&self) -> bool {
287        self.0.is_empty()
288    }
289}
290
291struct Resolver<'input> {
292    tree: &'input DiscoveredContentTree,
293    content: ValidatedContent,
294    allowed_origins: Vec<ExternalAssetOrigin>,
295    discovered_assets: BTreeMap<LogicalAssetPath, Arc<[u8]>>,
296    resolved_local_assets: BTreeMap<LogicalAssetPath, ResolvedLocalAsset>,
297    errors: Vec<AssetResolutionError>,
298    warnings: Vec<AssetResolutionWarning>,
299}
300
301impl<'input> Resolver<'input> {
302    fn new(tree: &'input DiscoveredContentTree, content: ValidatedContent) -> Self {
303        Self {
304            tree,
305            content,
306            allowed_origins: Vec::new(),
307            discovered_assets: BTreeMap::new(),
308            resolved_local_assets: BTreeMap::new(),
309            errors: Vec::new(),
310            warnings: Vec::new(),
311        }
312    }
313
314    fn resolve(mut self) -> Result<PreparedContent, PrepareContentError> {
315        self.index_local_assets();
316        self.resolve_allowed_origins();
317
318        let publication_path = self.tree.publication.path.clone();
319        let favicon = self
320            .content
321            .publication
322            .site
323            .favicon
324            .clone()
325            .and_then(|favicon| {
326                self.resolve_reference(
327                    &publication_path,
328                    AssetReferenceLocation::PublicationFavicon,
329                    favicon.as_str(),
330                )
331            });
332
333        let image = self
334            .content
335            .publication
336            .site
337            .image
338            .clone()
339            .and_then(|image| {
340                self.resolve_reference(
341                    &publication_path,
342                    AssetReferenceLocation::PublicationImage,
343                    image.as_str(),
344                )
345            });
346
347        let site = ResolvedSiteAssets::new(
348            &self.content.publication,
349            favicon,
350            image,
351            self.allowed_origins.clone(),
352            Vec::new(),
353        );
354
355        let mut posts = Vec::with_capacity(self.content.posts.len());
356        for document in std::mem::take(&mut self.content.posts) {
357            posts.push(self.resolve_post(document));
358        }
359
360        if !self.errors.is_empty() {
361            self.errors
362                .sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
363            self.errors.dedup();
364            return Err(PrepareContentError::AssetResolution(AssetResolutionErrors(
365                self.errors,
366            )));
367        }
368
369        self.warnings
370            .sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
371        self.warnings.dedup();
372        Ok(PreparedContent {
373            publication: self.content.publication,
374            site,
375            posts,
376            local_assets: ResolvedLocalAssetStore {
377                assets: self.resolved_local_assets,
378            },
379            warnings: AssetResolutionWarnings(self.warnings),
380        })
381    }
382
383    fn index_local_assets(&mut self) {
384        for asset in &self.tree.assets {
385            let path = asset.path.clone();
386            if self.discovered_assets.contains_key(&path) {
387                self.errors.push(AssetResolutionError::new(
388                    LogicalContentPath::new(path.as_str()),
389                    AssetReferenceLocation::LocalAsset,
390                    AssetResolutionCode::LocalAssetDuplicated,
391                    format!("local asset path is duplicated: {path}"),
392                ));
393                continue;
394            }
395            self.discovered_assets
396                .insert(path, Arc::clone(&asset.bytes));
397        }
398    }
399
400    fn resolve_allowed_origins(&mut self) {
401        let path = self.tree.publication.path.clone();
402        let mut canonical_origins = BTreeSet::new();
403        for (index, authored) in self
404            .content
405            .publication
406            .assets
407            .allowed_https_origins
408            .iter()
409            .enumerate()
410        {
411            let Some(ordinal) = index
412                .checked_add(1)
413                .and_then(NonZeroUsize::new)
414                .map(AllowedOriginOrdinal::new)
415            else {
416                self.errors.push(AssetResolutionError::new(
417                    path.clone(),
418                    AssetReferenceLocation::ContentSource,
419                    AssetResolutionCode::AllowedOriginCountExceeded,
420                    "the allowlist contains more origins than this platform can address",
421                ));
422                break;
423            };
424            let location = AssetReferenceLocation::AllowedOrigin { ordinal };
425            let origin = match ExternalAssetOrigin::parse(authored.as_str()) {
426                Ok(origin) => origin,
427                Err(error) => {
428                    self.errors.push(AssetResolutionError::new(
429                        path.clone(),
430                        location,
431                        AssetResolutionCode::AllowedOriginInvalid,
432                        error.to_string(),
433                    ));
434                    continue;
435                }
436            };
437            if !canonical_origins.insert(origin.as_str().to_owned()) {
438                self.errors.push(AssetResolutionError::new(
439                    path.clone(),
440                    location,
441                    AssetResolutionCode::AllowedOriginDuplicated,
442                    format!("allowed HTTPS origin is duplicated after normalization: {origin}"),
443                ));
444                continue;
445            }
446            self.allowed_origins.push(origin);
447        }
448        self.allowed_origins
449            .sort_by(|left, right| left.as_str().cmp(right.as_str()));
450    }
451
452    fn resolve_post(&mut self, document: PostDocument) -> PreparedPost {
453        let path = document.path.clone();
454        let image = document.metadata.image.as_ref().and_then(|image| {
455            self.resolve_reference(
456                &path,
457                AssetReferenceLocation::PostPreviewImage,
458                image.as_str(),
459            )
460        });
461
462        let mut references = BTreeMap::new();
463        let mut markdown_destinations = Vec::new();
464        let mut destination_count = 0_usize;
465        for (event, range) in
466            Parser::new_ext(document.markdown.as_str(), Options::empty()).into_offset_iter()
467        {
468            let (kind, authored, is_asset) = match event {
469                Event::Start(Tag::Image { dest_url, .. }) => {
470                    (MarkdownDestinationKind::Image, dest_url, true)
471                }
472                Event::Start(Tag::Link { dest_url, .. }) => {
473                    let is_asset = self.is_markdown_file_reference(dest_url.as_ref());
474                    (MarkdownDestinationKind::Download, dest_url, is_asset)
475                }
476                _ => continue,
477            };
478
479            let Some(next_count) = destination_count.checked_add(1) else {
480                self.errors.push(AssetResolutionError::new(
481                    path.clone(),
482                    AssetReferenceLocation::ContentSource,
483                    AssetResolutionCode::MarkdownDestinationCountExceeded,
484                    "Markdown contains more destinations than this platform can address",
485                ));
486                break;
487            };
488            destination_count = next_count;
489            let Some(non_zero_ordinal) = NonZeroUsize::new(destination_count) else {
490                continue;
491            };
492            let ordinal = MarkdownDestinationOrdinal::new(non_zero_ordinal);
493            if !is_asset {
494                continue;
495            }
496
497            let location = AssetReferenceLocation::Markdown {
498                ordinal,
499                destination_kind: kind,
500            };
501            let Some(target) = self.resolve_reference(&path, location, authored.as_ref()) else {
502                continue;
503            };
504            references
505                .entry(reference_key(&target))
506                .or_insert_with(|| target.clone());
507            markdown_destinations.push(ResolvedMarkdownDestination::new(
508                ordinal,
509                MarkdownSourceRange::new(range),
510                kind,
511                AuthoredMarkdownDestination::new(authored.as_ref()),
512                target,
513            ));
514        }
515
516        let content_digest = digest_post_content(&document);
517        let assets = ResolvedPostAssets::from_resolution(
518            &document,
519            &content_digest,
520            &self.allowed_origins,
521            image,
522            references.into_values().collect(),
523            markdown_destinations,
524        );
525        PreparedPost {
526            document,
527            assets,
528            content_digest,
529        }
530    }
531
532    fn is_markdown_file_reference(&self, value: &str) -> bool {
533        if looks_like_local_asset_namespace(value) {
534            return true;
535        }
536        self.has_allowed_external_origin(value)
537    }
538
539    fn resolve_reference(
540        &mut self,
541        path: &LogicalContentPath,
542        location: AssetReferenceLocation,
543        authored: &str,
544    ) -> Option<AssetRevisionReference> {
545        if looks_like_external_reference(authored) {
546            return self.resolve_external(path, location, authored);
547        }
548
549        let logical_path = match LogicalAssetPath::parse(authored) {
550            Ok(path) => path,
551            Err(error) => {
552                self.errors.push(AssetResolutionError::new(
553                    path.clone(),
554                    location,
555                    AssetResolutionCode::LocalAssetPathInvalid,
556                    format!("local asset reference `{authored}` is invalid: {error}"),
557                ));
558                return None;
559            }
560        };
561        let Some(bytes) = self.discovered_assets.get(&logical_path).cloned() else {
562            self.errors.push(AssetResolutionError::new(
563                path.clone(),
564                location,
565                AssetResolutionCode::LocalAssetMissing,
566                format!("local asset does not exist: {logical_path}"),
567            ));
568            return None;
569        };
570        let resolved = self
571            .resolved_local_assets
572            .entry(logical_path.clone())
573            .or_insert_with(|| ResolvedLocalAsset {
574                asset: DigestedAsset::new(logical_path, digest_asset(&bytes)),
575                bytes,
576            });
577        Some(AssetRevisionReference::local(resolved.asset.clone()))
578    }
579
580    fn resolve_external(
581        &mut self,
582        path: &LogicalContentPath,
583        location: AssetReferenceLocation,
584        authored: &str,
585    ) -> Option<AssetRevisionReference> {
586        let url = match ExternalAssetUrl::parse(authored) {
587            Ok(url) => url,
588            Err(error) => {
589                self.errors.push(AssetResolutionError::new(
590                    path.clone(),
591                    location,
592                    AssetResolutionCode::ExternalAssetUrlInvalid,
593                    error.to_string(),
594                ));
595                return None;
596            }
597        };
598        if !self.is_allowed(&url) {
599            self.errors.push(AssetResolutionError::new(
600                path.clone(),
601                location,
602                AssetResolutionCode::ExternalAssetOriginNotAllowed,
603                "external asset origin is not allowlisted",
604            ));
605            return None;
606        }
607        if !appears_immutable(&url) {
608            self.warnings.push(AssetResolutionWarning {
609                path: path.clone(),
610                location,
611                code: AssetResolutionWarningCode::ExternalUrlMayBeMutable,
612                url: url.clone(),
613            });
614        }
615        Some(AssetRevisionReference::external(url))
616    }
617
618    fn is_allowed(&self, url: &ExternalAssetUrl) -> bool {
619        self.is_allowed_url(url.as_url())
620    }
621
622    fn has_allowed_external_origin(&self, authored: &str) -> bool {
623        if let Ok(url) = url::Url::parse(authored) {
624            return self.is_allowed_url(&url);
625        }
626
627        let Some(host) = raw_https_host(authored) else {
628            return false;
629        };
630        self.allowed_origins
631            .iter()
632            .filter_map(|origin| origin.as_url().host_str())
633            .any(|allowed| allowed.eq_ignore_ascii_case(host))
634    }
635
636    fn is_allowed_url(&self, url: &url::Url) -> bool {
637        self.allowed_origins.iter().any(|origin| {
638            origin.as_url().scheme() == url.scheme()
639                && origin.as_url().host() == url.host()
640                && origin.as_url().port_or_known_default() == url.port_or_known_default()
641        })
642    }
643}
644
645fn reference_key(reference: &AssetRevisionReference) -> (u8, String) {
646    let (kind, value) = reference.sort_key();
647    (kind, value.to_owned())
648}
649
650fn looks_like_external_reference(value: &str) -> bool {
651    if value.starts_with("//") || value.contains("://") {
652        return true;
653    }
654    value.find(':').is_some_and(|colon| {
655        value
656            .find(['/', '\\'])
657            .is_none_or(|separator| colon < separator)
658    })
659}
660
661fn raw_https_host(value: &str) -> Option<&str> {
662    let (scheme, remainder) = value.trim().split_once("://")?;
663    if !scheme.eq_ignore_ascii_case("https") {
664        return None;
665    }
666    let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
667    let authority = remainder[..authority_end]
668        .rsplit_once('@')
669        .map_or(&remainder[..authority_end], |(_, host)| host);
670    if authority.starts_with('[') {
671        let end = authority.find(']')?;
672        return Some(&authority[1..end]);
673    }
674    Some(
675        authority
676            .split_once(':')
677            .map_or(authority, |(host, _)| host),
678    )
679    .filter(|host| !host.is_empty())
680}
681
682fn looks_like_local_asset_namespace(value: &str) -> bool {
683    if looks_like_external_reference(value) {
684        return false;
685    }
686    let value = value.trim();
687    let lowercase = value.to_ascii_lowercase();
688    if lowercase == "assets"
689        || lowercase.starts_with("assets/")
690        || lowercase.starts_with("assets\\")
691        || lowercase.starts_with("assets%")
692        || lowercase.starts_with("/assets/")
693        || lowercase.starts_with("./assets/")
694        || lowercase.starts_with("../assets/")
695    {
696        return true;
697    }
698    lowercase
699        .split(['/', '\\'])
700        .any(|component| component == "assets")
701}
702
703fn appears_immutable(url: &ExternalAssetUrl) -> bool {
704    if url.as_url().query_pairs().any(|(key, value)| {
705        !value.is_empty()
706            && matches!(
707                key.to_ascii_lowercase().as_ref(),
708                "v" | "ver" | "version" | "rev" | "revision" | "hash" | "digest"
709            )
710    }) {
711        return true;
712    }
713
714    url.as_url().path_segments().is_some_and(|segments| {
715        segments.flat_map(version_tokens).any(|token| {
716            let token = token.to_ascii_lowercase();
717            token.strip_prefix('v').is_some_and(|digits| {
718                !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
719            }) || (token.len() >= 8
720                && token.bytes().all(|byte| byte.is_ascii_hexdigit())
721                && token.bytes().any(|byte| byte.is_ascii_digit()))
722        })
723    })
724}
725
726fn version_tokens(segment: &str) -> impl Iterator<Item = &str> {
727    segment.split(|character: char| !character.is_ascii_alphanumeric())
728}
729
730#[cfg(test)]
731mod tests {
732    use std::collections::BTreeSet;
733
734    use super::*;
735    use crate::identity::finalize_post_revision;
736    use crate::tree::{asset, post, publication};
737    use crate::{ContentValidationCode, DraftStatus, PostCollection};
738
739    const POST_ID: &str = "4f054633-2d09-4b05-97d0-c6f0011a5199";
740    const SECOND_POST_ID: &str = "7d97b17a-686d-46f4-ad77-234f4973c69a";
741
742    fn publication_source(favicon: Option<&str>, origins: &[&str]) -> String {
743        let favicon = favicon
744            .map(|value| format!("favicon = {value:?}\n"))
745            .unwrap_or_default();
746        let origins = origins
747            .iter()
748            .map(|origin| format!("{origin:?}"))
749            .collect::<Vec<_>>()
750            .join(", ");
751        format!(
752            "[site]\n\
753             title = \"Field Notes\"\n\
754             base_url = \"https://blog.example.com/\"\n\
755             description = \"Resolver tests.\"\n\
756             {favicon}\n\
757             [author]\n\
758             name = \"Example Author\"\n\
759             \n\
760             [assets]\n\
761             allowed_https_origins = [{origins}]\n"
762        )
763    }
764
765    fn post_source(id: &str, slug: &str, image: Option<&str>, draft: bool, body: &str) -> String {
766        let image = image
767            .map(|value| format!("image = {value:?}\n"))
768            .unwrap_or_default();
769        format!(
770            "+++\n\
771             id = \"{id}\"\n\
772             title = \"{slug}\"\n\
773             slug = \"{slug}\"\n\
774             authored_at = 2026-08-29T15:00:00-04:00\n\
775             description = \"Resolver fixture.\"\n\
776             {image}\
777             draft = {draft}\n\
778             +++\n\
779             {body}"
780        )
781    }
782
783    fn content_tree(
784        publication_contents: String,
785        posts: Vec<(&str, PostCollection, String)>,
786        assets: Vec<(&str, &[u8])>,
787    ) -> DiscoveredContentTree {
788        DiscoveredContentTree::new(
789            publication("publication.toml", publication_contents),
790            posts
791                .into_iter()
792                .map(|(path, collection, source)| post(path, collection, source))
793                .collect(),
794            assets
795                .into_iter()
796                .map(|(path, bytes)| {
797                    asset(
798                        LogicalAssetPath::parse(path).expect("fixture asset path must parse"),
799                        bytes.to_vec(),
800                    )
801                })
802                .collect(),
803            0,
804        )
805    }
806
807    fn resolve(tree: &DiscoveredContentTree) -> PreparedContent {
808        prepare_content(tree).expect("fixture content and assets must validate")
809    }
810
811    #[test]
812    fn preparation_rejects_duplicate_posts_before_resolving_their_assets() {
813        let tree = content_tree(
814            publication_source(None, &[]),
815            vec![
816                (
817                    "posts/first.md",
818                    PostCollection::Posts,
819                    post_source(
820                        POST_ID,
821                        "first",
822                        None,
823                        false,
824                        "![missing](assets/missing.png)",
825                    ),
826                ),
827                (
828                    "posts/second.md",
829                    PostCollection::Posts,
830                    post_source(POST_ID, "second", None, false, "Second"),
831                ),
832            ],
833            Vec::new(),
834        );
835        let PrepareContentError::InvalidContent(errors) = prepare_content(&tree).unwrap_err()
836        else {
837            panic!("invalid documents must fail before asset resolution")
838        };
839        assert_eq!(errors.errors().len(), 1);
840        assert_eq!(
841            errors.errors()[0].code,
842            ContentValidationCode::DuplicatePostId
843        );
844    }
845
846    #[test]
847    fn prepared_pairs_and_revision_bytes_are_stable_across_discovery_order() {
848        let mut tree = content_tree(
849            publication_source(Some("assets/cover.png"), &["https://cdn.example.com"]),
850            vec![
851                (
852                    "posts/z-assets.md",
853                    PostCollection::Posts,
854                    post_source(
855                        POST_ID,
856                        "assets",
857                        Some("assets/cover.png"),
858                        false,
859                        "![z](https://cdn.example.com/z-v1.png)\n\
860                         [file](assets/file.pdf)\n\
861                         ![a](https://cdn.example.com/a-v1.png)\n\
862                         ![cover](assets/cover.png)\n\
863                         [repeated](assets/file.pdf)",
864                    ),
865                ),
866                (
867                    "drafts/a-empty.md",
868                    PostCollection::Drafts,
869                    post_source(SECOND_POST_ID, "empty", None, true, "No assets."),
870                ),
871            ],
872            vec![("assets/file.pdf", b"file"), ("assets/cover.png", b"cover")],
873        );
874        let prepared = prepare_content(&tree).unwrap();
875        tree.posts.reverse();
876        tree.assets.reverse();
877        assert_eq!(prepared, prepare_content(&tree).unwrap());
878        drop(tree);
879
880        let content = prepared.view();
881        assert_eq!(
882            content.posts[0].view().document.metadata.id.as_str(),
883            SECOND_POST_ID
884        );
885        assert!(content.posts[0].view().assets.references.is_empty());
886        assert_eq!(
887            content.posts[1].view().document.metadata.id.as_str(),
888            POST_ID
889        );
890        assert_eq!(content.posts[1].view().assets.references.len(), 4);
891        let renderer = PostRendererIdentity::baseline();
892        for post in content.posts {
893            let inputs = post.view();
894            let article = inputs.document.markdown.as_str().as_bytes();
895            assert_eq!(
896                post.finalize_revision(&renderer, article),
897                finalize_post_revision(
898                    inputs.document,
899                    inputs.assets,
900                    content.site_assets,
901                    &renderer,
902                    article,
903                    &[]
904                )
905                .expect("prepared inputs must satisfy the checked identity API"),
906            );
907        }
908    }
909
910    #[test]
911    fn resolves_site_post_and_occurrence_level_markdown_assets() {
912        let body = "[ordinary](https://example.org/read)\n\
913                    ![diagram](assets/images/diagram.png)\n\
914                    [manual](assets/files/manual.pdf)\n\
915                    [manual again](assets/files/manual.pdf)\n\
916                    [CDN download](https://cdn.example.com/files/manual.pdf?version=2)";
917        let tree = content_tree(
918            publication_source(
919                Some("assets/site/favicon.png"),
920                &["HTTPS://CDN.EXAMPLE.COM:443"],
921            ),
922            vec![(
923                "posts/resolver.md",
924                PostCollection::Posts,
925                post_source(
926                    POST_ID,
927                    "resolver",
928                    Some("https://cdn.example.com/posts/cover-v1.webp"),
929                    false,
930                    body,
931                ),
932            )],
933            vec![
934                ("assets/site/favicon.png", b"favicon"),
935                ("assets/images/diagram.png", b"diagram"),
936                ("assets/files/manual.pdf", b"manual"),
937            ],
938        );
939
940        let resolved = resolve(&tree);
941        assert!(resolved.warnings.is_empty());
942        assert_eq!(
943            resolved.site.allowed_origins[0].as_str(),
944            "https://cdn.example.com/"
945        );
946        let Some(AssetRevisionReference::Local(favicon)) = resolved.site.favicon.as_ref() else {
947            panic!("favicon must resolve to a local asset")
948        };
949        assert_eq!(favicon.path.as_str(), "assets/site/favicon.png");
950        assert_eq!(
951            resolved
952                .local_assets
953                .resolve(favicon)
954                .expect("favicon bytes must be retained")
955                .bytes
956                .as_ref(),
957            b"favicon"
958        );
959
960        let post = &resolved.posts[0];
961        let Some(AssetRevisionReference::External(image)) = post.assets.image.as_ref() else {
962            panic!("preview must resolve to an external URL")
963        };
964        assert_eq!(
965            image.as_str(),
966            "https://cdn.example.com/posts/cover-v1.webp"
967        );
968        assert_eq!(post.assets.references.len(), 3);
969
970        let occurrences = &post.assets.markdown_destinations;
971        assert_eq!(occurrences.len(), 4);
972        assert_eq!(
973            occurrences
974                .iter()
975                .map(|occurrence| occurrence.ordinal.get())
976                .collect::<Vec<_>>(),
977            vec![2, 3, 4, 5]
978        );
979        assert_eq!(occurrences[0].kind, MarkdownDestinationKind::Image);
980        assert_eq!(occurrences[1].kind, MarkdownDestinationKind::Download);
981        assert_eq!(occurrences[1].authored.as_str(), "assets/files/manual.pdf");
982        assert!(document_range(&tree, occurrences[0].source_range).starts_with("![diagram]"));
983        assert_eq!(
984            reference_key(&occurrences[1].target),
985            reference_key(&occurrences[2].target)
986        );
987        assert_eq!(resolved.local_assets.assets.len(), 3);
988    }
989
990    #[test]
991    fn aggregates_invalid_origins_urls_missing_files_and_namespace_traversal() {
992        let tree = content_tree(
993            publication_source(
994                Some("assets/missing.ico"),
995                &[
996                    "https://cdn.example.com",
997                    "HTTPS://CDN.EXAMPLE.COM:443/",
998                    "http://insecure.example.com",
999                ],
1000            ),
1001            vec![(
1002                "posts/errors.md",
1003                PostCollection::Posts,
1004                post_source(
1005                    POST_ID,
1006                    "errors",
1007                    Some("http://cdn.example.com/cover.png"),
1008                    false,
1009                    "![sibling](https://images.cdn.example.com/image-v1.png)\n\
1010                     ![bad port](https://cdn.example.com:invalid/image.png)\n\
1011                     [escape](../assets/secret.pdf)",
1012                ),
1013            )],
1014            Vec::new(),
1015        );
1016        let error = prepare_content(&tree).expect_err("assets must fail");
1017        let PrepareContentError::AssetResolution(errors) = error else {
1018            panic!("resolution diagnostics were expected")
1019        };
1020        let codes: BTreeSet<_> = errors.errors().iter().map(|error| error.code).collect();
1021        assert_eq!(
1022            codes,
1023            BTreeSet::from([
1024                AssetResolutionCode::AllowedOriginDuplicated,
1025                AssetResolutionCode::AllowedOriginInvalid,
1026                AssetResolutionCode::ExternalAssetOriginNotAllowed,
1027                AssetResolutionCode::ExternalAssetUrlInvalid,
1028                AssetResolutionCode::LocalAssetMissing,
1029                AssetResolutionCode::LocalAssetPathInvalid,
1030            ])
1031        );
1032        assert!(
1033            errors
1034                .errors()
1035                .windows(2)
1036                .all(|pair| { pair[0].sort_key() <= pair[1].sort_key() })
1037        );
1038    }
1039
1040    #[test]
1041    fn rejects_credentials_fragments_and_malformed_ports_for_each_external_asset_surface() {
1042        for invalid in [
1043            "https://user@cdn.example.com/image.png",
1044            "https://cdn.example.com/image.png#section",
1045            "https://cdn.example.com:/image.png",
1046            "https://cdn.example.com:65536/image.png",
1047        ] {
1048            let tree = content_tree(
1049                publication_source(None, &["https://cdn.example.com"]),
1050                vec![(
1051                    "posts/invalid.md",
1052                    PostCollection::Posts,
1053                    post_source(POST_ID, "invalid", Some(invalid), false, "Body"),
1054                )],
1055                Vec::new(),
1056            );
1057            let PrepareContentError::AssetResolution(errors) =
1058                prepare_content(&tree).expect_err("URL must fail")
1059            else {
1060                panic!("resolution diagnostics were expected")
1061            };
1062            assert_eq!(errors.errors().len(), 1, "invalid value: {invalid}");
1063            assert_eq!(
1064                errors.errors()[0].code,
1065                AssetResolutionCode::ExternalAssetUrlInvalid,
1066                "invalid value: {invalid}"
1067            );
1068        }
1069
1070        let tree = content_tree(
1071            publication_source(
1072                Some("https://user@cdn.example.com/favicon.png"),
1073                &["https://cdn.example.com"],
1074            ),
1075            vec![(
1076                "posts/downloads.md",
1077                PostCollection::Posts,
1078                post_source(
1079                    POST_ID,
1080                    "downloads",
1081                    None,
1082                    false,
1083                    "[fragment](https://cdn.example.com/file.pdf#page)\n\
1084                     [userinfo](https://token@cdn.example.com/file.pdf)\n\
1085                     [port](https://cdn.example.com:invalid/file.pdf)",
1086                ),
1087            )],
1088            Vec::new(),
1089        );
1090        let PrepareContentError::AssetResolution(errors) =
1091            prepare_content(&tree).expect_err("URLs must fail")
1092        else {
1093            panic!("resolution diagnostics were expected")
1094        };
1095        assert_eq!(errors.errors().len(), 4);
1096        assert!(errors.errors().iter().all(|error| {
1097            error.code == AssetResolutionCode::ExternalAssetUrlInvalid
1098                && !error.message.contains("token")
1099                && !error.message.contains("user")
1100        }));
1101    }
1102
1103    #[test]
1104    fn resolver_rejects_raw_backslashes_before_whatwg_can_normalize_them() {
1105        let tree = content_tree(
1106            publication_source(
1107                Some("https://cdn.example.com\\favicon.png"),
1108                &["https://cdn.example.com"],
1109            ),
1110            vec![(
1111                "posts/raw-url.md",
1112                PostCollection::Posts,
1113                post_source(
1114                    POST_ID,
1115                    "raw-url",
1116                    Some("https://cdn.example.com\\cover.png"),
1117                    false,
1118                    "![backslash](https://cdn.example.com\\image.png)",
1119                ),
1120            )],
1121            Vec::new(),
1122        );
1123        let PrepareContentError::AssetResolution(errors) =
1124            prepare_content(&tree).expect_err("raw backslashes must fail")
1125        else {
1126            panic!("resolution diagnostics were expected")
1127        };
1128        assert_eq!(errors.errors().len(), 3);
1129        assert!(
1130            errors
1131                .errors()
1132                .iter()
1133                .all(|error| error.code == AssetResolutionCode::ExternalAssetUrlInvalid)
1134        );
1135    }
1136
1137    #[test]
1138    fn ordinary_links_bypass_asset_policy_but_asset_namespace_attempts_do_not() {
1139        let ordinary_tree = content_tree(
1140            publication_source(None, &["https://cdn.example.com"]),
1141            vec![(
1142                "posts/links.md",
1143                PostCollection::Posts,
1144                post_source(
1145                    POST_ID,
1146                    "links",
1147                    None,
1148                    false,
1149                    "[site](http://example.org) \
1150                     [remote file](https://example.org/file.pdf) \
1151                     [different port](https://cdn.example.com:8443/file.pdf)",
1152                ),
1153            )],
1154            Vec::new(),
1155        );
1156        let ordinary = resolve(&ordinary_tree);
1157        assert!(ordinary.posts[0].assets.markdown_destinations.is_empty());
1158
1159        for invalid in [
1160            "assets/../private.txt",
1161            "ASSETS/private.txt",
1162            "./assets/private.txt",
1163            "assets%2fprivate.txt",
1164        ] {
1165            let tree = content_tree(
1166                publication_source(None, &[]),
1167                vec![(
1168                    "posts/escape.md",
1169                    PostCollection::Posts,
1170                    post_source(
1171                        POST_ID,
1172                        "escape",
1173                        None,
1174                        false,
1175                        &format!("[file]({invalid})"),
1176                    ),
1177                )],
1178                Vec::new(),
1179            );
1180            let PrepareContentError::AssetResolution(errors) =
1181                prepare_content(&tree).expect_err("path must fail")
1182            else {
1183                panic!("resolution diagnostics were expected")
1184            };
1185            assert_eq!(
1186                errors.errors()[0].code,
1187                AssetResolutionCode::LocalAssetPathInvalid,
1188                "invalid value: {invalid}"
1189            );
1190        }
1191    }
1192
1193    #[test]
1194    fn warnings_are_typed_deterministic_and_occurrence_scoped() {
1195        let first = content_tree(
1196            publication_source(
1197                Some("https://cdn.example.com/favicon.png"),
1198                &["https://cdn.example.com"],
1199            ),
1200            vec![
1201                (
1202                    "posts/zulu.md",
1203                    PostCollection::Posts,
1204                    post_source(
1205                        POST_ID,
1206                        "zulu",
1207                        None,
1208                        false,
1209                        "![mutable](https://cdn.example.com/image.png)",
1210                    ),
1211                ),
1212                (
1213                    "posts/alpha.md",
1214                    PostCollection::Posts,
1215                    post_source(
1216                        SECOND_POST_ID,
1217                        "alpha",
1218                        Some("https://cdn.example.com/cover.png"),
1219                        false,
1220                        "[versioned](https://cdn.example.com/manual.pdf?revision=three)",
1221                    ),
1222                ),
1223            ],
1224            Vec::new(),
1225        );
1226        let second = content_tree(
1227            publication_source(
1228                Some("https://cdn.example.com/favicon.png"),
1229                &["https://cdn.example.com"],
1230            ),
1231            first
1232                .posts
1233                .iter()
1234                .rev()
1235                .map(|post| (post.path.as_str(), post.collection, post.source.to_string()))
1236                .collect(),
1237            Vec::new(),
1238        );
1239
1240        let first = resolve(&first);
1241        let second = resolve(&second);
1242        assert_eq!(first.warnings, second.warnings);
1243        assert_eq!(first.warnings.warnings().len(), 3);
1244        assert!(first.warnings.warnings().iter().all(|warning| {
1245            warning.code == AssetResolutionWarningCode::ExternalUrlMayBeMutable
1246        }));
1247        assert!(
1248            first
1249                .warnings
1250                .warnings()
1251                .iter()
1252                .any(|warning| matches!(warning.location, AssetReferenceLocation::Markdown { .. }))
1253        );
1254    }
1255
1256    #[test]
1257    fn shared_local_assets_keep_occurrences_and_refresh_identity_when_bytes_change() {
1258        let make_tree = |bytes: &'static [u8]| {
1259            content_tree(
1260                publication_source(Some("assets/shared.png"), &[]),
1261                vec![(
1262                    "posts/local.md",
1263                    PostCollection::Posts,
1264                    post_source(
1265                        POST_ID,
1266                        "local",
1267                        Some("assets/shared.png"),
1268                        false,
1269                        "![local](assets/shared.png)\n[download](assets/shared.png)",
1270                    ),
1271                )],
1272                vec![("assets/shared.png", bytes)],
1273            )
1274        };
1275        let first = resolve(&make_tree(b"first"));
1276        let second = resolve(&make_tree(b"second"));
1277        assert_eq!(first.local_assets.assets.len(), 1);
1278        assert_eq!(first.posts[0].assets.image, first.site.favicon);
1279        let occurrences = &first.posts[0].assets.markdown_destinations;
1280        assert_eq!(occurrences.len(), 2);
1281        for occurrence in occurrences {
1282            assert_eq!(Some(&occurrence.target), first.site.favicon.as_ref());
1283        }
1284        let Some(AssetRevisionReference::Local(first_favicon)) = first.site.favicon.as_ref() else {
1285            panic!("local favicon was expected")
1286        };
1287        let Some(AssetRevisionReference::Local(second_favicon)) = second.site.favicon.as_ref()
1288        else {
1289            panic!("local favicon was expected")
1290        };
1291        assert_ne!(first_favicon.digest, second_favicon.digest);
1292        assert_eq!(
1293            first
1294                .local_assets
1295                .resolve(first_favicon)
1296                .expect("matching reference must resolve")
1297                .bytes
1298                .as_ref(),
1299            b"first"
1300        );
1301        assert_eq!(
1302            second.local_assets.resolve(first_favicon),
1303            Err(ResolvedLocalAssetLookupError::DigestMismatch)
1304        );
1305    }
1306
1307    #[test]
1308    fn duplicate_discovered_asset_paths_are_rejected() {
1309        let publication_contents = publication_source(Some("assets/favicon.png"), &[]);
1310        let tree = DiscoveredContentTree::new(
1311            publication("publication.toml", publication_contents),
1312            vec![post(
1313                "posts/post.md",
1314                PostCollection::Posts,
1315                post_source(POST_ID, "post", None, false, "Body"),
1316            )],
1317            vec![
1318                asset(
1319                    LogicalAssetPath::parse("assets/favicon.png").unwrap(),
1320                    b"first".to_vec(),
1321                ),
1322                asset(
1323                    LogicalAssetPath::parse("assets/favicon.png").unwrap(),
1324                    b"second".to_vec(),
1325                ),
1326            ],
1327            0,
1328        );
1329        let PrepareContentError::AssetResolution(errors) =
1330            prepare_content(&tree).expect_err("duplicate asset must fail")
1331        else {
1332            panic!("resolution diagnostics were expected")
1333        };
1334        assert!(
1335            errors
1336                .errors()
1337                .iter()
1338                .any(|error| error.code == AssetResolutionCode::LocalAssetDuplicated)
1339        );
1340    }
1341
1342    #[test]
1343    fn draft_assets_remain_scoped_to_their_source_bound_post() {
1344        let tree = content_tree(
1345            publication_source(None, &[]),
1346            vec![
1347                (
1348                    "posts/public.md",
1349                    PostCollection::Posts,
1350                    post_source(
1351                        POST_ID,
1352                        "public",
1353                        None,
1354                        false,
1355                        "![public](assets/public.png)",
1356                    ),
1357                ),
1358                (
1359                    "drafts/private.md",
1360                    PostCollection::Drafts,
1361                    post_source(
1362                        SECOND_POST_ID,
1363                        "private",
1364                        None,
1365                        true,
1366                        "![private](assets/private.png)",
1367                    ),
1368                ),
1369            ],
1370            vec![
1371                ("assets/public.png", b"public"),
1372                ("assets/private.png", b"private"),
1373            ],
1374        );
1375        let resolved = resolve(&tree);
1376        assert!(resolved.site.references.is_empty());
1377        let public = resolved
1378            .posts
1379            .iter()
1380            .find(|post| post.document.metadata.draft == DraftStatus::Publishable)
1381            .expect("publishable post must be present");
1382        let draft = resolved
1383            .posts
1384            .iter()
1385            .find(|post| post.document.metadata.draft == DraftStatus::Draft)
1386            .expect("draft post must be present");
1387        assert_eq!(public.assets.references.len(), 1);
1388        assert_eq!(draft.assets.references.len(), 1);
1389        assert_ne!(
1390            reference_key(&public.assets.references[0]),
1391            reference_key(&draft.assets.references[0])
1392        );
1393    }
1394
1395    fn document_range(tree: &DiscoveredContentTree, range: MarkdownSourceRange) -> &str {
1396        let source = &tree.posts[0].source;
1397        let (_, markdown) = source
1398            .split_once("+++\n")
1399            .and_then(|(_, rest)| rest.split_once("+++\n"))
1400            .expect("fixture frontmatter delimiters must exist");
1401        &markdown[range.as_range()]
1402    }
1403
1404    #[test]
1405    fn public_diagnostic_enums_have_exhaustive_stable_wire_values() {
1406        let ordinal = MarkdownDestinationOrdinal::new(NonZeroUsize::MIN);
1407        let allowed = AllowedOriginOrdinal::new(NonZeroUsize::MIN);
1408        let locations = [
1409            (
1410                AssetReferenceLocation::ContentSource,
1411                serde_json::json!({"kind": "content_source"}),
1412            ),
1413            (
1414                AssetReferenceLocation::AllowedOrigin { ordinal: allowed },
1415                serde_json::json!({"kind": "allowed_origin", "ordinal": 1}),
1416            ),
1417            (
1418                AssetReferenceLocation::PublicationFavicon,
1419                serde_json::json!({"kind": "publication_favicon"}),
1420            ),
1421            (
1422                AssetReferenceLocation::PostPreviewImage,
1423                serde_json::json!({"kind": "post_preview_image"}),
1424            ),
1425            (
1426                AssetReferenceLocation::Markdown {
1427                    ordinal,
1428                    destination_kind: MarkdownDestinationKind::Image,
1429                },
1430                serde_json::json!({
1431                    "kind": "markdown",
1432                    "ordinal": 1,
1433                    "destination_kind": "image"
1434                }),
1435            ),
1436            (
1437                AssetReferenceLocation::LocalAsset,
1438                serde_json::json!({"kind": "local_asset"}),
1439            ),
1440        ];
1441        for (value, expected) in locations {
1442            assert_eq!(serde_json::to_value(value).unwrap(), expected);
1443        }
1444
1445        let codes = [
1446            (
1447                AssetResolutionCode::AllowedOriginInvalid,
1448                "allowed_origin_invalid",
1449            ),
1450            (
1451                AssetResolutionCode::AllowedOriginDuplicated,
1452                "allowed_origin_duplicated",
1453            ),
1454            (
1455                AssetResolutionCode::AllowedOriginCountExceeded,
1456                "allowed_origin_count_exceeded",
1457            ),
1458            (
1459                AssetResolutionCode::LocalAssetDuplicated,
1460                "local_asset_duplicated",
1461            ),
1462            (
1463                AssetResolutionCode::LocalAssetPathInvalid,
1464                "local_asset_path_invalid",
1465            ),
1466            (
1467                AssetResolutionCode::LocalAssetMissing,
1468                "local_asset_missing",
1469            ),
1470            (
1471                AssetResolutionCode::ExternalAssetUrlInvalid,
1472                "external_asset_url_invalid",
1473            ),
1474            (
1475                AssetResolutionCode::ExternalAssetOriginNotAllowed,
1476                "external_asset_origin_not_allowed",
1477            ),
1478            (
1479                AssetResolutionCode::MarkdownDestinationCountExceeded,
1480                "markdown_destination_count_exceeded",
1481            ),
1482        ];
1483        for (value, expected) in codes {
1484            assert_eq!(serde_json::to_value(value).unwrap(), expected);
1485        }
1486
1487        for (value, expected) in [
1488            (MarkdownDestinationKind::Image, "image"),
1489            (MarkdownDestinationKind::Download, "download"),
1490        ] {
1491            assert_eq!(serde_json::to_value(value).unwrap(), expected);
1492        }
1493        assert_eq!(
1494            serde_json::to_value(AssetResolutionWarningCode::ExternalUrlMayBeMutable).unwrap(),
1495            "external_url_may_be_mutable"
1496        );
1497        assert_eq!(
1498            serde_json::to_value(ResolvedLocalAssetLookupError::Missing).unwrap(),
1499            "missing"
1500        );
1501        assert_eq!(
1502            serde_json::to_value(ResolvedLocalAssetLookupError::DigestMismatch).unwrap(),
1503            "digest_mismatch"
1504        );
1505    }
1506}