Skip to main content

saluki_config/
yaml.rs

1//! YAML parsing for configuration files.
2
3use serde::de::DeserializeOwned;
4
5/// Deserializes a YAML document using YAML 1.1 scalar resolution.
6///
7/// Configuration files are commonly written against YAML 1.1 parsers, so plain scalars resolve the way those parsers
8/// resolve them rather than by the stricter YAML 1.2 core schema:
9///
10/// - `yes`/`no`, `on`/`off`, and `y`/`n` (in any case) are booleans.
11/// - Integers with a leading zero, such as `012`, are octal.
12/// - `<<` merge keys are expanded.
13///
14/// Non-finite floats (`.inf`, `.nan`) in untyped positions are kept as their string spelling rather than rejecting
15/// the whole document, leaving it to the consumer of that value to decide whether it's valid.
16///
17/// The default limits on the number of parsed nodes and events are lifted, as configuration files can legitimately
18/// hold very large lists. Nesting depth, alias expansion, and total scalar size are still bounded.
19///
20/// Errors report only a message and location, never the surrounding source lines, as configuration files can hold
21/// credentials. Comments are not retained, so runs of comments don't count against the comment limits.
22///
23/// # Errors
24///
25/// If the document isn't valid YAML, or doesn't deserialize as `T`, an error is returned.
26pub fn from_str<T>(input: &str) -> Result<T, serde_saphyr::Error>
27where
28    T: DeserializeOwned,
29{
30    let mut budget = serde_saphyr::Budget::default();
31    budget.max_nodes = usize::MAX;
32    budget.max_events = usize::MAX;
33
34    let options = serde_saphyr::options! {
35        budget: Some(budget),
36        emit_comments: false,
37        legacy_octal_numbers: true,
38        reject_non_finite_typeless_float: false,
39        with_snippet: false,
40    };
41    serde_saphyr::from_str_with_options(input, options)
42}
43
44#[cfg(test)]
45mod tests {
46    use serde_json::{json, Value};
47
48    use super::*;
49
50    #[test]
51    fn resolves_plain_scalars_with_yaml_1_1_rules() {
52        let value: Value = from_str(
53            "yes: yes\noff: Off\nshort: n\noctal: 012\nhex: 0x1F\nnot_octal: 08\nquoted: '012'\nkeyword: \"on\"\n",
54        )
55        .unwrap();
56
57        assert_eq!(
58            value,
59            json!({
60                "yes": true,
61                "off": false,
62                "short": false,
63                "octal": 10,
64                "hex": 31,
65                "not_octal": 8.0,
66                "quoted": "012",
67                "keyword": "on",
68            })
69        );
70    }
71
72    #[test]
73    fn expands_merge_keys() {
74        let value: Value = from_str("base: &base {a: 1, b: 2}\nderived:\n  <<: *base\n  b: 3\n").unwrap();
75
76        assert_eq!(value["derived"], json!({"a": 1, "b": 3}));
77    }
78
79    #[test]
80    fn keeps_non_finite_floats_as_strings() {
81        let value: Value = from_str("a: .inf\nb: -.inf\nc: .nan\n").unwrap();
82
83        assert_eq!(value, json!({"a": ".inf", "b": "-.inf", "c": ".nan"}));
84    }
85
86    #[test]
87    fn parses_documents_with_very_large_lists() {
88        let mut input = String::from("items:\n");
89        for i in 0..300_000 {
90            input.push_str(&format!("  - item_{i}\n"));
91        }
92
93        let value: Value = from_str(&input).unwrap();
94
95        assert_eq!(value["items"].as_array().unwrap().len(), 300_000);
96    }
97
98    #[test]
99    fn rejects_duplicate_keys() {
100        assert!(from_str::<Value>("a: 1\na: 2\n").is_err());
101    }
102
103    #[test]
104    fn errors_do_not_include_source_lines() {
105        let input = "api_key: 0123456789abcdef0123456789abcdef\nlog_level: [unclosed\n";
106
107        let error = from_str::<Value>(input).unwrap_err().to_string();
108
109        assert!(!error.contains("0123456789abcdef"), "error leaks source: {error}");
110        assert!(error.contains("line 2"), "error lacks location: {error}");
111    }
112
113    #[test]
114    fn parses_long_runs_of_comments() {
115        let mut input = String::from("proxy: # optional\n");
116        for i in 0..100 {
117            input.push_str(&format!("# documentation line {i}\n"));
118        }
119        input.push_str("log_level: info\n");
120
121        let value: Value = from_str(&input).unwrap();
122
123        assert_eq!(value, json!({"proxy": null, "log_level": "info"}));
124    }
125}