1use anyhow::{Context, Result, bail};
83use std::collections::HashMap;
84use std::path::{Component, Path, PathBuf};
85
86const ALLOWED_EXTENSIONS: &[&str] = &["md", "txt", "json", "toml", "yaml", "yml"];
91
92pub const MAX_RENDER_DEPTH: usize = 10;
97
98pub fn validate_content_path(
161 path_str: &str,
162 project_dir: &Path,
163 max_size: Option<u64>,
164) -> Result<PathBuf> {
165 let path = Path::new(path_str);
167
168 if path.is_absolute() {
170 bail!(
171 "Absolute paths are not allowed in content filter. \
172 Path '{}' must be relative to project root.",
173 path_str
174 );
175 }
176
177 let mut components_count: i32 = 0;
180 for component in path.components() {
181 match component {
182 Component::Normal(_) => components_count += 1,
183 Component::ParentDir => {
184 components_count -= 1;
185 if components_count < 0 {
187 bail!(
188 "Path traversal outside project directory is not allowed. \
189 Path '{}' attempts to access parent directories beyond project root.",
190 path_str
191 );
192 }
193 }
194 Component::CurDir => {
195 }
197 _ => {
198 bail!("Invalid path component in '{}'. Only relative paths are allowed.", path_str);
200 }
201 }
202 }
203
204 let extension = path.extension().and_then(|ext| ext.to_str()).ok_or_else(|| {
206 anyhow::anyhow!(
207 "File '{}' has no extension. Allowed extensions: {}",
208 path_str,
209 ALLOWED_EXTENSIONS.join(", ")
210 )
211 })?;
212
213 let extension_lower = extension.to_lowercase();
214 if !ALLOWED_EXTENSIONS.contains(&extension_lower.as_str()) {
215 bail!(
216 "File extension '.{}' is not allowed. \
217 Allowed extensions: {}. \
218 Path: '{}'",
219 extension,
220 ALLOWED_EXTENSIONS.join(", "),
221 path_str
222 );
223 }
224
225 let full_path = project_dir.join(path);
227
228 if !full_path.exists() {
230 bail!(
231 "File not found: '{}'. \
232 The content filter requires files to exist. \
233 Full path attempted: {}",
234 path_str,
235 full_path.display()
236 );
237 }
238
239 if !full_path.is_file() {
241 bail!(
242 "Path '{}' is not a regular file. \
243 The content filter only works with files, not directories or special files.",
244 path_str
245 );
246 }
247
248 let canonical_path = full_path
250 .canonicalize()
251 .with_context(|| format!("Failed to canonicalize path: {}", full_path.display()))?;
252
253 let canonical_project = project_dir.canonicalize().with_context(|| {
254 format!("Failed to canonicalize project directory: {}", project_dir.display())
255 })?;
256
257 if !canonical_path.starts_with(&canonical_project) {
259 bail!(
260 "Security violation: Path '{}' resolves to '{}' which is outside project directory '{}'",
261 path_str,
262 canonical_path.display(),
263 canonical_project.display()
264 );
265 }
266
267 if let Some(max_bytes) = max_size {
269 let metadata = canonical_path.metadata().with_context(|| {
270 format!("Failed to read file metadata: {}", canonical_path.display())
271 })?;
272
273 let file_size = metadata.len();
274 if file_size > max_bytes {
275 bail!(
276 "File '{}' is too large ({} bytes). Maximum allowed size: {} bytes ({:.2} MB vs {:.2} MB limit).",
277 path_str,
278 file_size,
279 max_bytes,
280 file_size as f64 / (1024.0 * 1024.0),
281 max_bytes as f64 / (1024.0 * 1024.0)
282 );
283 }
284 }
285
286 Ok(canonical_path)
287}
288
289pub fn read_and_process_content(file_path: &Path) -> Result<String> {
325 let content = std::fs::read_to_string(file_path).with_context(|| {
327 format!(
328 "Failed to read project file: {}. \
329 Ensure the file is readable and contains valid UTF-8.",
330 file_path.display()
331 )
332 })?;
333
334 let extension = file_path
336 .extension()
337 .and_then(|ext| ext.to_str())
338 .map(|s| s.to_lowercase())
339 .unwrap_or_default();
340
341 let processed_content = match extension.as_str() {
342 "md" => {
343 match crate::markdown::MarkdownDocument::parse(&content) {
345 Ok(doc) => doc.content,
346 Err(e) => {
347 tracing::warn!(
348 "Failed to parse markdown file '{}': {}. Using raw content.",
349 file_path.display(),
350 e
351 );
352 content
353 }
354 }
355 }
356 "json" => {
357 match serde_json::from_str::<serde_json::Value>(&content) {
359 Ok(json) => serde_json::to_string_pretty(&json).unwrap_or(content),
360 Err(e) => {
361 tracing::warn!(
362 "Failed to parse JSON file '{}': {}. Using raw content.",
363 file_path.display(),
364 e
365 );
366 content
367 }
368 }
369 }
370 _ => {
371 content
373 }
374 };
375
376 Ok(processed_content)
377}
378
379pub fn create_content_filter(
424 project_dir: PathBuf,
425 max_size: Option<u64>,
426) -> impl tera::Filter + 'static {
427 move |value: &tera::Value, _args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
428 let path_str = value
430 .as_str()
431 .ok_or_else(|| tera::Error::msg("content filter requires a string path"))?;
432
433 let file_path = validate_content_path(path_str, &project_dir, max_size)
435 .map_err(|e| tera::Error::msg(format!("content filter error: {}", e)))?;
436
437 let content = read_and_process_content(&file_path)
438 .map_err(|e| tera::Error::msg(format!("content filter error: {}", e)))?;
439
440 Ok(tera::Value::String(content))
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use std::fs;
449 use tempfile::TempDir;
450
451 fn create_test_project() -> TempDir {
452 let temp = TempDir::new().unwrap();
453 let project_dir = temp.path();
454
455 fs::create_dir_all(project_dir.join("docs")).unwrap();
457 fs::create_dir_all(project_dir.join("project")).unwrap();
458
459 fs::write(project_dir.join("docs/guide.md"), "# Guide\n\nContent here").unwrap();
461 fs::write(project_dir.join("docs/notes.txt"), "Plain text notes").unwrap();
462 fs::write(project_dir.join("project/config.json"), r#"{"key": "value"}"#).unwrap();
463
464 fs::write(
466 project_dir.join("docs/with-frontmatter.md"),
467 "---\ntitle: Test\n---\n\n# Content",
468 )
469 .unwrap();
470
471 temp
472 }
473
474 #[test]
475 fn test_validate_valid_path() {
476 let temp = create_test_project();
477 let project_dir = temp.path();
478
479 let result = validate_content_path("docs/guide.md", project_dir, None);
480 assert!(result.is_ok());
481
482 let path = result.unwrap();
483 assert!(path.ends_with("docs/guide.md"));
484 assert!(path.is_absolute());
485 }
486
487 #[test]
488 fn test_validate_rejects_absolute_path() {
489 let temp = create_test_project();
490 let project_dir = temp.path();
491
492 #[cfg(windows)]
494 let absolute_path = "C:\\Windows\\System32\\config";
495 #[cfg(not(windows))]
496 let absolute_path = "/etc/passwd";
497
498 let result = validate_content_path(absolute_path, project_dir, None);
499 assert!(result.is_err());
500 assert!(result.unwrap_err().to_string().contains("Absolute paths"));
501 }
502
503 #[test]
504 fn test_validate_rejects_traversal() {
505 let temp = create_test_project();
506 let project_dir = temp.path();
507
508 let result = validate_content_path("../../etc/passwd", project_dir, None);
509 assert!(result.is_err());
510 assert!(result.unwrap_err().to_string().contains("traversal"));
511 }
512
513 #[test]
514 fn test_validate_rejects_invalid_extension() {
515 let temp = create_test_project();
516 let project_dir = temp.path();
517
518 fs::write(project_dir.join("script.sh"), "#!/bin/bash").unwrap();
520
521 let result = validate_content_path("script.sh", project_dir, None);
522 assert!(result.is_err());
523 assert!(result.unwrap_err().to_string().contains("not allowed"));
524 }
525
526 #[test]
527 fn test_validate_rejects_missing_file() {
528 let temp = create_test_project();
529 let project_dir = temp.path();
530
531 let result = validate_content_path("docs/missing.md", project_dir, None);
532 assert!(result.is_err());
533 assert!(result.unwrap_err().to_string().contains("not found"));
534 }
535
536 #[test]
537 fn test_validate_rejects_file_too_large() {
538 let temp = create_test_project();
539 let project_dir = temp.path();
540
541 let large_file = project_dir.join("large.md");
543 fs::write(&large_file, "a".repeat(1000)).unwrap();
544
545 let result = validate_content_path("large.md", project_dir, Some(1001));
547 assert!(result.is_ok());
548
549 let result = validate_content_path("large.md", project_dir, Some(999));
551 assert!(result.is_err());
552 let err_msg = result.unwrap_err().to_string();
553 assert!(err_msg.contains("too large"));
554 assert!(err_msg.contains("1000 bytes"));
555 assert!(err_msg.contains("999 bytes"));
556 }
557
558 #[test]
559 fn test_read_markdown_strips_frontmatter() {
560 let temp = create_test_project();
561 let project_dir = temp.path();
562
563 let path = project_dir.join("docs/with-frontmatter.md");
564 let content = read_and_process_content(&path).unwrap();
565
566 assert!(!content.contains("---"));
567 assert!(!content.contains("title: Test"));
568 assert!(content.contains("# Content"));
569 }
570
571 #[test]
572 fn test_read_json_pretty_prints() {
573 let temp = create_test_project();
574 let project_dir = temp.path();
575
576 let path = project_dir.join("project/config.json");
577 let content = read_and_process_content(&path).unwrap();
578
579 assert!(content.contains('\n'));
581 assert!(content.contains("\"key\""));
582 assert!(content.contains("\"value\""));
583 }
584
585 #[test]
586 fn test_read_text_returns_raw() {
587 let temp = create_test_project();
588 let project_dir = temp.path();
589
590 let path = project_dir.join("docs/notes.txt");
591 let content = read_and_process_content(&path).unwrap();
592
593 assert_eq!(content, "Plain text notes");
594 }
595
596 #[test]
597 fn test_filter_function() {
598 use tera::Tera;
599
600 let temp = create_test_project();
601 let project_dir = temp.path().to_path_buf();
602
603 let mut tera = Tera::default();
605 tera.register_filter("content", create_content_filter(project_dir, None));
606
607 let template = r#"{{ 'docs/guide.md' | content }}"#;
609 let context = tera::Context::new();
610
611 let result = tera.render_str(template, &context);
612 assert!(result.is_ok(), "Filter should render successfully");
613
614 let content = result.unwrap();
615 assert!(content.contains("# Guide"));
616 assert!(content.contains("Content here"));
617 }
618
619 #[test]
620 fn test_filter_rejects_non_string() {
621 use tera::Tera;
622
623 let temp = create_test_project();
624 let project_dir = temp.path().to_path_buf();
625
626 let mut tera = Tera::default();
628 tera.register_filter("content", create_content_filter(project_dir, None));
629
630 let template = r#"{{ 42 | content }}"#;
632 let context = tera::Context::new();
633
634 let result = tera.render_str(template, &context);
635 assert!(result.is_err(), "Filter should reject non-string values");
637 }
638
639 #[test]
640 fn test_recursive_template_rendering() {
641 }
644}
645
646#[cfg(test)]
648mod recursive_tests {
649 use std::fs;
650
651 #[test]
652 fn test_two_level_recursion() {
653 use crate::templating::TemplateRenderer;
654 use tempfile::TempDir;
655 use tera::Context;
656
657 let temp = TempDir::new().unwrap();
659 let project_dir = temp.path();
660
661 fs::create_dir_all(project_dir.join("docs")).unwrap();
662
663 fs::write(
665 project_dir.join("docs/level1.md"),
666 "# Level 1\n{{ 'docs/level2.md' | content }}",
667 )
668 .unwrap();
669
670 fs::write(project_dir.join("docs/level2.md"), "Content from level 2").unwrap();
672
673 let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
675 let context = Context::new();
676
677 let template = "{{ 'docs/level1.md' | content }}";
678 let result = renderer.render_template(template, &context);
679
680 assert!(result.is_ok(), "Two-level recursion should succeed");
681 let content = result.unwrap();
682 assert!(content.contains("# Level 1"));
683 assert!(content.contains("Content from level 2"));
684 assert!(!content.contains("{{"), "No template syntax should remain");
685 }
686
687 #[test]
688 fn test_three_level_recursion() {
689 use crate::templating::TemplateRenderer;
690 use tempfile::TempDir;
691 use tera::Context;
692
693 let temp = TempDir::new().unwrap();
694 let project_dir = temp.path();
695
696 fs::create_dir_all(project_dir.join("docs")).unwrap();
697
698 fs::write(project_dir.join("docs/level1.md"), "L1: {{ 'docs/level2.md' | content }}")
700 .unwrap();
701
702 fs::write(project_dir.join("docs/level2.md"), "L2: {{ 'docs/level3.md' | content }}")
703 .unwrap();
704
705 fs::write(project_dir.join("docs/level3.md"), "L3: Final").unwrap();
706
707 let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
708 let context = Context::new();
709
710 let template = "{{ 'docs/level1.md' | content }}";
711 let result = renderer.render_template(template, &context);
712
713 assert!(result.is_ok(), "Three-level recursion should succeed");
714 let content = result.unwrap();
715 assert!(content.contains("L1:"));
716 assert!(content.contains("L2:"));
717 assert!(content.contains("L3: Final"));
718 }
719
720 #[test]
721 fn test_depth_limit_exceeded() {
722 use crate::templating::TemplateRenderer;
723 use tempfile::TempDir;
724 use tera::Context;
725
726 let temp = TempDir::new().unwrap();
727 let project_dir = temp.path();
728
729 fs::create_dir_all(project_dir.join("docs")).unwrap();
730
731 fs::write(project_dir.join("docs/loop.md"), "Loop: {{ 'docs/loop.md' | content }}")
733 .unwrap();
734
735 let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
736 let context = Context::new();
737
738 let template = "{{ 'docs/loop.md' | content }}";
739 let result = renderer.render_template(template, &context);
740
741 assert!(result.is_err(), "Circular reference should cause error");
742 let err = result.unwrap_err().to_string();
743 assert!(
744 err.contains("maximum recursion depth") || err.contains("depth"),
745 "Error should mention depth limit. Got: {}",
746 err
747 );
748 }
749
750 #[test]
751 fn test_multiple_file_references_same_level() {
752 use crate::templating::TemplateRenderer;
753 use tempfile::TempDir;
754 use tera::Context;
755
756 let temp = TempDir::new().unwrap();
757 let project_dir = temp.path();
758
759 fs::create_dir_all(project_dir.join("docs")).unwrap();
760
761 fs::write(
763 project_dir.join("docs/main.md"),
764 "# Main\n\n{{ 'docs/part1.md' | content }}\n\n{{ 'docs/part2.md' | content }}",
765 )
766 .unwrap();
767
768 fs::write(project_dir.join("docs/part1.md"), "Part 1 content").unwrap();
769 fs::write(project_dir.join("docs/part2.md"), "Part 2 content").unwrap();
770
771 let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
772 let context = Context::new();
773
774 let template = "{{ 'docs/main.md' | content }}";
775 let result = renderer.render_template(template, &context);
776
777 assert!(result.is_ok(), "Multiple file references should succeed");
778 let content = result.unwrap();
779 assert!(content.contains("# Main"));
780 assert!(content.contains("Part 1 content"));
781 assert!(content.contains("Part 2 content"));
782 }
783}