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
21pub 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#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct PreparedPost {
75 document: PostDocument,
76 assets: ResolvedPostAssets,
77 content_digest: PostContentDigest,
78}
79
80#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct ResolvedLocalAsset {
113 pub asset: DigestedAsset,
114 pub bytes: Arc<[u8]>,
115}
116
117#[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
138impl<'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",
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\n\
860 [file](assets/file.pdf)\n\
861 \n\
862 \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 \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\n\
1010 \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\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",
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 \
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"),
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",
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\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",
1356 ),
1357 ),
1358 (
1359 "drafts/private.md",
1360 PostCollection::Drafts,
1361 post_source(
1362 SECOND_POST_ID,
1363 "private",
1364 None,
1365 true,
1366 "",
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}