riley_cms_core/
content.rs

1//! Content parsing and caching for riley_cms
2
3use 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/// In-memory cache of parsed content
13#[derive(Debug)]
14pub struct ContentCache {
15    posts: HashMap<String, Post>,
16    series: HashMap<String, SeriesData>,
17    etag: String,
18}
19
20/// Internal series data with owned posts
21#[derive(Debug)]
22struct SeriesData {
23    slug: String,
24    config: SeriesConfig,
25    post_slugs: Vec<String>,
26}
27
28impl ContentCache {
29    /// Load content from disk into cache
30    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        // Iterate through content directory
47        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            // Security: DirEntry::file_type() does NOT follow symlinks,
59            // preventing symlink traversal attacks (e.g., content.mdx -> /etc/passwd).
60            // Note: A theoretical TOCTOU race exists between this check and subsequent
61            // file reads, but it requires local filesystem access during the microsecond
62            // window and is not exploitable via the git push interface alone.
63            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            // Check total content size limit before loading more
92            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            // Check if this is a series (has series.toml)
103            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                // Check if this is a post (has config.toml + content.mdx)
122                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    /// Read a file to string, rejecting files larger than max_size.
165    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    /// Check that a file is not a symlink (prevents traversal attacks).
181    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    /// Load a single post from a directory
193    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        // Security: reject symlinked files to prevent reading arbitrary system files
203        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    /// Load a series and its posts
229    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        // Load posts within the series
242        for entry in fs::read_dir(path)? {
243            let entry = entry?;
244            let post_path = entry.path();
245
246            // Security: reject symlinks to prevent traversal attacks
247            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        // Sort posts by order field, then alphabetically by slug
279        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    /// Helper to get post order
302    fn get_post_order(post: &Post) -> Option<i32> {
303        post.order
304    }
305
306    /// Compute ETag from content
307    fn compute_etag(posts: &HashMap<String, Post>, series: &HashMap<String, SeriesData>) -> String {
308        let mut hasher = Sha256::new();
309
310        // Sort keys for deterministic output.
311        // Length-prefix each field to prevent concatenation collisions
312        // (e.g., slug="ab" content="c" vs slug="a" content="bc").
313        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    /// Get ETag for HTTP caching
336    pub fn etag(&self) -> String {
337        self.etag.clone()
338    }
339
340    /// Maximum number of items returned in a single list request.
341    const MAX_PAGE_SIZE: usize = 500;
342
343    /// List posts with filtering and pagination
344    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        // Sort by goes_live_at descending (newest first), drafts at end
356        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    /// Get a single post by slug
380    pub fn get_post(&self, slug: &str) -> Result<Option<Post>> {
381        Ok(self.posts.get(slug).cloned())
382    }
383
384    /// List series with filtering and pagination
385    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        // Sort by goes_live_at descending
397        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    /// Get a single series by slug
430    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    /// Check if content is visible based on goes_live_at and options
464    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, // Live
473        }
474    }
475
476    /// Validate content structure
477    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        // Check posts have series_slug set
645        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        // Create posts with explicit ordering (not alphabetical)
659        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"); // order 1
681        assert_eq!(series.posts[1].slug, "middle"); // order 2
682        assert_eq!(series.posts[2].slug, "apple"); // order 3
683    }
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        // Live post (past date)
691        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        // Default options - should see live posts
701        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        // Draft post (no date)
713        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        // Default - no drafts
719        let opts = ListOptions::default();
720        let result = cache.list_posts(&opts).unwrap();
721        assert_eq!(result.items.len(), 0);
722
723        // With include_drafts
724        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        // Scheduled post (future date)
738        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        // Default - no scheduled
748        let opts = ListOptions::default();
749        let result = cache.list_posts(&opts).unwrap();
750        assert_eq!(result.items.len(), 0);
751
752        // With include_scheduled
753        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        // Create 5 live posts
767        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        // Limit to 2
779        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        // Offset 2, limit 2
790        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        // Modify content
813        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 a real post
898        create_post_files(
899            &content_dir.join("real-post"),
900            "Real Post",
901            "Preview",
902            "# Real content",
903        );
904
905        // Create a symlink directory pointing elsewhere
906        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        // Only the real post should be loaded, symlink is skipped
921        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        // Real config, but symlinked content file
937        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        // Post with symlinked content.mdx should not be loaded
951        assert!(!cache.posts.contains_key("evil-post"));
952    }
953}