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/// Types parsed at a strict create boundary implement this so the
22/// boundary can guard fields that [`serde_ignored`] can't see.
23///
24/// The blanket-ish default returns `None` ("serde_ignored catches every
25/// typo, nothing extra to check"), which is correct for any type
26/// WITHOUT a `#[serde(flatten)]` field. A type that DOES flatten a
27/// sub-struct must override it: serde deserialises a flattened parent
28/// by buffering all fields into an internal map and handing the
29/// leftovers to the flattened field's deserialiser, and that buffering
30/// is opaque to serde_ignored — so an unknown TOP-LEVEL key on a
31/// flattening type is silently dropped instead of rejected (#924).
32/// Overriding with the complete top-level key set (the type's own
33/// fields AND the flattened struct's fields) lets the boundary reject
34/// those keys explicitly. Nested typos are still caught by
35/// serde_ignored as usual.
36pub trait StrictSchema {
37    /// The complete set of valid top-level keys, or `None` when the
38    /// type has no flattened field. See the trait docs.
39    fn strict_top_level_keys() -> Option<&'static [&'static str]> {
40        None
41    }
42}
43
44/// Parse YAML, rejecting any key the target type doesn't know.
45/// Errors are human-readable strings ready for CLI / HTTP 400 use.
46pub fn from_yaml_str<T: DeserializeOwned + StrictSchema>(raw: &str) -> Result<T, String> {
47    let de = serde_yaml::Deserializer::from_str(raw);
48    let mut ignored: Vec<String> = Vec::new();
49    let value: T = serde_ignored::deserialize(de, |path| ignored.push(path.to_string()))
50        .map_err(|e| e.to_string())?;
51    reject_ignored(ignored)?;
52    if let Some(known) = T::strict_top_level_keys() {
53        // #924: guard the flatten-hidden top-level keys. Re-parse to a
54        // Value only to enumerate the mapping's keys — cheap, and only
55        // for flattening types (Schedule).
56        let value: serde_yaml::Value = serde_yaml::from_str(raw).map_err(|e| e.to_string())?;
57        if let Some(map) = value.as_mapping() {
58            // A YAML key isn't necessarily a string: `on:` parses as the
59            // boolean `true`, `123:` as a number. `yaml_key_label` renders
60            // those to their scalar text so a non-string key can't slip
61            // past the guard by not being a `&str` (gemini #945).
62            reject_unknown_top_level(map.keys().map(yaml_key_label), known)?;
63        }
64    }
65    Ok(value)
66}
67
68/// Render a YAML mapping key as the string the strict guard compares
69/// against the known-key set. A string key borrows; a scalar key
70/// (bool / number / null — e.g. the `on:` → `true` footgun) is rendered
71/// owned so it's still checked rather than silently dropped.
72fn yaml_key_label(k: &serde_yaml::Value) -> std::borrow::Cow<'_, str> {
73    use std::borrow::Cow;
74    match k {
75        serde_yaml::Value::String(s) => Cow::Borrowed(s),
76        serde_yaml::Value::Bool(b) => Cow::Owned(b.to_string()),
77        serde_yaml::Value::Number(n) => Cow::Owned(n.to_string()),
78        serde_yaml::Value::Null => Cow::Owned("null".to_string()),
79        other => Cow::Owned(format!("{other:?}")),
80    }
81}
82
83/// Parse JSON bytes, rejecting any key the target type doesn't know.
84pub fn from_json_slice<T: DeserializeOwned + StrictSchema>(bytes: &[u8]) -> Result<T, String> {
85    let mut de = serde_json::Deserializer::from_slice(bytes);
86    let mut ignored: Vec<String> = Vec::new();
87    let value: T = serde_ignored::deserialize(&mut de, |path| ignored.push(path.to_string()))
88        .map_err(|e| e.to_string())?;
89    // serde_json::from_slice calls de.end() internally to reject
90    // trailing bytes; this open-coded path must do the same or
91    // `{...}{"extra":true}` would pass the strict boundary
92    // (PR #558 review, claude).
93    de.end().map_err(|e| e.to_string())?;
94    reject_ignored(ignored)?;
95    if let Some(known) = T::strict_top_level_keys() {
96        // #924: same flatten-hidden top-level guard as the YAML path.
97        let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
98        if let Some(map) = value.as_object() {
99            // JSON object keys are always strings — borrow them.
100            reject_unknown_top_level(
101                map.keys().map(|k| std::borrow::Cow::Borrowed(k.as_str())),
102                known,
103            )?;
104        }
105    }
106    Ok(value)
107}
108
109/// Reject any present top-level key not in `known`. Takes an iterator of
110/// `Cow<str>` so the common string-key case borrows (no per-key alloc on
111/// the happy path, gemini #945) while a rendered non-string YAML key can
112/// still be passed owned; only the `unknown` set — empty in the happy
113/// path — ever allocates.
114fn reject_unknown_top_level<'a, I>(present: I, known: &[&str]) -> Result<(), String>
115where
116    I: IntoIterator<Item = std::borrow::Cow<'a, str>>,
117{
118    let mut unknown: Vec<String> = present
119        .into_iter()
120        .filter(|k| !known.contains(&k.as_ref()))
121        .map(|k| k.into_owned())
122        .collect();
123    if unknown.is_empty() {
124        return Ok(());
125    }
126    unknown.sort();
127    unknown.dedup();
128    Err(format!(
129        "unknown field(s): {} — check for typos; fields must match the current schema",
130        unknown.join(", ")
131    ))
132}
133
134fn reject_ignored(mut ignored: Vec<String>) -> Result<(), String> {
135    if ignored.is_empty() {
136        return Ok(());
137    }
138    ignored.sort();
139    ignored.dedup();
140    Err(format!(
141        "unknown field(s): {} — check for typos; fields must match the current schema",
142        ignored.join(", ")
143    ))
144}
145
146#[cfg(test)]
147mod tests {
148    use crate::manifest::Manifest;
149
150    const MINIMAL: &str = r#"
151id: echo-test
152version: 0.0.1
153execute:
154  shell: powershell
155  script: "echo 'kanade'"
156  timeout: 30s
157"#;
158
159    #[test]
160    fn strict_accepts_clean_manifest() {
161        let m: Manifest = super::from_yaml_str(MINIMAL).expect("clean yaml parses");
162        assert_eq!(m.id, "echo-test");
163    }
164
165    #[test]
166    fn strict_rejects_top_level_typo_with_path() {
167        let yaml = format!("{MINIMAL}staleness_polcy: warn\n");
168        let err = super::from_yaml_str::<Manifest>(&yaml).unwrap_err();
169        assert!(err.contains("staleness_polcy"), "{err}");
170    }
171
172    #[test]
173    fn strict_rejects_nested_typo_with_path() {
174        let yaml = r#"
175id: echo-test
176version: 0.0.1
177execute:
178  shell: powershell
179  script_objectt: foo
180  timeout: 30s
181"#;
182        let err = super::from_yaml_str::<Manifest>(yaml).unwrap_err();
183        // serde_ignored reports the full path, e.g. `execute.script_objectt`.
184        assert!(err.contains("execute.script_objectt"), "{err}");
185    }
186
187    #[test]
188    fn tolerant_read_accepts_future_fields() {
189        // #492: the READ path (plain serde) must accept payloads from
190        // a newer writer — this is the gradual-upgrade contract that
191        // deny_unknown_fields used to break fleet-wide.
192        let yaml = format!("{MINIMAL}field_from_the_future: 42\n");
193        let m: Manifest = serde_yaml::from_str(&yaml).expect("tolerant read");
194        assert_eq!(m.id, "echo-test");
195    }
196
197    #[test]
198    fn strict_json_rejects_typo() {
199        let json = serde_json::json!({
200            "id": "echo-test",
201            "version": "0.0.1",
202            "execute": { "shell": "powershell", "script": "echo", "timeout": "30s" },
203            "tyypo": true,
204        });
205        let err =
206            super::from_json_slice::<Manifest>(&serde_json::to_vec(&json).unwrap()).unwrap_err();
207        assert!(err.contains("tyypo"), "{err}");
208    }
209}