Skip to main content

ignition_core/deploy/
yamlio.rs

1//! Strict YAML parsing for the declared tree (`manifest.yaml`,
2//! `targets/<env>.yaml`).
3//!
4//! [`strict_parse`] is a PURE function of `(file label, bytes)`: it reads no
5//! file, holds no global state, and parsing the same bytes twice yields
6//! equal results or equal errors. Every defect becomes a
7//! [`CoreError::DeployTreeInvalid`] whose message never echoes a value read
8//! from the input.
9//!
10//! Position convention: `yaml_serde::Error::location()` is 1-BASED for both
11//! line and column (pinned by `unknown_top_level_field_has_position`), and
12//! the column counts the key start for unknown-field and wrong-type
13//! errors, so [`DeployFinding`] positions are passed through unchanged. `yaml_serde` reports 1:1 for missing and
14//! duplicate fields, which is not a real position; those findings carry
15//! none and name only the file.
16//!
17//! [`DeployFinding`]: crate::error::DeployFinding
18
19use serde::Deserialize;
20use serde::de::DeserializeOwned;
21
22use crate::error::CoreError;
23
24/// The only `schema_version` this `ign` reads.
25pub const SCHEMA_VERSION: u32 = 1;
26
27/// Largest YAML input accepted, in bytes (1 MiB). Checked before any parse.
28pub const YAML_MAX_BYTES: usize = 1_048_576;
29
30/// A `schema_version` that is exactly [`SCHEMA_VERSION`]; any other value
31/// fails deserialization with an `unsupported schema_version` message.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SchemaVersion;
34
35impl<'de> Deserialize<'de> for SchemaVersion {
36    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
37        match u32::deserialize(d)? {
38            SCHEMA_VERSION => Ok(Self),
39            v => Err(serde::de::Error::custom(format!(
40                "unsupported schema_version {v}; this ign reads {SCHEMA_VERSION}"
41            ))),
42        }
43    }
44}
45
46/// First-pass probe: reads only `schema_version`, WITHOUT
47/// `deny_unknown_fields`, so an unknown key earlier in the file cannot
48/// mask a version error.
49#[derive(Deserialize)]
50struct SchemaProbe {
51    #[allow(dead_code)]
52    schema_version: SchemaVersion,
53}
54
55/// Parse `bytes` (labelled `file` in errors) into `T`.
56///
57/// Guards, in order: size cap, carriage return, UTF-8, schema version
58/// (first pass), then the strict typed parse (second pass; `T` is expected
59/// to carry `deny_unknown_fields`).
60pub fn strict_parse<T: DeserializeOwned>(file: &str, bytes: &[u8]) -> Result<T, CoreError> {
61    if bytes.len() > YAML_MAX_BYTES {
62        return Err(CoreError::deploy_tree_invalid(
63            file,
64            None,
65            None,
66            format!(
67                "file is {} bytes; the YAML size cap is {YAML_MAX_BYTES} bytes",
68                bytes.len()
69            ),
70        ));
71    }
72    if let Some(at) = bytes.iter().position(|b| *b == b'\r') {
73        let (line, column) = position_of(bytes, at);
74        return Err(CoreError::deploy_tree_invalid(
75            file,
76            Some(line),
77            Some(column),
78            "carriage return (0x0D); save the file with LF line endings",
79        ));
80    }
81    let text = std::str::from_utf8(bytes).map_err(|e| {
82        let (line, column) = position_of(bytes, e.valid_up_to());
83        CoreError::deploy_tree_invalid(file, Some(line), Some(column), "file is not valid UTF-8")
84    })?;
85    yaml_serde::from_str::<SchemaProbe>(text).map_err(|e| {
86        let mapped = map_error(file, &e);
87        relocate_schema_error(file, text, mapped)
88    })?;
89    yaml_serde::from_str::<T>(text).map_err(|e| map_error(file, &e))
90}
91
92/// yaml_serde positions a `custom` error raised inside a value at the START
93/// OF THE ENCLOSING MAPPING, not at the key. For the version error the key
94/// is known, so point at it: the `schema_version:` line at column 1.
95fn relocate_schema_error(file: &str, text: &str, err: CoreError) -> CoreError {
96    let CoreError::DeployTreeInvalid(details) = &err else {
97        return err;
98    };
99    let Some(finding) = details.findings.first() else {
100        return err;
101    };
102    if !finding.message.starts_with("unsupported schema_version") {
103        return err;
104    }
105    match text.lines().position(|l| l.starts_with("schema_version:")) {
106        Some(i) => {
107            CoreError::deploy_tree_invalid(file, Some(i + 1), Some(1), finding.message.clone())
108        }
109        None => err,
110    }
111}
112
113/// 1-based `(line, column)` of byte offset `at` (column counted in bytes).
114fn position_of(bytes: &[u8], at: usize) -> (usize, usize) {
115    let before = &bytes[..at.min(bytes.len())];
116    let line = before.iter().filter(|b| **b == b'\n').count() + 1;
117    let line_start = before
118        .iter()
119        .rposition(|b| *b == b'\n')
120        .map_or(0, |i| i + 1);
121    (line, before.len() - line_start + 1)
122}
123
124fn map_error(file: &str, e: &yaml_serde::Error) -> CoreError {
125    let text = e.to_string();
126    let message = scrub_values(strip_position(&text));
127    let positionless = ["missing field", "duplicate field"]
128        .iter()
129        .any(|p| message.starts_with(p) || message.contains(&format!(": {p}")));
130    let (line, column) = if positionless {
131        (None, None)
132    } else {
133        match e.location() {
134            Some(loc) => (Some(loc.line()), Some(loc.column())),
135            None => (None, None),
136        }
137    };
138    CoreError::deploy_tree_invalid(file, line, column, message)
139}
140
141/// Drop yaml_serde's trailing ` at line N column M`, so the position is
142/// reported once, by us.
143fn strip_position(msg: &str) -> &str {
144    match msg.rfind(" at line ") {
145        Some(i) if msg[i..].contains(" column ") => &msg[..i],
146        _ => msg,
147    }
148}
149
150/// Remove every user-supplied scalar value serde or yaml_serde would echo
151/// (T-21-10): a wrong-typed credential must never reach an error, an
152/// envelope, or a log. Audited sources of an echoed value:
153/// - `invalid type: <kind> <value>` and `invalid value: <kind> <value>`
154///   (string, integer, floating point, boolean, character);
155/// - `unknown variant `<value>`, expected ...` (any enum field);
156/// - `deserializing nested enum in ...::<tag>`, which echoes a YAML tag.
157///
158/// Not echoed, so left alone: `unknown field `<key>`` (a key, kept so the
159/// message is actionable), `missing field`, `duplicate field`,
160/// `invalid length N`, and every libyaml scanner/parser message (static
161/// text plus a position).
162fn scrub_values(msg: &str) -> String {
163    scrub_invalid(&scrub_variant(msg))
164}
165
166/// `unknown variant `<value>`, expected ...` becomes
167/// `unknown variant, expected ...`. The value is raw (not escaped), so it
168/// may itself contain a backtick or `, expected `; the real list is the
169/// LAST `, expected ` because variant names are static.
170fn scrub_variant(msg: &str) -> String {
171    const LEAD: &str = "unknown variant ";
172    if msg.contains("deserializing nested enum in ") {
173        return "deserializing nested enum from YAML is not supported".to_string();
174    }
175    let Some(at) = msg.find(LEAD) else {
176        return msg.to_string();
177    };
178    let head = &msg[..at + LEAD.len() - 1]; // keep "unknown variant", drop the space
179    match msg.rfind(", expected ") {
180        Some(end) if end >= at + LEAD.len() => format!("{head}{}", &msg[end..]),
181        _ => head.to_string(),
182    }
183}
184
185/// Remove the value from `invalid type: <kind> <value>` and
186/// `invalid value: <kind> <value>` messages.
187fn scrub_invalid(msg: &str) -> String {
188    const LEADS: [&str; 2] = ["invalid type: ", "invalid value: "];
189    const KINDS: [&str; 5] = [
190        "string ",
191        "integer ",
192        "floating point ",
193        "boolean ",
194        "character ",
195    ];
196    for lead in LEADS {
197        let Some(at) = msg.find(lead) else { continue };
198        let after_lead = at + lead.len();
199        let rest = &msg[after_lead..];
200        for kind in KINDS {
201            let Some(value_part) = rest.strip_prefix(kind) else {
202                continue;
203            };
204            let value_len = quoted_len(value_part);
205            if value_len == 0 {
206                continue;
207            }
208            let head = &msg[..after_lead + kind.len() - 1]; // keep "string", drop the space
209            let tail = &value_part[value_len..];
210            return format!("{head}{tail}");
211        }
212    }
213    msg.to_string()
214}
215
216/// Byte length of the leading quoted token (`"…"` with backslash escapes,
217/// `` `…` `` or `'…'`), or 0 when `s` does not start with one.
218fn quoted_len(s: &str) -> usize {
219    let mut chars = s.char_indices();
220    let Some((_, open)) = chars.next() else {
221        return 0;
222    };
223    if !matches!(open, '"' | '`' | '\'') {
224        return 0;
225    }
226    let mut escaped = false;
227    for (i, c) in chars {
228        if escaped {
229            escaped = false;
230        } else if c == '\\' && open == '"' {
231            escaped = true;
232        } else if c == open {
233            return i + c.len_utf8();
234        }
235    }
236    // Unterminated: scrub everything rather than leak a fragment.
237    s.len()
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::error::CoreError;
244    use std::time::{Duration, Instant};
245
246    #[derive(Debug, Deserialize, PartialEq)]
247    #[serde(deny_unknown_fields)]
248    struct Probe {
249        schema_version: SchemaVersion,
250        #[serde(default)]
251        port: Option<u16>,
252        #[serde(default)]
253        nested: Option<Nested>,
254    }
255
256    #[derive(Debug, Deserialize, PartialEq)]
257    #[serde(deny_unknown_fields)]
258    struct Nested {
259        #[serde(default)]
260        a: Option<String>,
261    }
262
263    fn err(file: &str, yaml: &str) -> String {
264        strict_parse::<Probe>(file, yaml.as_bytes())
265            .expect_err("must fail")
266            .to_string()
267    }
268
269    fn finding(e: CoreError) -> crate::error::DeployFinding {
270        match e {
271            CoreError::DeployTreeInvalid(d) => d.findings.into_iter().next().expect("one finding"),
272            other => panic!("expected deploy_tree_invalid, got {other}"),
273        }
274    }
275
276    fn fail(yaml: &str) -> crate::error::DeployFinding {
277        finding(strict_parse::<Probe>("m.yaml", yaml.as_bytes()).expect_err("must fail"))
278    }
279
280    #[test]
281    fn unknown_top_level_field_has_position() {
282        let f = fail("schema_version: 1\nbogus: 2\n");
283        assert_eq!((f.line, f.column), (Some(2), Some(1)));
284        assert!(f.message.contains("bogus"), "{}", f.message);
285        assert!(!f.message.contains(" at line "), "{}", f.message);
286        assert_eq!(f.file, "m.yaml");
287    }
288
289    #[test]
290    fn unknown_nested_field_has_position() {
291        let f = fail("schema_version: 1\nnested:\n  a: x\n  extra: 1\n");
292        assert_eq!((f.line, f.column), (Some(4), Some(3)));
293        assert!(f.message.contains("extra"), "{}", f.message);
294    }
295
296    #[test]
297    fn wrong_type_is_positioned_and_scrubbed() {
298        let f = fail("schema_version: 1\nport: s3cr3t-canary\n");
299        assert_eq!(f.line, Some(2));
300        assert!(!f.message.contains("s3cr3t-canary"), "{}", f.message);
301        let shown = err("m.yaml", "schema_version: 1\nport: s3cr3t-canary\n");
302        assert!(!shown.contains("s3cr3t-canary"), "{shown}");
303
304        // Escaped quotes inside the value cannot leak a fragment.
305        let shown = err("m.yaml", "schema_version: 1\nport: 'a\"b\\\"c-canary2'\n");
306        assert!(!shown.contains("canary2"), "{shown}");
307
308        // Numeric and out-of-range values are scrubbed as well.
309        let shown = err("m.yaml", "schema_version: 1\nport: 99999\n");
310        assert!(!shown.contains("99999"), "{shown}");
311    }
312
313    #[test]
314    fn enum_canary_is_scrubbed_but_variants_are_kept() {
315        #[derive(Debug, Deserialize, PartialEq)]
316        #[serde(rename_all = "lowercase")]
317        enum Kind {
318            Windows,
319            Linux,
320        }
321        #[derive(Debug, Deserialize)]
322        #[serde(deny_unknown_fields)]
323        struct WithEnum {
324            #[allow(dead_code)]
325            schema_version: SchemaVersion,
326            #[allow(dead_code)]
327            kind: Kind,
328        }
329        let run = |yaml: &str| {
330            let e = strict_parse::<WithEnum>("t.yaml", yaml.as_bytes()).expect_err("bad variant");
331            (e.to_string(), finding(e))
332        };
333
334        let (shown, f) = run("schema_version: 1\nkind: s3cr3t-canary\n");
335        assert!(!shown.contains("s3cr3t-canary"), "{shown}");
336        assert_eq!(f.line, Some(2));
337        assert!(shown.contains("unknown variant"), "{shown}");
338        assert!(shown.contains("expected `windows` or `linux`"), "{shown}");
339
340        // A value that imitates the message structure cannot leak a tail.
341        let (shown, _) = run("schema_version: 1\nkind: 'evil`, expected `windows`-canary3 `x'\n");
342        assert!(!shown.contains("canary3"), "{shown}");
343        assert!(shown.contains("expected `windows` or `linux`"), "{shown}");
344
345        // A tagged value's tag name is not echoed either.
346        let (shown, _) = run("schema_version: 1\nkind: !tagcanary4 {a: 1}\n");
347        assert!(!shown.contains("tagcanary4"), "{shown}");
348
349        // Booleans and numbers in an enum slot are scrubbed too.
350        let (shown, _) = run("schema_version: 1\nkind: 8675309\n");
351        assert!(!shown.contains("8675309"), "{shown}");
352    }
353
354    #[test]
355    fn wrong_schema_version_is_positioned() {
356        let f = fail("schema_version: 2\n");
357        assert_eq!(f.message, "unsupported schema_version 2; this ign reads 1");
358        assert_eq!(f.line, Some(1));
359        // The probe runs first, so an earlier unknown key cannot mask it.
360        let f = fail("bogus: 1\nschema_version: 2\n");
361        assert!(f.message.starts_with("unsupported schema_version 2"));
362        assert_eq!(f.line, Some(2));
363    }
364
365    #[test]
366    fn syntax_error_is_positioned() {
367        let f = fail("schema_version: 1\nnested:\n\ta: x\n");
368        assert_eq!(f.line, Some(3));
369        assert!(f.column.is_some());
370    }
371
372    #[test]
373    fn missing_and_duplicate_fields_name_file_only() {
374        let missing = fail("port: 1\n");
375        assert_eq!((missing.line, missing.column), (None, None));
376        assert!(
377            missing.message.contains("missing field"),
378            "{}",
379            missing.message
380        );
381        let shown = err("targets/prod.yaml", "port: 1\n");
382        assert!(shown.contains("targets/prod.yaml: "), "{shown}");
383
384        let dup = fail("schema_version: 1\nport: 1\nport: 2\n");
385        assert_eq!((dup.line, dup.column), (None, None));
386        assert!(dup.message.contains("duplicate field"), "{}", dup.message);
387    }
388
389    #[test]
390    fn anchors_and_aliases_are_accepted() {
391        let ok: Probe = strict_parse(
392            "m.yaml",
393            b"schema_version: 1\nnested: &n\n  a: x\nport: 80\n",
394        )
395        .expect("anchors parse");
396        assert_eq!(ok.port, Some(80));
397        // An alias to a scalar.
398        #[derive(Debug, Deserialize)]
399        struct Loose {
400            #[allow(dead_code)]
401            schema_version: SchemaVersion,
402            a: String,
403            b: String,
404        }
405        let ok: Loose =
406            strict_parse("m.yaml", b"schema_version: 1\na: &x hello\nb: *x\n").expect("alias");
407        assert_eq!((ok.a.as_str(), ok.b.as_str()), ("hello", "hello"));
408    }
409
410    #[test]
411    fn alias_bomb_is_refused() {
412        // Nine levels of nine aliases each: 9^9 leaves if expanded. yaml_serde
413        // carries its own repetition limit, so no pre-parse alias guard is
414        // needed; this pins that behaviour for the pinned 0.10 line.
415        let mut yaml = String::from(
416            "schema_version: 1\nl0: &l0 [\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\"]\n",
417        );
418        for level in 1..=9 {
419            let refs = vec![format!("*l{}", level - 1); 9].join(",");
420            yaml.push_str(&format!("l{level}: &l{level} [{refs}]\n"));
421        }
422        #[derive(Debug, Deserialize)]
423        #[serde(deny_unknown_fields)]
424        struct Consumes {
425            #[allow(dead_code)]
426            schema_version: SchemaVersion,
427            #[allow(dead_code)]
428            l0: serde_json::Value,
429            #[allow(dead_code)]
430            l1: serde_json::Value,
431            #[allow(dead_code)]
432            l2: serde_json::Value,
433            #[allow(dead_code)]
434            l3: serde_json::Value,
435            #[allow(dead_code)]
436            l4: serde_json::Value,
437            #[allow(dead_code)]
438            l5: serde_json::Value,
439            #[allow(dead_code)]
440            l6: serde_json::Value,
441            #[allow(dead_code)]
442            l7: serde_json::Value,
443            #[allow(dead_code)]
444            l8: serde_json::Value,
445            #[allow(dead_code)]
446            l9: serde_json::Value,
447        }
448        let started = Instant::now();
449        let r = strict_parse::<Consumes>("bomb.yaml", yaml.as_bytes());
450        assert!(
451            started.elapsed() < Duration::from_secs(5),
452            "bomb took {:?}",
453            started.elapsed()
454        );
455        let shown = r.expect_err("the bomb must be refused").to_string();
456        assert!(shown.contains("bomb.yaml"), "{shown}");
457        assert!(shown.contains("repetition limit exceeded"), "{shown}");
458        assert_eq!(
459            CoreError::DEPLOY_SLUGS[0],
460            strict_parse::<Consumes>("bomb.yaml", yaml.as_bytes())
461                .expect_err("again")
462                .code()
463        );
464    }
465
466    #[test]
467    fn size_cap_boundary() {
468        // Exactly the cap parses: pad with a trailing comment.
469        let head = b"schema_version: 1\n#";
470        let mut at_cap = head.to_vec();
471        at_cap.resize(YAML_MAX_BYTES, b'x');
472        assert_eq!(at_cap.len(), YAML_MAX_BYTES);
473        strict_parse::<Probe>("m.yaml", &at_cap).expect("exactly the cap parses");
474
475        at_cap.push(b'x');
476        let shown = strict_parse::<Probe>("m.yaml", &at_cap)
477            .expect_err("over the cap")
478            .to_string();
479        assert!(shown.contains("m.yaml"), "{shown}");
480        assert!(shown.contains("1048576"), "{shown}");
481        assert!(shown.contains("1048577"), "{shown}");
482    }
483
484    #[test]
485    fn carriage_return_is_refused_with_line() {
486        let f = fail("schema_version: 1\r\n");
487        assert_eq!((f.line, f.column), (Some(1), Some(18)));
488        assert!(f.message.contains("0x0D"), "{}", f.message);
489        let f = fail("schema_version: 1\nport: 1\r\n");
490        assert_eq!(f.line, Some(2));
491    }
492
493    #[test]
494    fn non_utf8_is_refused() {
495        let e = strict_parse::<Probe>("m.yaml", b"schema_version: 1\n# \xff\xfe\n")
496            .expect_err("invalid utf-8");
497        let f = finding(e);
498        assert!(f.message.contains("UTF-8"), "{}", f.message);
499        assert_eq!(f.line, Some(2));
500    }
501
502    #[test]
503    fn strict_parse_is_pure() {
504        for yaml in [
505            "schema_version: 1\nport: 80\n",
506            "schema_version: 1\nbogus: 1\n",
507            "schema_version: 9\n",
508            "port: 1\n",
509        ] {
510            let a = strict_parse::<Probe>("m.yaml", yaml.as_bytes());
511            let b = strict_parse::<Probe>("m.yaml", yaml.as_bytes());
512            assert_eq!(format!("{a:?}"), format!("{b:?}"), "{yaml:?}");
513        }
514    }
515}