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}