Skip to main content

pharos_runtime_compiler/
database_yaml.rs

1//! Bounded alias and merge expansion for database source authoring only.
2use pharos_runtime_core::yaml::YamlDiagnostic;
3use saphyr_parser::{Event, Parser, ScalarStyle, Span, SpannedEventReceiver};
4use serde_json::{Map, Value};
5use std::collections::BTreeMap;
6
7const MAX_BYTES: usize = 8 * 1024 * 1024;
8const MAX_NODES: usize = 200_000;
9
10struct Frame {
11    anchor: usize,
12    mapping: bool,
13    values: Vec<Value>,
14    keys: BTreeMap<String, ()>,
15}
16
17#[derive(Default)]
18struct Receiver {
19    frames: Vec<Frame>,
20    anchors: BTreeMap<usize, Value>,
21    result: Option<Value>,
22    error: Option<YamlDiagnostic>,
23    nodes: usize,
24    bytes: usize,
25    aliases: usize,
26    documents: usize,
27}
28impl Receiver {
29    /// Records the first source diagnostic while the parser finishes safely.
30    fn fail(&mut self, span: Span, code: &'static str, message: &str) {
31        self.error.get_or_insert(YamlDiagnostic {
32            code,
33            line: span.start.line(),
34            column: span.start.col() + 1,
35            message: message.to_owned(),
36        });
37    }
38    /// Adds an independently owned value within the total expansion budget.
39    fn add(&mut self, value: Value, anchor: usize, span: Span) {
40        let size = serde_json::to_vec(&value).map_or(MAX_BYTES + 1, |v| v.len());
41        let mut pending = vec![(&value, self.frames.len() + 1)];
42        let mut expanded = 0usize;
43        while let Some((node, depth)) = pending.pop() {
44            expanded += 1;
45            if depth > 64 || expanded > MAX_NODES {
46                self.fail(
47                    span,
48                    "yaml_expansion_limit",
49                    "expanded database YAML exceeds its node or nesting budget",
50                );
51                return;
52            }
53            match node {
54                Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))),
55                Value::Object(fields) => {
56                    pending.extend(fields.values().map(|item| (item, depth + 1)))
57                }
58                _ => {}
59            }
60        }
61        self.nodes = self.nodes.saturating_add(expanded);
62        self.bytes = self.bytes.saturating_add(size);
63        if self.nodes > MAX_NODES
64            || self.bytes > 4 * MAX_BYTES
65            || size > MAX_BYTES
66            || (anchor != 0 && self.anchors.len() >= 4096)
67        {
68            self.fail(
69                span,
70                "yaml_expansion_limit",
71                "database YAML expansion exceeds its budget",
72            );
73            return;
74        }
75        if anchor != 0 {
76            self.anchors.insert(anchor, value.clone());
77        }
78        if let Some(frame) = self.frames.last_mut() {
79            if frame.mapping && frame.values.len().is_multiple_of(2) {
80                let Some(key) = value.as_str() else {
81                    self.fail(span, "yaml_key", "mapping keys must be strings");
82                    return;
83                };
84                if frame.keys.insert(key.to_owned(), ()).is_some() {
85                    self.fail(span, "yaml_duplicate_key", "duplicate explicit mapping key");
86                    return;
87                }
88            }
89            frame.values.push(value);
90        } else if self.result.replace(value).is_some() {
91            self.fail(
92                span,
93                "yaml_document_count",
94                "exactly one database document is required",
95            );
96        }
97    }
98    /// Materializes explicit fields and unambiguous mapping merges.
99    fn mapping(&mut self, values: Vec<Value>, span: Span) -> Option<Value> {
100        if !values.len().is_multiple_of(2) {
101            self.fail(span, "yaml_mapping", "mapping has no value");
102            return None;
103        }
104        let mut explicit = Map::new();
105        let mut merged = Map::new();
106        for pair in values.chunks_exact(2) {
107            let Some(key) = pair[0].as_str() else {
108                self.fail(span, "yaml_key", "database mapping keys must be strings");
109                return None;
110            };
111            if explicit.contains_key(key) {
112                self.fail(span, "yaml_duplicate_key", "duplicate explicit mapping key");
113                return None;
114            }
115            if key == "<<" {
116                if explicit.insert(key.to_owned(), Value::Null).is_some() {
117                    return None;
118                }
119                let sources = match &pair[1] {
120                    Value::Array(items) => items.as_slice(),
121                    item => std::slice::from_ref(item),
122                };
123                for source in sources {
124                    let Some(fields) = source.as_object() else {
125                        self.fail(span, "yaml_merge", "merge sources must be mappings");
126                        return None;
127                    };
128                    for (name, value) in fields {
129                        if merged.get(name).is_some_and(|existing| existing != value) {
130                            self.fail(span, "yaml_merge_conflict", "conflicting merged fields");
131                            return None;
132                        }
133                        merged.insert(name.clone(), value.clone());
134                    }
135                }
136            } else {
137                explicit.insert(key.to_owned(), pair[1].clone());
138            }
139        }
140        explicit.remove("<<");
141        merged.extend(explicit);
142        Some(Value::Object(merged))
143    }
144}
145impl<'a> SpannedEventReceiver<'a> for Receiver {
146    /// Expands only already-completed anchors, rejecting recursive references.
147    fn on_event(&mut self, event: Event<'a>, span: Span) {
148        if self.error.is_some() {
149            return;
150        }
151        let is_mapping = matches!(&event, Event::MappingStart(..));
152        match event {
153            Event::DocumentStart(_) => {
154                self.documents += 1;
155                if self.documents > 1 {
156                    self.fail(
157                        span,
158                        "yaml_document_count",
159                        "exactly one database document is required",
160                    );
161                }
162            }
163            Event::MappingStart(anchor, tag) | Event::SequenceStart(anchor, tag) => {
164                if tag.is_some() || self.frames.len() >= 64 {
165                    self.fail(
166                        span,
167                        "yaml_structure",
168                        "tags or excessive nesting are unsupported",
169                    );
170                    return;
171                }
172                let mapping = is_mapping;
173                self.frames.push(Frame {
174                    anchor,
175                    mapping,
176                    values: Vec::new(),
177                    keys: BTreeMap::new(),
178                });
179            }
180            Event::MappingEnd | Event::SequenceEnd => {
181                let Some(frame) = self.frames.pop() else {
182                    self.fail(span, "yaml_structure", "unexpected container end");
183                    return;
184                };
185                let value = if frame.mapping {
186                    self.mapping(frame.values, span)
187                } else {
188                    Some(Value::Array(frame.values))
189                };
190                if let Some(value) = value {
191                    self.add(value, frame.anchor, span);
192                }
193            }
194            Event::Scalar(text, style, anchor, tag) => {
195                if tag.is_some() {
196                    self.fail(span, "yaml_tag", "explicit tags are unsupported");
197                    return;
198                }
199                let value = if style != ScalarStyle::Plain {
200                    Some(Value::String(text.into_owned()))
201                } else if text.is_empty() {
202                    Some(Value::Null)
203                } else {
204                    pharos_runtime_core::yaml::parse_yaml_text(&text)
205                        .or_else(|| Some(Value::String(text.into_owned())))
206                };
207                if let Some(value) = value {
208                    self.add(value, anchor, span);
209                }
210            }
211            Event::Alias(anchor) => {
212                self.aliases += 1;
213                if self.aliases > 20_000 {
214                    self.fail(span, "yaml_alias_limit", "too many aliases");
215                    return;
216                }
217                let Some(value) = self.anchors.get(&anchor).cloned() else {
218                    self.fail(span, "yaml_alias", "undefined or recursive alias");
219                    return;
220                };
221                self.add(value, 0, span);
222            }
223            _ => {}
224        }
225    }
226}
227/// Compiles one bounded database-authoring document into independent JSON values.
228pub fn parse(text: &str) -> Result<Value, YamlDiagnostic> {
229    let mut receiver = Receiver::default();
230    if text.len() > MAX_BYTES {
231        return Err(YamlDiagnostic {
232            code: "yaml_input_limit",
233            line: 1,
234            column: 1,
235            message: "database YAML input exceeds 8 MiB".to_owned(),
236        });
237    }
238    Parser::new_from_str(text)
239        .load(&mut receiver, true)
240        .map_err(|error| YamlDiagnostic {
241            code: "yaml_parse_error",
242            line: error.marker().line(),
243            column: error.marker().col() + 1,
244            message: error.to_string(),
245        })?;
246    if let Some(error) = receiver.error {
247        return Err(error);
248    }
249    receiver.result.ok_or(YamlDiagnostic {
250        code: "yaml_empty",
251        line: 1,
252        column: 1,
253        message: "database document is empty".to_owned(),
254    })
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    /// Merge overrides are explicit and expanded objects never share mutation state.
261    #[test]
262    fn independent_aliases_and_explicit_overrides() {
263        let mut value = parse("base: &base {kind: text, nullable: false}\nfield: {<<: *base, kind: integer}\ncopy: *base\n").unwrap();
264        assert_eq!(value["field"]["kind"], "integer");
265        value["copy"]["kind"] = Value::String("boolean".into());
266        assert_eq!(value["base"]["kind"], "text");
267    }
268    /// Rejects ambiguous, recursive, tagged and duplicate declarations with diagnostics.
269    #[test]
270    fn rejects_unsafe_source() {
271        for text in [
272            "a: 1\na: 2",
273            "a: &a [*a]",
274            "a: !unsafe value",
275            "1: value",
276            "---\na: 1\n---\nb: 2",
277            "a: &a {x: 1}\nb: &b {x: 2}\nc: {<<: [*a, *b]}",
278        ] {
279            assert!(parse(text).is_err(), "accepted {text}");
280        }
281        assert!(pharos_runtime_core::yaml::parse_yaml_text("a: &a {x: 1}\nb: *a").is_none());
282    }
283    /// Stops exponential alias expansion before a large document can be materialized.
284    #[test]
285    fn rejects_expansion_bombs() {
286        let mut text = "a0: &a0 [0, 0]\n".to_owned();
287        for index in 1..20 {
288            text.push_str(&format!(
289                "a{index}: &a{index} [*a{}, *a{}]\n",
290                index - 1,
291                index - 1
292            ));
293        }
294        assert!(parse(&text).is_err());
295    }
296}