Skip to main content

kanade_shared/
strict.rs

1//! #492: strict create-boundary parsing.
2//!
3//! The manifest / schedule types used to carry
4//! `#[serde(deny_unknown_fields)]` as an operator typo guard — but
5//! the same types are READ fleet-wide (agents decode them from
6//! BUCKET_JOBS / BUCKET_SCHEDULES and inside live `Command`s), so
7//! any field a newer backend added made every older agent reject the
8//! whole object during a gradual fleet upgrade. CheckHint's `fleet`
9//! field (#290 PR-E) did exactly that to pre-PR-E agents.
10//!
11//! The split: read paths are tolerant (plain serde, unknown fields
12//! ignored — the wire rule is "new fields always have defaults"),
13//! and the WRITE boundaries (`kanade job/schedule create`, the
14//! backend's POST body extractor) call these helpers, which collect
15//! every ignored path via [`serde_ignored`] and reject the payload
16//! with the offending key paths spelled out — strictly better
17//! diagnostics than `deny_unknown_fields`' single-key error.
18
19use serde::de::DeserializeOwned;
20
21/// Parse YAML, rejecting any key the target type doesn't know.
22/// Errors are human-readable strings ready for CLI / HTTP 400 use.
23pub fn from_yaml_str<T: DeserializeOwned>(raw: &str) -> Result<T, String> {
24    let de = serde_yaml::Deserializer::from_str(raw);
25    let mut ignored: Vec<String> = Vec::new();
26    let value: T = serde_ignored::deserialize(de, |path| ignored.push(path.to_string()))
27        .map_err(|e| e.to_string())?;
28    reject_ignored(ignored)?;
29    Ok(value)
30}
31
32/// Parse JSON bytes, rejecting any key the target type doesn't know.
33pub fn from_json_slice<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, String> {
34    let mut de = serde_json::Deserializer::from_slice(bytes);
35    let mut ignored: Vec<String> = Vec::new();
36    let value: T = serde_ignored::deserialize(&mut de, |path| ignored.push(path.to_string()))
37        .map_err(|e| e.to_string())?;
38    // serde_json::from_slice calls de.end() internally to reject
39    // trailing bytes; this open-coded path must do the same or
40    // `{...}{"extra":true}` would pass the strict boundary
41    // (PR #558 review, claude).
42    de.end().map_err(|e| e.to_string())?;
43    reject_ignored(ignored)?;
44    Ok(value)
45}
46
47fn reject_ignored(mut ignored: Vec<String>) -> Result<(), String> {
48    if ignored.is_empty() {
49        return Ok(());
50    }
51    ignored.sort();
52    ignored.dedup();
53    Err(format!(
54        "unknown field(s): {} — check for typos; fields must match the current schema",
55        ignored.join(", ")
56    ))
57}
58
59#[cfg(test)]
60mod tests {
61    use crate::manifest::Manifest;
62
63    const MINIMAL: &str = r#"
64id: echo-test
65version: 0.0.1
66execute:
67  shell: powershell
68  script: "echo 'kanade'"
69  timeout: 30s
70"#;
71
72    #[test]
73    fn strict_accepts_clean_manifest() {
74        let m: Manifest = super::from_yaml_str(MINIMAL).expect("clean yaml parses");
75        assert_eq!(m.id, "echo-test");
76    }
77
78    #[test]
79    fn strict_rejects_top_level_typo_with_path() {
80        let yaml = format!("{MINIMAL}staleness_polcy: warn\n");
81        let err = super::from_yaml_str::<Manifest>(&yaml).unwrap_err();
82        assert!(err.contains("staleness_polcy"), "{err}");
83    }
84
85    #[test]
86    fn strict_rejects_nested_typo_with_path() {
87        let yaml = r#"
88id: echo-test
89version: 0.0.1
90execute:
91  shell: powershell
92  script_objectt: foo
93  timeout: 30s
94"#;
95        let err = super::from_yaml_str::<Manifest>(yaml).unwrap_err();
96        // serde_ignored reports the full path, e.g. `execute.script_objectt`.
97        assert!(err.contains("execute.script_objectt"), "{err}");
98    }
99
100    #[test]
101    fn tolerant_read_accepts_future_fields() {
102        // #492: the READ path (plain serde) must accept payloads from
103        // a newer writer — this is the gradual-upgrade contract that
104        // deny_unknown_fields used to break fleet-wide.
105        let yaml = format!("{MINIMAL}field_from_the_future: 42\n");
106        let m: Manifest = serde_yaml::from_str(&yaml).expect("tolerant read");
107        assert_eq!(m.id, "echo-test");
108    }
109
110    #[test]
111    fn strict_json_rejects_typo() {
112        let json = serde_json::json!({
113            "id": "echo-test",
114            "version": "0.0.1",
115            "execute": { "shell": "powershell", "script": "echo", "timeout": "30s" },
116            "tyypo": true,
117        });
118        let err =
119            super::from_json_slice::<Manifest>(&serde_json::to_vec(&json).unwrap()).unwrap_err();
120        assert!(err.contains("tyypo"), "{err}");
121    }
122}