use serde::de::DeserializeOwned;
pub trait StrictSchema {
fn strict_top_level_keys() -> Option<&'static [&'static str]> {
None
}
}
pub fn from_yaml_str<T: DeserializeOwned + StrictSchema>(raw: &str) -> Result<T, String> {
let de = serde_yaml::Deserializer::from_str(raw);
let mut ignored: Vec<String> = Vec::new();
let value: T = serde_ignored::deserialize(de, |path| ignored.push(path.to_string()))
.map_err(|e| e.to_string())?;
reject_ignored(ignored)?;
if let Some(known) = T::strict_top_level_keys() {
let value: serde_yaml::Value = serde_yaml::from_str(raw).map_err(|e| e.to_string())?;
if let Some(map) = value.as_mapping() {
reject_unknown_top_level(map.keys().map(yaml_key_label), known)?;
}
}
Ok(value)
}
fn yaml_key_label(k: &serde_yaml::Value) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
match k {
serde_yaml::Value::String(s) => Cow::Borrowed(s),
serde_yaml::Value::Bool(b) => Cow::Owned(b.to_string()),
serde_yaml::Value::Number(n) => Cow::Owned(n.to_string()),
serde_yaml::Value::Null => Cow::Owned("null".to_string()),
other => Cow::Owned(format!("{other:?}")),
}
}
pub fn from_json_slice<T: DeserializeOwned + StrictSchema>(bytes: &[u8]) -> Result<T, String> {
let mut de = serde_json::Deserializer::from_slice(bytes);
let mut ignored: Vec<String> = Vec::new();
let value: T = serde_ignored::deserialize(&mut de, |path| ignored.push(path.to_string()))
.map_err(|e| e.to_string())?;
de.end().map_err(|e| e.to_string())?;
reject_ignored(ignored)?;
if let Some(known) = T::strict_top_level_keys() {
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
if let Some(map) = value.as_object() {
reject_unknown_top_level(
map.keys().map(|k| std::borrow::Cow::Borrowed(k.as_str())),
known,
)?;
}
}
Ok(value)
}
fn reject_unknown_top_level<'a, I>(present: I, known: &[&str]) -> Result<(), String>
where
I: IntoIterator<Item = std::borrow::Cow<'a, str>>,
{
let mut unknown: Vec<String> = present
.into_iter()
.filter(|k| !known.contains(&k.as_ref()))
.map(|k| k.into_owned())
.collect();
if unknown.is_empty() {
return Ok(());
}
unknown.sort();
unknown.dedup();
Err(format!(
"unknown field(s): {} — check for typos; fields must match the current schema",
unknown.join(", ")
))
}
fn reject_ignored(mut ignored: Vec<String>) -> Result<(), String> {
if ignored.is_empty() {
return Ok(());
}
ignored.sort();
ignored.dedup();
Err(format!(
"unknown field(s): {} — check for typos; fields must match the current schema",
ignored.join(", ")
))
}
#[cfg(test)]
mod tests {
use crate::manifest::Manifest;
const MINIMAL: &str = r#"
id: echo-test
version: 0.0.1
execute:
shell: powershell
script: "echo 'kanade'"
timeout: 30s
"#;
#[test]
fn strict_accepts_clean_manifest() {
let m: Manifest = super::from_yaml_str(MINIMAL).expect("clean yaml parses");
assert_eq!(m.id, "echo-test");
}
#[test]
fn strict_rejects_top_level_typo_with_path() {
let yaml = format!("{MINIMAL}staleness_polcy: warn\n");
let err = super::from_yaml_str::<Manifest>(&yaml).unwrap_err();
assert!(err.contains("staleness_polcy"), "{err}");
}
#[test]
fn strict_rejects_nested_typo_with_path() {
let yaml = r#"
id: echo-test
version: 0.0.1
execute:
shell: powershell
script_objectt: foo
timeout: 30s
"#;
let err = super::from_yaml_str::<Manifest>(yaml).unwrap_err();
assert!(err.contains("execute.script_objectt"), "{err}");
}
#[test]
fn tolerant_read_accepts_future_fields() {
let yaml = format!("{MINIMAL}field_from_the_future: 42\n");
let m: Manifest = serde_yaml::from_str(&yaml).expect("tolerant read");
assert_eq!(m.id, "echo-test");
}
#[test]
fn strict_json_rejects_typo() {
let json = serde_json::json!({
"id": "echo-test",
"version": "0.0.1",
"execute": { "shell": "powershell", "script": "echo", "timeout": "30s" },
"tyypo": true,
});
let err =
super::from_json_slice::<Manifest>(&serde_json::to_vec(&json).unwrap()).unwrap_err();
assert!(err.contains("tyypo"), "{err}");
}
}