1use crate::config::ContentConfig;
4use crate::error::{Error, Result};
5use crate::types::*;
6use chrono::Utc;
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11
12#[derive(Debug)]
14pub struct ContentCache {
15 posts: HashMap<String, Post>,
16 series: HashMap<String, SeriesData>,
17 etag: String,
18}
19
20#[derive(Debug)]
22struct SeriesData {
23 slug: String,
24 config: SeriesConfig,
25 post_slugs: Vec<String>,
26}
27
28impl ContentCache {
29 pub fn load(config: &ContentConfig) -> Result<Self> {
31 let content_path = config.repo_path.join(&config.content_dir);
32
33 if !content_path.exists() {
34 return Ok(Self {
35 posts: HashMap::new(),
36 series: HashMap::new(),
37 etag: Self::compute_etag(&HashMap::new(), &HashMap::new()),
38 });
39 }
40
41 let mut posts = HashMap::new();
42 let mut series = HashMap::new();
43 let mut errors = 0u32;
44 let mut total_bytes: u64 = 0;
45
46 for entry in fs::read_dir(&content_path)? {
48 let entry = match entry {
49 Ok(e) => e,
50 Err(e) => {
51 tracing::warn!("Failed to read directory entry: {}", e);
52 errors += 1;
53 continue;
54 }
55 };
56 let path = entry.path();
57
58 let file_type = match entry.file_type() {
64 Ok(ft) => ft,
65 Err(e) => {
66 tracing::warn!("Failed to get file type for {:?}: {}", path, e);
67 errors += 1;
68 continue;
69 }
70 };
71 if file_type.is_symlink() {
72 tracing::warn!(
73 "Security: Skipping symlink in content directory: {:?}",
74 path
75 );
76 continue;
77 }
78 if !file_type.is_dir() {
79 continue;
80 }
81
82 let slug = match path.file_name().and_then(|n| n.to_str()) {
83 Some(s) => s.to_string(),
84 None => {
85 tracing::warn!("Skipping directory with invalid name: {:?}", path);
86 errors += 1;
87 continue;
88 }
89 };
90
91 if total_bytes > config.max_total_content_size {
93 tracing::error!(
94 "Total content size ({} bytes) exceeds limit ({} bytes). \
95 Skipping remaining content.",
96 total_bytes,
97 config.max_total_content_size
98 );
99 break;
100 }
101
102 let series_toml = path.join("series.toml");
104 if series_toml.exists() {
105 match Self::load_series(&path, &slug, config.max_content_file_size) {
106 Ok((series_data, series_posts)) => {
107 for post in &series_posts {
108 total_bytes += post.content.len() as u64;
109 }
110 series.insert(slug.clone(), series_data);
111 for post in series_posts {
112 posts.insert(post.slug.clone(), post);
113 }
114 }
115 Err(e) => {
116 tracing::error!("Failed to load series '{}': {}", slug, e);
117 errors += 1;
118 }
119 }
120 } else {
121 let config_toml = path.join("config.toml");
123 let content_mdx = path.join("content.mdx");
124
125 if config_toml.exists() && content_mdx.exists() {
126 match Self::load_post(&path, &slug, None, config.max_content_file_size) {
127 Ok(post) => {
128 total_bytes += post.content.len() as u64;
129 posts.insert(slug, post);
130 }
131 Err(e) => {
132 tracing::error!("Failed to load post '{}': {}", slug, e);
133 errors += 1;
134 }
135 }
136 }
137 }
138 }
139
140 if errors > 0 {
141 tracing::warn!(
142 "Content loaded with {} error(s): {} posts, {} series",
143 errors,
144 posts.len(),
145 series.len()
146 );
147 } else {
148 tracing::info!(
149 "Content loaded: {} posts, {} series",
150 posts.len(),
151 series.len()
152 );
153 }
154
155 let etag = Self::compute_etag(&posts, &series);
156
157 Ok(Self {
158 posts,
159 series,
160 etag,
161 })
162 }
163
164 fn read_file_bounded(path: &Path, max_size: u64) -> Result<String> {
166 let meta = fs::metadata(path)?;
167 if meta.len() > max_size {
168 return Err(Error::Content {
169 path: path.to_path_buf(),
170 message: format!(
171 "File size {} bytes exceeds limit of {} bytes",
172 meta.len(),
173 max_size
174 ),
175 });
176 }
177 Ok(fs::read_to_string(path)?)
178 }
179
180 fn reject_symlink(path: &Path) -> Result<()> {
182 let meta = fs::symlink_metadata(path)?;
183 if meta.file_type().is_symlink() {
184 return Err(Error::Content {
185 path: path.to_path_buf(),
186 message: "Symlinks are not allowed in content directories".to_string(),
187 });
188 }
189 Ok(())
190 }
191
192 fn load_post(
194 path: &Path,
195 slug: &str,
196 series_slug: Option<&str>,
197 max_file_size: u64,
198 ) -> Result<Post> {
199 let config_path = path.join("config.toml");
200 let content_path = path.join("content.mdx");
201
202 Self::reject_symlink(&config_path)?;
204 Self::reject_symlink(&content_path)?;
205
206 let config_str = Self::read_file_bounded(&config_path, max_file_size)?;
207 let config: PostConfig = toml::from_str(&config_str).map_err(|e| Error::Content {
208 path: config_path.clone(),
209 message: e.to_string(),
210 })?;
211
212 let content = Self::read_file_bounded(&content_path, max_file_size)?;
213
214 Ok(Post {
215 slug: slug.to_string(),
216 title: config.title,
217 subtitle: config.subtitle,
218 preview_text: config.preview_text,
219 preview_image: config.preview_image,
220 tags: config.tags,
221 goes_live_at: config.goes_live_at,
222 series_slug: series_slug.map(String::from),
223 content,
224 order: config.order,
225 })
226 }
227
228 fn load_series(path: &Path, slug: &str, max_file_size: u64) -> Result<(SeriesData, Vec<Post>)> {
230 let series_toml = path.join("series.toml");
231 Self::reject_symlink(&series_toml)?;
232 let series_str = Self::read_file_bounded(&series_toml, max_file_size)?;
233 let config: SeriesConfig = toml::from_str(&series_str).map_err(|e| Error::Content {
234 path: series_toml.clone(),
235 message: e.to_string(),
236 })?;
237
238 let mut posts = Vec::new();
239 let mut post_slugs = Vec::new();
240
241 for entry in fs::read_dir(path)? {
243 let entry = entry?;
244 let post_path = entry.path();
245
246 let file_type = entry.file_type()?;
248 if file_type.is_symlink() {
249 tracing::warn!(
250 "Security: Skipping symlink in series directory: {:?}",
251 post_path
252 );
253 continue;
254 }
255 if !file_type.is_dir() {
256 continue;
257 }
258
259 let post_slug = post_path
260 .file_name()
261 .and_then(|n| n.to_str())
262 .ok_or_else(|| Error::Content {
263 path: post_path.clone(),
264 message: "Invalid directory name".to_string(),
265 })?
266 .to_string();
267
268 let config_toml = post_path.join("config.toml");
269 let content_mdx = post_path.join("content.mdx");
270
271 if config_toml.exists() && content_mdx.exists() {
272 let post = Self::load_post(&post_path, &post_slug, Some(slug), max_file_size)?;
273 post_slugs.push(post_slug);
274 posts.push(post);
275 }
276 }
277
278 posts.sort_by(|a, b| {
280 let order_a = Self::get_post_order(a);
281 let order_b = Self::get_post_order(b);
282 match (order_a, order_b) {
283 (Some(oa), Some(ob)) => oa.cmp(&ob),
284 (Some(_), None) => std::cmp::Ordering::Less,
285 (None, Some(_)) => std::cmp::Ordering::Greater,
286 (None, None) => a.slug.cmp(&b.slug),
287 }
288 });
289
290 post_slugs = posts.iter().map(|p| p.slug.clone()).collect();
291
292 let series_data = SeriesData {
293 slug: slug.to_string(),
294 config,
295 post_slugs,
296 };
297
298 Ok((series_data, posts))
299 }
300
301 fn get_post_order(post: &Post) -> Option<i32> {
303 post.order
304 }
305
306 fn compute_etag(posts: &HashMap<String, Post>, series: &HashMap<String, SeriesData>) -> String {
308 let mut hasher = Sha256::new();
309
310 let mut post_keys: Vec<_> = posts.keys().collect();
314 post_keys.sort();
315 for key in post_keys {
316 if let Some(post) = posts.get(key) {
317 hasher.update((key.len() as u64).to_le_bytes());
318 hasher.update(key.as_bytes());
319 hasher.update((post.content.len() as u64).to_le_bytes());
320 hasher.update(post.content.as_bytes());
321 }
322 }
323
324 let mut series_keys: Vec<_> = series.keys().collect();
325 series_keys.sort();
326 for key in series_keys {
327 hasher.update((key.len() as u64).to_le_bytes());
328 hasher.update(key.as_bytes());
329 }
330
331 let result = hasher.finalize();
332 format!("\"{}\"", hex::encode(result))
333 }
334
335 pub fn etag(&self) -> String {
337 self.etag.clone()
338 }
339
340 const MAX_PAGE_SIZE: usize = 500;
342
343 pub fn list_posts(&self, opts: &ListOptions) -> Result<ListResult<PostSummary>> {
345 let now = Utc::now();
346 let limit = opts.limit.unwrap_or(50).min(Self::MAX_PAGE_SIZE);
347 let offset = opts.offset.unwrap_or(0);
348
349 let mut filtered: Vec<_> = self
350 .posts
351 .values()
352 .filter(|post| Self::is_visible(post.goes_live_at, opts, &now))
353 .collect();
354
355 filtered.sort_by(|a, b| match (&b.goes_live_at, &a.goes_live_at) {
357 (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
358 (Some(_), None) => std::cmp::Ordering::Less,
359 (None, Some(_)) => std::cmp::Ordering::Greater,
360 (None, None) => a.slug.cmp(&b.slug),
361 });
362
363 let total = filtered.len();
364 let items: Vec<PostSummary> = filtered
365 .into_iter()
366 .skip(offset)
367 .take(limit)
368 .map(|p| p.into())
369 .collect();
370
371 Ok(ListResult {
372 items,
373 total,
374 limit,
375 offset,
376 })
377 }
378
379 pub fn get_post(&self, slug: &str) -> Result<Option<Post>> {
381 Ok(self.posts.get(slug).cloned())
382 }
383
384 pub fn list_series(&self, opts: &ListOptions) -> Result<ListResult<SeriesSummary>> {
386 let now = Utc::now();
387 let limit = opts.limit.unwrap_or(50).min(Self::MAX_PAGE_SIZE);
388 let offset = opts.offset.unwrap_or(0);
389
390 let mut filtered: Vec<_> = self
391 .series
392 .values()
393 .filter(|s| Self::is_visible(s.config.goes_live_at, opts, &now))
394 .collect();
395
396 filtered.sort_by(
398 |a, b| match (&b.config.goes_live_at, &a.config.goes_live_at) {
399 (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
400 (Some(_), None) => std::cmp::Ordering::Less,
401 (None, Some(_)) => std::cmp::Ordering::Greater,
402 (None, None) => a.slug.cmp(&b.slug),
403 },
404 );
405
406 let total = filtered.len();
407 let items: Vec<SeriesSummary> = filtered
408 .into_iter()
409 .skip(offset)
410 .take(limit)
411 .map(|s| SeriesSummary {
412 slug: s.slug.clone(),
413 title: s.config.title.clone(),
414 description: s.config.description.clone(),
415 preview_image: s.config.preview_image.clone(),
416 goes_live_at: s.config.goes_live_at,
417 post_count: s.post_slugs.len(),
418 })
419 .collect();
420
421 Ok(ListResult {
422 items,
423 total,
424 limit,
425 offset,
426 })
427 }
428
429 pub fn get_series(&self, slug: &str) -> Result<Option<Series>> {
431 let series_data = match self.series.get(slug) {
432 Some(s) => s,
433 None => return Ok(None),
434 };
435
436 let posts: Vec<SeriesPostSummary> = series_data
437 .post_slugs
438 .iter()
439 .filter_map(|post_slug| {
440 self.posts.get(post_slug).map(|post| SeriesPostSummary {
441 slug: post.slug.clone(),
442 title: post.title.clone(),
443 subtitle: post.subtitle.clone(),
444 preview_text: post.preview_text.clone(),
445 preview_image: post.preview_image.clone(),
446 tags: post.tags.clone(),
447 goes_live_at: post.goes_live_at,
448 order: post.order,
449 })
450 })
451 .collect();
452
453 Ok(Some(Series {
454 slug: series_data.slug.clone(),
455 title: series_data.config.title.clone(),
456 description: series_data.config.description.clone(),
457 preview_image: series_data.config.preview_image.clone(),
458 goes_live_at: series_data.config.goes_live_at,
459 posts,
460 }))
461 }
462
463 fn is_visible(
465 goes_live_at: Option<chrono::DateTime<Utc>>,
466 opts: &ListOptions,
467 now: &chrono::DateTime<Utc>,
468 ) -> bool {
469 match goes_live_at {
470 None => opts.include_drafts,
471 Some(date) if date > *now => opts.include_scheduled,
472 Some(_) => true, }
474 }
475
476 pub fn validate(&self) -> Vec<ValidationError> {
478 let mut errors = Vec::new();
479
480 for (slug, post) in &self.posts {
481 if post.title.is_empty() {
482 errors.push(ValidationError {
483 path: format!("{}/config.toml", slug),
484 message: "Title cannot be empty".to_string(),
485 });
486 }
487 if post.preview_text.is_empty() {
488 errors.push(ValidationError {
489 path: format!("{}/config.toml", slug),
490 message: "preview_text cannot be empty".to_string(),
491 });
492 }
493 if post.content.is_empty() {
494 errors.push(ValidationError {
495 path: format!("{}/content.mdx", slug),
496 message: "Content cannot be empty".to_string(),
497 });
498 }
499 }
500
501 for (slug, series) in &self.series {
502 if series.config.title.is_empty() {
503 errors.push(ValidationError {
504 path: format!("{}/series.toml", slug),
505 message: "Title cannot be empty".to_string(),
506 });
507 }
508 }
509
510 errors
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use tempfile::TempDir;
518
519 fn create_content_config(temp_dir: &TempDir) -> ContentConfig {
520 ContentConfig {
521 repo_path: temp_dir.path().to_path_buf(),
522 content_dir: "content".to_string(),
523 max_content_file_size: 5 * 1024 * 1024,
524 max_total_content_size: 100 * 1024 * 1024,
525 }
526 }
527
528 fn create_post_files(dir: &Path, title: &str, preview: &str, content: &str) {
529 fs::create_dir_all(dir).unwrap();
530 fs::write(
531 dir.join("config.toml"),
532 format!(
533 r#"title = "{}"
534preview_text = "{}"
535"#,
536 title, preview
537 ),
538 )
539 .unwrap();
540 fs::write(dir.join("content.mdx"), content).unwrap();
541 }
542
543 fn create_post_with_date(dir: &Path, title: &str, goes_live_at: Option<&str>) {
544 fs::create_dir_all(dir).unwrap();
545 let date_line = goes_live_at
546 .map(|d| format!("goes_live_at = \"{}\"", d))
547 .unwrap_or_default();
548 fs::write(
549 dir.join("config.toml"),
550 format!(
551 r#"title = "{}"
552preview_text = "Preview"
553{}
554"#,
555 title, date_line
556 ),
557 )
558 .unwrap();
559 fs::write(dir.join("content.mdx"), "# Content").unwrap();
560 }
561
562 #[test]
563 fn test_load_empty_content() {
564 let temp_dir = TempDir::new().unwrap();
565 let config = create_content_config(&temp_dir);
566
567 let cache = ContentCache::load(&config).unwrap();
568
569 assert!(cache.posts.is_empty());
570 assert!(cache.series.is_empty());
571 }
572
573 #[test]
574 fn test_load_single_post() {
575 let temp_dir = TempDir::new().unwrap();
576 let content_dir = temp_dir.path().join("content");
577 let post_dir = content_dir.join("my-post");
578
579 create_post_files(&post_dir, "My Title", "Preview text", "# Hello World");
580
581 let config = create_content_config(&temp_dir);
582 let cache = ContentCache::load(&config).unwrap();
583
584 assert_eq!(cache.posts.len(), 1);
585 let post = cache.posts.get("my-post").unwrap();
586 assert_eq!(post.slug, "my-post");
587 assert_eq!(post.title, "My Title");
588 assert_eq!(post.preview_text, "Preview text");
589 assert_eq!(post.content, "# Hello World");
590 assert!(post.series_slug.is_none());
591 }
592
593 #[test]
594 fn test_load_series_with_posts() {
595 let temp_dir = TempDir::new().unwrap();
596 let content_dir = temp_dir.path().join("content");
597 let series_dir = content_dir.join("my-series");
598
599 fs::create_dir_all(&series_dir).unwrap();
600 fs::write(
601 series_dir.join("series.toml"),
602 r#"title = "My Series"
603description = "A test series"
604"#,
605 )
606 .unwrap();
607
608 let post1_dir = series_dir.join("part-one");
609 fs::create_dir_all(&post1_dir).unwrap();
610 fs::write(
611 post1_dir.join("config.toml"),
612 r#"title = "Part One"
613preview_text = "First part"
614order = 1
615"#,
616 )
617 .unwrap();
618 fs::write(post1_dir.join("content.mdx"), "# Part 1").unwrap();
619
620 let post2_dir = series_dir.join("part-two");
621 fs::create_dir_all(&post2_dir).unwrap();
622 fs::write(
623 post2_dir.join("config.toml"),
624 r#"title = "Part Two"
625preview_text = "Second part"
626order = 2
627"#,
628 )
629 .unwrap();
630 fs::write(post2_dir.join("content.mdx"), "# Part 2").unwrap();
631
632 let config = create_content_config(&temp_dir);
633 let cache = ContentCache::load(&config).unwrap();
634
635 assert_eq!(cache.series.len(), 1);
636 assert_eq!(cache.posts.len(), 2);
637
638 let series = cache.get_series("my-series").unwrap().unwrap();
639 assert_eq!(series.title, "My Series");
640 assert_eq!(series.posts.len(), 2);
641 assert_eq!(series.posts[0].slug, "part-one");
642 assert_eq!(series.posts[1].slug, "part-two");
643
644 let post = cache.posts.get("part-one").unwrap();
646 assert_eq!(post.series_slug, Some("my-series".to_string()));
647 }
648
649 #[test]
650 fn test_series_ordering() {
651 let temp_dir = TempDir::new().unwrap();
652 let content_dir = temp_dir.path().join("content");
653 let series_dir = content_dir.join("ordered-series");
654
655 fs::create_dir_all(&series_dir).unwrap();
656 fs::write(series_dir.join("series.toml"), "title = \"Ordered\"").unwrap();
657
658 for (name, order) in [("zebra", 1), ("apple", 3), ("middle", 2)] {
660 let post_dir = series_dir.join(name);
661 fs::create_dir_all(&post_dir).unwrap();
662 fs::write(
663 post_dir.join("config.toml"),
664 format!(
665 r#"title = "{}"
666preview_text = "test"
667order = {}
668"#,
669 name, order
670 ),
671 )
672 .unwrap();
673 fs::write(post_dir.join("content.mdx"), "content").unwrap();
674 }
675
676 let config = create_content_config(&temp_dir);
677 let cache = ContentCache::load(&config).unwrap();
678 let series = cache.get_series("ordered-series").unwrap().unwrap();
679
680 assert_eq!(series.posts[0].slug, "zebra"); assert_eq!(series.posts[1].slug, "middle"); assert_eq!(series.posts[2].slug, "apple"); }
684
685 #[test]
686 fn test_visibility_filtering_live() {
687 let temp_dir = TempDir::new().unwrap();
688 let content_dir = temp_dir.path().join("content");
689
690 create_post_with_date(
692 &content_dir.join("live-post"),
693 "Live",
694 Some("2020-01-01T00:00:00Z"),
695 );
696
697 let config = create_content_config(&temp_dir);
698 let cache = ContentCache::load(&config).unwrap();
699
700 let opts = ListOptions::default();
702 let result = cache.list_posts(&opts).unwrap();
703 assert_eq!(result.items.len(), 1);
704 assert_eq!(result.items[0].title, "Live");
705 }
706
707 #[test]
708 fn test_visibility_filtering_drafts() {
709 let temp_dir = TempDir::new().unwrap();
710 let content_dir = temp_dir.path().join("content");
711
712 create_post_with_date(&content_dir.join("draft-post"), "Draft", None);
714
715 let config = create_content_config(&temp_dir);
716 let cache = ContentCache::load(&config).unwrap();
717
718 let opts = ListOptions::default();
720 let result = cache.list_posts(&opts).unwrap();
721 assert_eq!(result.items.len(), 0);
722
723 let opts = ListOptions {
725 include_drafts: true,
726 ..Default::default()
727 };
728 let result = cache.list_posts(&opts).unwrap();
729 assert_eq!(result.items.len(), 1);
730 }
731
732 #[test]
733 fn test_visibility_filtering_scheduled() {
734 let temp_dir = TempDir::new().unwrap();
735 let content_dir = temp_dir.path().join("content");
736
737 create_post_with_date(
739 &content_dir.join("scheduled-post"),
740 "Scheduled",
741 Some("2099-01-01T00:00:00Z"),
742 );
743
744 let config = create_content_config(&temp_dir);
745 let cache = ContentCache::load(&config).unwrap();
746
747 let opts = ListOptions::default();
749 let result = cache.list_posts(&opts).unwrap();
750 assert_eq!(result.items.len(), 0);
751
752 let opts = ListOptions {
754 include_scheduled: true,
755 ..Default::default()
756 };
757 let result = cache.list_posts(&opts).unwrap();
758 assert_eq!(result.items.len(), 1);
759 }
760
761 #[test]
762 fn test_pagination() {
763 let temp_dir = TempDir::new().unwrap();
764 let content_dir = temp_dir.path().join("content");
765
766 for i in 1..=5 {
768 create_post_with_date(
769 &content_dir.join(format!("post-{}", i)),
770 &format!("Post {}", i),
771 Some("2020-01-01T00:00:00Z"),
772 );
773 }
774
775 let config = create_content_config(&temp_dir);
776 let cache = ContentCache::load(&config).unwrap();
777
778 let opts = ListOptions {
780 limit: Some(2),
781 ..Default::default()
782 };
783 let result = cache.list_posts(&opts).unwrap();
784 assert_eq!(result.items.len(), 2);
785 assert_eq!(result.total, 5);
786 assert_eq!(result.limit, 2);
787 assert_eq!(result.offset, 0);
788
789 let opts = ListOptions {
791 limit: Some(2),
792 offset: Some(2),
793 ..Default::default()
794 };
795 let result = cache.list_posts(&opts).unwrap();
796 assert_eq!(result.items.len(), 2);
797 assert_eq!(result.offset, 2);
798 }
799
800 #[test]
801 fn test_etag_changes_with_content() {
802 let temp_dir = TempDir::new().unwrap();
803 let content_dir = temp_dir.path().join("content");
804 let post_dir = content_dir.join("my-post");
805
806 create_post_files(&post_dir, "Title", "Preview", "Content v1");
807
808 let config = create_content_config(&temp_dir);
809 let cache1 = ContentCache::load(&config).unwrap();
810 let etag1 = cache1.etag();
811
812 fs::write(post_dir.join("content.mdx"), "Content v2").unwrap();
814 let cache2 = ContentCache::load(&config).unwrap();
815 let etag2 = cache2.etag();
816
817 assert_ne!(etag1, etag2);
818 }
819
820 #[test]
821 fn test_validation_empty_title() {
822 let temp_dir = TempDir::new().unwrap();
823 let content_dir = temp_dir.path().join("content");
824 let post_dir = content_dir.join("bad-post");
825
826 fs::create_dir_all(&post_dir).unwrap();
827 fs::write(
828 post_dir.join("config.toml"),
829 r#"title = ""
830preview_text = "Preview"
831"#,
832 )
833 .unwrap();
834 fs::write(post_dir.join("content.mdx"), "# Content").unwrap();
835
836 let config = create_content_config(&temp_dir);
837 let cache = ContentCache::load(&config).unwrap();
838 let errors = cache.validate();
839
840 assert!(!errors.is_empty());
841 assert!(errors.iter().any(|e| e.message.contains("Title")));
842 }
843
844 #[test]
845 fn test_validation_empty_content() {
846 let temp_dir = TempDir::new().unwrap();
847 let content_dir = temp_dir.path().join("content");
848 let post_dir = content_dir.join("empty-content");
849
850 fs::create_dir_all(&post_dir).unwrap();
851 fs::write(
852 post_dir.join("config.toml"),
853 r#"title = "Title"
854preview_text = "Preview"
855"#,
856 )
857 .unwrap();
858 fs::write(post_dir.join("content.mdx"), "").unwrap();
859
860 let config = create_content_config(&temp_dir);
861 let cache = ContentCache::load(&config).unwrap();
862 let errors = cache.validate();
863
864 assert!(!errors.is_empty());
865 assert!(errors.iter().any(|e| e.message.contains("Content")));
866 }
867
868 #[test]
869 fn test_get_nonexistent_post() {
870 let temp_dir = TempDir::new().unwrap();
871 let config = create_content_config(&temp_dir);
872 let cache = ContentCache::load(&config).unwrap();
873
874 let result = cache.get_post("nonexistent").unwrap();
875 assert!(result.is_none());
876 }
877
878 #[test]
879 fn test_get_nonexistent_series() {
880 let temp_dir = TempDir::new().unwrap();
881 let config = create_content_config(&temp_dir);
882 let cache = ContentCache::load(&config).unwrap();
883
884 let result = cache.get_series("nonexistent").unwrap();
885 assert!(result.is_none());
886 }
887
888 #[cfg(unix)]
889 #[test]
890 fn test_symlink_directory_is_skipped() {
891 use std::os::unix::fs::symlink;
892
893 let temp_dir = TempDir::new().unwrap();
894 let content_dir = temp_dir.path().join("content");
895 fs::create_dir_all(&content_dir).unwrap();
896
897 create_post_files(
899 &content_dir.join("real-post"),
900 "Real Post",
901 "Preview",
902 "# Real content",
903 );
904
905 let target_dir = temp_dir.path().join("secret");
907 fs::create_dir_all(&target_dir).unwrap();
908 fs::write(
909 target_dir.join("config.toml"),
910 "title = \"Hacked\"\npreview_text = \"Evil\"",
911 )
912 .unwrap();
913 fs::write(target_dir.join("content.mdx"), "# Secrets here").unwrap();
914
915 symlink(&target_dir, content_dir.join("symlink-post")).unwrap();
916
917 let config = create_content_config(&temp_dir);
918 let cache = ContentCache::load(&config).unwrap();
919
920 assert_eq!(cache.posts.len(), 1);
922 assert!(cache.posts.contains_key("real-post"));
923 assert!(!cache.posts.contains_key("symlink-post"));
924 }
925
926 #[cfg(unix)]
927 #[test]
928 fn test_symlinked_content_file_is_rejected() {
929 use std::os::unix::fs::symlink;
930
931 let temp_dir = TempDir::new().unwrap();
932 let content_dir = temp_dir.path().join("content");
933 let post_dir = content_dir.join("evil-post");
934 fs::create_dir_all(&post_dir).unwrap();
935
936 fs::write(
938 post_dir.join("config.toml"),
939 "title = \"Evil\"\npreview_text = \"Preview\"",
940 )
941 .unwrap();
942
943 let secret_file = temp_dir.path().join("secret.txt");
944 fs::write(&secret_file, "SECRET_DATA").unwrap();
945 symlink(&secret_file, post_dir.join("content.mdx")).unwrap();
946
947 let config = create_content_config(&temp_dir);
948 let cache = ContentCache::load(&config).unwrap();
949
950 assert!(!cache.posts.contains_key("evil-post"));
952 }
953}