1use 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 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 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 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 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}
227pub 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 #[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 #[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 #[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}