Skip to main content

rskit_codec/
yaml.rs

1use rskit_errors::{AppError, AppResult};
2use serde_json::Value;
3
4use crate::codec::Codec;
5
6/// Built-in YAML codec.
7///
8/// Decodes YAML into the canonical [`Value`] tree and encodes a value tree back to YAML.
9/// Like the TOML codec, the top-level value must be a mapping (the crate's document
10/// contract for config-shaped formats), and a non-mapping top level surfaces as a
11/// typed error rather than a panic — on both decode and encode, so round-trips stay symmetric.
12///
13/// # Security
14///
15/// Unlike TOML and JSON, YAML supports anchors and aliases, which the parser expands
16/// during decode. A small hostile document can therefore reference-expand into a much
17/// larger in-memory tree ("billion laughs"). This codec does not itself cap expansion,
18/// so callers must decode only size-bounded input — the same trust boundary the other
19/// codecs rely on (e.g. `rskit-fs` bounded reads). Do not feed unbounded or untrusted
20/// streams straight into [`decode_value`](YamlCodec::decode_value).
21#[derive(Debug, Clone, Copy, Default)]
22pub struct YamlCodec;
23
24impl Codec for YamlCodec {
25    fn name(&self) -> &'static str {
26        "yaml"
27    }
28
29    fn encode_value(&self, value: &Value) -> AppResult<String> {
30        if !value.is_object() {
31            return Err(AppError::invalid_input(
32                "codec",
33                "failed to serialize value as YAML: top level must be a mapping",
34            ));
35        }
36        serde_norway::to_string(value).map_err(|err| {
37            AppError::invalid_input("codec", "failed to serialize value as YAML").with_cause(err)
38        })
39    }
40
41    fn decode_value(&self, contents: &str) -> AppResult<Value> {
42        let value = serde_norway::from_str::<Value>(contents).map_err(|err| {
43            AppError::invalid_input("codec", "failed to parse YAML").with_cause(err)
44        })?;
45        if !value.is_object() {
46            return Err(AppError::invalid_input(
47                "codec",
48                "YAML top level must be a mapping",
49            ));
50        }
51        Ok(value)
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::decode;
59    use serde::Deserialize;
60
61    #[test]
62    fn round_trips_table() {
63        let codec = YamlCodec;
64        let value: Value = serde_json::json!({
65            "name": "svc",
66            "tags": ["alpha", "beta", "gamma"],
67            "nested": { "enabled": true, "retries": 3 }
68        });
69
70        let encoded = codec.encode_value(&value).unwrap();
71        let decoded = codec.decode_value(&encoded).unwrap();
72
73        assert_eq!(decoded, value);
74        assert_eq!(codec.name(), "yaml");
75    }
76
77    #[test]
78    fn rejects_malformed_input() {
79        let err = YamlCodec.decode_value("key: [unclosed").unwrap_err();
80        assert!(err.to_string().contains("parse"));
81    }
82
83    #[test]
84    fn rejects_non_mapping_top_level_on_decode() {
85        // Valid YAML documents, but outside the crate's mapping-root contract.
86        for doc in ["- a\n- b", "42", ""] {
87            let err = YamlCodec.decode_value(doc).unwrap_err();
88            assert!(err.to_string().contains("mapping"), "doc: {doc:?}");
89        }
90    }
91
92    #[test]
93    fn rejects_non_mapping_top_level_on_encode() {
94        // Mirrors the decode-side contract so encode → decode stays symmetric.
95        let err = YamlCodec.encode_value(&Value::Null).unwrap_err();
96        let message = err.to_string();
97        assert!(message.contains("serialize"));
98        // Unlike TOML (whose serializer rejects `null` natively), this rejection
99        // is the codec's own top-level contract — pin the reason.
100        assert!(message.contains("mapping"), "names the contract: {message}");
101    }
102
103    #[test]
104    fn decode_honors_deny_unknown_fields() {
105        #[derive(Debug, Deserialize)]
106        #[serde(deny_unknown_fields)]
107        struct Settings {
108            #[expect(dead_code, reason = "only the field set is under test")]
109            name: String,
110        }
111
112        let err = decode::<Settings>(&YamlCodec, "name: svc\nunknown: 1\n").unwrap_err();
113        assert!(err.to_string().contains("deserialize"));
114    }
115}