1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![doc(
4 html_favicon_url = "https://kura.pro/frontmatter-gen/images/favicon.ico",
5 html_logo_url = "https://cloudcdn.pro/clients/frontmatter-gen/v1/logos/frontmatter-gen.svg",
6 html_root_url = "https://docs.rs/frontmatter-gen"
7)]
8
9use std::num::NonZeroUsize;
66
67pub use crate::{
69 config::Config,
70 error::Error,
71 extractor::{detect_format, extract_raw_frontmatter},
72 parser::{parse, to_string},
73 types::{Format, Frontmatter, Value},
74};
75
76#[cfg(feature = "cli")]
78pub mod cli;
79pub mod config;
80pub mod engine;
81pub mod error;
82pub mod extractor;
83pub mod parser;
84#[cfg(feature = "ssg")]
85pub mod ssg;
86pub mod types;
87pub mod utils;
88
89pub const MAX_FRONTMATTER_SIZE: NonZeroUsize =
91 match NonZeroUsize::new(1024 * 1024) {
92 Some(val) => val,
93 None => unreachable!(),
94 };
95
96pub const MAX_NESTING_DEPTH: NonZeroUsize = match NonZeroUsize::new(32)
100{
101 Some(val) => val,
102 None => unreachable!(),
103};
104
105pub type Result<T> = std::result::Result<T, Error>;
110
111pub mod prelude {
116 pub use crate::{
117 extract, to_format, Config, Error, Format, Frontmatter, Result,
118 Value,
119 };
120}
121
122#[derive(Debug, Clone, Copy)]
126pub struct ParseOptions {
127 pub max_size: NonZeroUsize,
129 pub max_depth: NonZeroUsize,
131 pub validate: bool,
133}
134
135impl Default for ParseOptions {
136 fn default() -> Self {
137 Self {
138 max_size: MAX_FRONTMATTER_SIZE,
139 max_depth: MAX_NESTING_DEPTH,
140 validate: true,
141 }
142 }
143}
144
145const ERR_PATH_TRAVERSAL: &str =
146 "Content contains path traversal patterns";
147const ERR_NULL_BYTES: &str = "Content contains null bytes";
148
149pub fn validate_input(
174 content: &str,
175 options: &ParseOptions,
176) -> Result<()> {
177 let mut inside_fenced_code = false;
178
179 for line in content.lines() {
180 let trimmed = line.trim_start();
181 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
182 inside_fenced_code = !inside_fenced_code;
183 } else if !inside_fenced_code {
184 if line.contains("../") || line.contains("..\\") {
186 log::warn!("Potential path traversal detected: {line}");
190 let msg = ERR_PATH_TRAVERSAL.to_string();
191 return Err(Error::ValidationError(msg));
192 }
193
194 if line.contains('\0') {
196 log::warn!("Null byte detected in content");
197 let msg = ERR_NULL_BYTES.to_string();
199 return Err(Error::ValidationError(msg));
200 }
201 }
202 }
203
204 let size = content.len();
206 let max = options.max_size.get();
207 if size > max {
208 log::warn!("Content exceeds maximum size: {size} > {max}");
209 return Err(Error::ContentTooLarge { size, max });
210 }
211
212 Ok(())
213}
214
215pub fn extract(content: &str) -> Result<(Frontmatter, &str)> {
261 let options = ParseOptions::from_env();
262 validate_input(content, &options)?;
263
264 let (raw_frontmatter, remaining_content) =
265 extract_raw_frontmatter(content)?;
266 let format = detect_format(raw_frontmatter)?;
267 let frontmatter = parse(raw_frontmatter, format)?;
268
269 Ok((frontmatter, remaining_content))
270}
271
272pub fn to_format(
306 frontmatter: &Frontmatter,
307 format: Format,
308) -> Result<String> {
309 to_string(frontmatter, format)
310}
311
312impl ParseOptions {
313 pub fn from_env() -> Self {
333 let max_size = std::env::var("MAX_FRONTMATTER_SIZE")
334 .ok()
335 .and_then(|val| val.parse::<NonZeroUsize>().ok())
336 .unwrap_or(MAX_FRONTMATTER_SIZE);
337
338 let max_depth = std::env::var("MAX_NESTING_DEPTH")
339 .ok()
340 .and_then(|val| val.parse::<NonZeroUsize>().ok())
341 .unwrap_or(MAX_NESTING_DEPTH);
342
343 Self {
344 max_size,
345 max_depth,
346 validate: std::env::var("VALIDATE_STRUCTURE")
347 .map_or(true, |val| val.eq_ignore_ascii_case("true")),
348 }
349 }
350}
351
352#[cfg(test)]
353mod extractor_tests {
354 use crate::Error;
355
356 fn mock_operation(input: Option<&str>) -> Result<String, Error> {
357 match input {
358 Some(value) => Ok(value.to_uppercase()), None => {
360 Err(Error::ParseError("Input is missing".to_string()))
361 }
362 }
363 }
364
365 #[test]
366 fn test_result_type_success() {
367 let input = Some("hello");
368 let result = mock_operation(input);
369 assert!(result.is_ok());
370 assert_eq!(result.unwrap(), "HELLO".to_string());
371 }
372
373 #[test]
374 fn test_result_type_error() {
375 let input = None;
376 let result = mock_operation(input);
377 assert!(matches!(
378 result,
379 Err(Error::ParseError(ref e)) if e == "Input is missing"
380 ));
381 }
382
383 #[test]
384 fn test_result_type_pattern_matching() {
385 let input = Some("world");
386 let result = mock_operation(input);
387 assert_eq!(result.ok(), Some("WORLD".to_string()));
388 }
389
390 #[test]
391 fn test_result_type_unwrap() {
392 let input = Some("rust");
393 let result = mock_operation(input);
394 assert_eq!(result.unwrap(), "RUST".to_string());
395 }
396
397 #[test]
398 fn test_result_type_expect() {
399 let input = Some("test");
400 let result = mock_operation(input);
401 assert_eq!(
402 result.expect("Unexpected error"),
403 "TEST".to_string()
404 );
405 }
406
407 #[test]
408 fn test_result_type_debug_format() {
409 let input = None;
410 let result = mock_operation(input);
411 assert_eq!(
412 format!("{:?}", result),
413 "Err(ParseError(\"Input is missing\"))"
414 );
415 }
416}
417
418#[cfg(test)]
419mod parser_tests {
420 use super::*;
421
422 #[test]
423 fn test_parse_yaml_frontmatter() {
424 let raw = "title: Test Post\npublished: true";
425 let format = Format::Yaml;
426 let parsed = parse(raw, format).unwrap();
427 assert_eq!(
428 parsed.get("title").unwrap().as_str().unwrap(),
429 "Test Post"
430 );
431 assert!(parsed.get("published").unwrap().as_bool().unwrap());
432 }
433
434 #[test]
435 fn test_parse_toml_frontmatter() {
436 let raw = "title = \"Test Post\"\npublished = true";
437 let format = Format::Toml;
438 let parsed = parse(raw, format).unwrap();
439 assert_eq!(
440 parsed.get("title").unwrap().as_str().unwrap(),
441 "Test Post"
442 );
443 assert!(parsed.get("published").unwrap().as_bool().unwrap());
444 }
445
446 #[test]
447 fn test_invalid_yaml_syntax() {
448 let raw = "{ key: value, ";
449 let format = Format::Yaml;
450 let result = parse(raw, format);
451 assert!(result.is_err());
452 }
453
454 #[test]
455 fn test_parse_invalid_toml_syntax() {
456 let raw = "title = \"Unmatched quote";
457 let format = Format::Toml;
458 let result = parse(raw, format);
459 assert!(result.is_err(), "Should fail for invalid TOML syntax");
460 }
461
462 #[test]
463 fn test_parse_invalid_json_syntax() {
464 let raw = "{\"title\": \"Missing closing brace\"";
465 let format = Format::Json;
466 let result = parse(raw, format);
467 assert!(result.is_err(), "Should fail for invalid JSON syntax");
468 }
469
470 #[test]
471 fn test_parse_with_unknown_format() {
472 let raw = "random text";
473 let format = Format::Unsupported;
474 let result = parse(raw, format);
475 assert!(result.is_err(), "Should fail for unsupported formats");
476 }
477
478 #[test]
479 fn test_parse_valid_yaml() {
480 let raw = "title: Valid Post\npublished: true";
481 let format = Format::Yaml;
482 let frontmatter = parse(raw, format).unwrap();
483 assert_eq!(
484 frontmatter.get("title").unwrap().as_str().unwrap(),
485 "Valid Post"
486 );
487 assert!(frontmatter
488 .get("published")
489 .unwrap()
490 .as_bool()
491 .unwrap());
492 }
493
494 #[test]
495 fn test_parse_malformed_yaml() {
496 let raw = "{ unclosed: mapping, ";
497 let format = Format::Yaml;
498 let result = parse(raw, format);
499 assert!(result.is_err(), "Should fail for malformed YAML");
500 }
501
502 #[test]
503 fn test_parse_json() {
504 let raw = r#"{"title": "Valid Post", "draft": false}"#;
505 let format = Format::Json;
506 let frontmatter = parse(raw, format).unwrap();
507 assert_eq!(
508 frontmatter.get("title").unwrap().as_str().unwrap(),
509 "Valid Post"
510 );
511 assert!(!frontmatter.get("draft").unwrap().as_bool().unwrap());
512 }
513}
514
515#[cfg(test)]
516mod format_tests {
517 use super::*;
518
519 #[test]
520 fn test_to_format_yaml() {
521 let mut frontmatter = Frontmatter::new();
522 let _ = frontmatter.insert(
523 "title".to_string(),
524 Value::String("Test Post".to_string()),
525 );
526 let yaml = to_format(&frontmatter, Format::Yaml).unwrap();
527 assert!(yaml.contains("title: Test Post"));
528 }
529
530 #[test]
531 fn test_format_conversion_roundtrip() {
532 let mut frontmatter = Frontmatter::new();
533 let _ = frontmatter.insert(
534 "key".to_string(),
535 Value::String("value".to_string()),
536 );
537 let yaml = to_format(&frontmatter, Format::Yaml).unwrap();
538 let content = format!("---\n{}\n---\nContent", yaml);
539 let (parsed, _) = extract(&content).unwrap();
540 assert_eq!(
541 parsed.get("key").unwrap().as_str().unwrap(),
542 "value"
543 );
544 }
545
546 #[test]
547 fn test_unsupported_format() {
548 let result =
549 to_format(&Frontmatter::new(), Format::Unsupported);
550 assert!(result.is_err());
551 }
552
553 #[test]
554 fn test_convert_to_yaml() {
555 let mut frontmatter = Frontmatter::new();
556 let _ = frontmatter.insert(
557 "title".to_string(),
558 Value::String("Test Post".into()),
559 );
560 let yaml = to_format(&frontmatter, Format::Yaml).unwrap();
561 assert!(yaml.contains("title: Test Post"));
562 }
563
564 #[test]
565 fn test_roundtrip_conversion() {
566 let content = "---\ntitle: Test Post\n---\nContent";
567 let (parsed, _) = extract(content).unwrap();
568 let yaml = to_format(&parsed, Format::Yaml).unwrap();
569 assert!(yaml.contains("title: Test Post"));
570 }
571
572 #[test]
573 fn test_format_invalid_data() {
574 let frontmatter = Frontmatter::new();
575 let result = to_format(&frontmatter, Format::Unsupported);
576 assert!(result.is_err());
577 }
578}
579
580#[cfg(test)]
581mod integration_tests {
582 use super::*;
583
584 #[test]
585 fn test_end_to_end_extraction_and_parsing() {
586 let content = "---\ntitle: Test Post\n---\nContent here";
587 let (frontmatter, content) = extract(content).unwrap();
588 assert_eq!(
589 frontmatter.get("title").unwrap().as_str().unwrap(),
590 "Test Post"
591 );
592 assert_eq!(content.trim(), "Content here");
593 }
594
595 #[test]
596 fn test_roundtrip_conversion() {
597 let content = "---\ntitle: Test Post\n---\nContent";
598 let (frontmatter, _) = extract(content).unwrap();
599 let yaml = to_format(&frontmatter, Format::Yaml).unwrap();
600 assert!(yaml.contains("title: Test Post"));
601 }
602
603 #[test]
604 fn test_complete_workflow() {
605 let content = "---\ntitle: Integration Test\n---\nBody content";
606 let (frontmatter, body) = extract(content).unwrap();
607 assert_eq!(
608 frontmatter.get("title").unwrap().as_str().unwrap(),
609 "Integration Test"
610 );
611 assert_eq!(body.trim(), "Body content");
612 }
613
614 #[test]
615 fn test_end_to_end_error_handling() {
616 let content = "Invalid frontmatter";
617 let result = extract(content);
618 assert!(result.is_err());
619 }
620}
621
622#[cfg(test)]
623mod edge_case_tests {
624 use super::*;
625
626 #[test]
627 fn test_special_characters_handling() {
628 let cases = vec![
629 (
630 "---\ntitle: \"Special: &chars\"\n---\nContent",
631 "Special: &chars",
632 ),
633 (
634 "---\ntitle: \"Another > test\"\n---\nContent",
635 "Another > test",
636 ),
637 ];
638
639 for (content, expected_title) in cases {
640 let (frontmatter, _) = extract(content).unwrap();
641 assert_eq!(
642 frontmatter.get("title").unwrap().as_str().unwrap(),
643 expected_title
644 );
645 }
646 }
647
648 #[cfg(feature = "ssg")]
649 #[tokio::test]
650 async fn test_async_extraction() {
651 let content = "---\ntitle: Async Test\n---\nContent";
652 let (frontmatter, body) = extract(content).unwrap();
653 assert_eq!(
654 frontmatter.get("title").unwrap().as_str().unwrap(),
655 "Async Test"
656 );
657 assert_eq!(body.trim(), "Content");
658 }
659
660 #[test]
661 fn test_large_frontmatter() {
662 let mut large_content = String::from("---\n");
663 for i in 0..1000 {
664 large_content
665 .push_str(&format!("key_{}: value_{}\n", i, i));
666 }
667 large_content.push_str("---\nContent");
668 let (frontmatter, content) = extract(&large_content).unwrap();
669 assert_eq!(frontmatter.len(), 1000);
670 assert_eq!(content.trim(), "Content");
671 }
672
673 #[test]
674 fn test_special_characters() {
675 let content =
676 "---\ntitle: \"Special & <characters>\"\n---\nContent";
677 let (frontmatter, _) = extract(content).unwrap();
678 assert_eq!(
679 frontmatter.get("title").unwrap().as_str().unwrap(),
680 "Special & <characters>"
681 );
682 }
683}
684
685#[cfg(test)]
686mod validate_input_tests {
687 use super::*;
688
689 #[test]
690 fn test_skip_validation_in_fenced_code_blocks() {
691 let options = ParseOptions::default();
692 let content = r#"
693 ---
694 title: Example
695 ---
696 ```
697 ../example/path
698 ```
699 Valid content here.
700 "#;
701
702 let result = validate_input(content, &options);
703 assert!(
704 result.is_ok(),
705 "Validation should skip fenced code blocks."
706 );
707 }
708
709 #[test]
710 fn test_detect_path_traversal_outside_code_blocks() {
711 let options = ParseOptions::default();
712 let content = r#"
713 ---
714 title: Example
715 ---
716 ../malicious/path
717 "#;
718
719 let result = validate_input(content, &options);
720 assert!(result.is_err(), "Validation should detect path traversal outside fenced code blocks.");
721 }
722
723 #[test]
724 fn test_validate_input_null_bytes() {
725 let options = ParseOptions::default();
726 let malicious_content = "title: Valid\0Post";
727 let result = validate_input(malicious_content, &options);
728 assert!(matches!(
729 result,
730 Err(Error::ValidationError(ref e)) if e == "Content contains null bytes"
731 ));
732 }
733
734 #[test]
735 fn test_validate_input_exceeds_max_size() {
736 let options = ParseOptions::default();
737 let oversized_content = "a".repeat(options.max_size.get() + 1);
738 let result = validate_input(&oversized_content, &options);
739 assert!(matches!(result, Err(Error::ContentTooLarge { .. })));
740 }
741
742 #[test]
743 fn test_validate_input_contains_null_bytes() {
744 let options = ParseOptions::default();
745 let malicious_content = "title: Valid\0Post";
746 let result = validate_input(malicious_content, &options);
747 assert!(matches!(
748 result,
749 Err(Error::ValidationError(ref e)) if e == "Content contains null bytes"
750 ));
751 }
752
753 #[test]
754 fn test_validate_input_path_traversal() {
755 let options = ParseOptions::default();
756 let malicious_content = "../malicious/path";
757 let result = validate_input(malicious_content, &options);
758 assert!(matches!(
759 result,
760 Err(Error::ValidationError(ref e)) if e == "Content contains path traversal patterns"
761 ));
762 }
763}
764
765#[cfg(test)]
766mod parse_options_tests {
767 use super::*;
768
769 #[test]
770 fn test_parse_options_default() {
771 let options = ParseOptions::default();
772 assert_eq!(options.max_size.get(), 1024 * 1024);
773 assert_eq!(options.max_depth.get(), 32);
774 assert!(options.validate);
775 }
776
777 #[test]
778 fn test_parse_options_from_env() {
779 std::env::set_var("MAX_FRONTMATTER_SIZE", "524288");
780 std::env::set_var("MAX_NESTING_DEPTH", "20");
781 std::env::set_var("VALIDATE_STRUCTURE", "false");
782
783 let options = ParseOptions::from_env();
784 assert_eq!(options.max_size.get(), 524288);
785 assert_eq!(options.max_depth.get(), 20);
786 assert!(!options.validate);
787
788 std::env::remove_var("MAX_FRONTMATTER_SIZE");
789 std::env::remove_var("MAX_NESTING_DEPTH");
790 std::env::remove_var("VALIDATE_STRUCTURE");
791 }
792}
793
794#[cfg(test)]
795mod size_limit_tests {
796 use super::*;
804 use std::num::NonZeroUsize;
805
806 #[test]
807 fn content_larger_than_max_size_is_rejected() {
808 log::set_max_level(log::LevelFilter::Trace);
809 let options = ParseOptions {
810 max_size: NonZeroUsize::new(16).expect("non-zero"),
811 ..ParseOptions::default()
812 };
813 let content = "---\ntitle: a very long title indeed\n---\nbody";
814 assert!(content.len() > 16);
815
816 let err = validate_input(content, &options)
817 .expect_err("oversized content is rejected");
818 assert!(
819 matches!(err, Error::ContentTooLarge { size, max } if size == content.len() && max == 16),
820 "the error names both the size and the limit: {err:?}"
821 );
822 }
823
824 #[test]
825 fn content_exactly_at_max_size_is_accepted() {
826 let content = "---\ntitle: T\n---\nbody";
827 let options = ParseOptions {
828 max_size: NonZeroUsize::new(content.len())
829 .expect("non-zero"),
830 ..ParseOptions::default()
831 };
832 validate_input(content, &options)
833 .expect("content exactly at the limit is accepted");
834 }
835}
836
837#[cfg(test)]
838extern crate alloc;
839
840#[cfg(test)]
841mod miri_and_core_symbiosis_tests {
842 mod core_kernel {
843 use crate::{
844 extractor::{detect_format, extract_raw_frontmatter},
845 parser::{
846 parse, parse_with_options, to_string,
847 validate_frontmatter, ParseOptions as ParserOptions,
848 },
849 Frontmatter, Value,
850 };
851 use alloc::{boxed::Box, string::String, vec::Vec};
852 use core::{fmt::Write as _, str};
853
854 pub(super) fn verify_core_pipeline(
855 input: &[u8],
856 max_depth: usize,
857 max_keys: usize,
858 ) -> crate::Result<Frontmatter> {
859 let content = str::from_utf8(input)
860 .map_err(|_| crate::Error::InvalidFormat)?;
861 let (raw, body) = extract_raw_frontmatter(content)?;
862 let doc_range = content.as_bytes().as_ptr_range();
863 assert!(doc_range.contains(&raw.as_bytes().as_ptr()));
864 assert!(doc_range.contains(&body.as_bytes().as_ptr()));
865 assert!(content.ends_with(body));
866
867 let fmt = detect_format(raw)?;
868 let opts = ParserOptions {
869 max_depth,
870 max_keys,
871 validate: true,
872 };
873 let fm: Frontmatter =
874 parse_with_options(raw, fmt, Some(opts))?;
875 validate_frontmatter(&fm, max_depth, max_keys)?;
876
877 let roundtrip: String = to_string(&fm, fmt)?;
878 let mut sink = String::new();
879 let _ = write!(&mut sink, "{roundtrip}");
880 assert!(!sink.is_empty());
881
882 let reparsed: Frontmatter = parse(&roundtrip, fmt)?;
883 let boxed: Box<Frontmatter> = Box::new(reparsed);
884 let keys: Vec<&String> =
885 boxed.iter().map(|(k, _)| k).collect();
886 assert_eq!(keys.len(), fm.len());
887 for (k, v) in fm.iter() {
888 let got = boxed.get(k);
889 assert_eq!(v.as_str(), got.and_then(Value::as_str));
890 assert_eq!(v.as_bool(), got.and_then(Value::as_bool));
891 }
892 Ok(*boxed)
893 }
894 }
895
896 use super::*;
897 use crate::error::{Context, EngineError};
898 use std::sync::Arc;
899 use std::thread;
900
901 #[test]
902 fn miri_zero_copy_provenance_and_core_alloc_symbiosis() {
903 let cases: [&[u8]; 3] = [
904 "---\ntitle: \"🦀 Rust Ω\"\nflag: true\n---\nBody 東京."
905 .as_bytes(),
906 "+++\ntitle = \"🦀 Rust Ω\"\nflag = true\n+++\nBody 東京."
907 .as_bytes(),
908 "{\n \"title\": \"🦀 Rust Ω\",\n \"flag\": true\n}\nBody 東京."
909 .as_bytes(),
910 ];
911
912 for doc in cases {
913 let fm =
914 core_kernel::verify_core_pipeline(doc, 4, 8).unwrap();
915 assert_eq!(
916 fm.get("title").and_then(Value::as_str),
917 Some("🦀 Rust Ω")
918 );
919 assert!(
920 core_kernel::verify_core_pipeline(doc, 4, 1).is_err()
921 );
922 }
923
924 assert!(core_kernel::verify_core_pipeline(&[0xFF, 0xFE], 4, 8)
925 .is_err());
926 }
927
928 #[test]
929 fn miri_concurrent_arc_error_clones_and_depth_boundaries() {
930 let deep_yaml = "---\na:\n b:\n c: val\n---\nbody";
931 assert!(core_kernel::verify_core_pipeline(
932 deep_yaml.as_bytes(),
933 3,
934 8
935 )
936 .is_ok());
937 assert!(core_kernel::verify_core_pipeline(
938 deep_yaml.as_bytes(),
939 2,
940 8
941 )
942 .is_err());
943
944 let yaml_err =
945 parse("invalid: [unclosed", Format::Yaml).unwrap_err();
946 let json_err =
947 parse("{\"unclosed\": ", Format::Json).unwrap_err();
948 let shared = Arc::new((yaml_err, json_err));
949
950 let mut handles = Vec::with_capacity(2);
951 for idx in 0..2 {
952 let pair = Arc::clone(&shared);
953 handles.push(thread::spawn(move || {
954 let ctx = Context {
955 line: Some(idx + 1),
956 column: Some(4),
957 snippet: Some("unclosed".to_owned()),
958 };
959 let y = pair.0.clone().with_context(&ctx);
960 let j = pair.1.clone().with_context(&ctx);
961 let engine_err = EngineError::FileSystemError {
962 source: std::io::Error::other("miri"),
963 context: y.to_string(),
964 };
965 let converted: Error = engine_err.clone().into();
966 assert!(!j.to_string().is_empty());
967 assert!(!converted.to_string().is_empty());
968 }));
969 }
970 for handle in handles {
971 handle.join().unwrap();
972 }
973 }
974}