1use serde::Deserialize;
20use serde::de::DeserializeOwned;
21
22use crate::error::CoreError;
23
24pub const SCHEMA_VERSION: u32 = 1;
26
27pub const YAML_MAX_BYTES: usize = 1_048_576;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SchemaVersion;
34
35impl<'de> Deserialize<'de> for SchemaVersion {
36 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
37 match u32::deserialize(d)? {
38 SCHEMA_VERSION => Ok(Self),
39 v => Err(serde::de::Error::custom(format!(
40 "unsupported schema_version {v}; this ign reads {SCHEMA_VERSION}"
41 ))),
42 }
43 }
44}
45
46#[derive(Deserialize)]
50struct SchemaProbe {
51 #[allow(dead_code)]
52 schema_version: SchemaVersion,
53}
54
55pub fn strict_parse<T: DeserializeOwned>(file: &str, bytes: &[u8]) -> Result<T, CoreError> {
61 if bytes.len() > YAML_MAX_BYTES {
62 return Err(CoreError::deploy_tree_invalid(
63 file,
64 None,
65 None,
66 format!(
67 "file is {} bytes; the YAML size cap is {YAML_MAX_BYTES} bytes",
68 bytes.len()
69 ),
70 ));
71 }
72 if let Some(at) = bytes.iter().position(|b| *b == b'\r') {
73 let (line, column) = position_of(bytes, at);
74 return Err(CoreError::deploy_tree_invalid(
75 file,
76 Some(line),
77 Some(column),
78 "carriage return (0x0D); save the file with LF line endings",
79 ));
80 }
81 let text = std::str::from_utf8(bytes).map_err(|e| {
82 let (line, column) = position_of(bytes, e.valid_up_to());
83 CoreError::deploy_tree_invalid(file, Some(line), Some(column), "file is not valid UTF-8")
84 })?;
85 yaml_serde::from_str::<SchemaProbe>(text).map_err(|e| {
86 let mapped = map_error(file, &e);
87 relocate_schema_error(file, text, mapped)
88 })?;
89 yaml_serde::from_str::<T>(text).map_err(|e| map_error(file, &e))
90}
91
92fn relocate_schema_error(file: &str, text: &str, err: CoreError) -> CoreError {
96 let CoreError::DeployTreeInvalid(details) = &err else {
97 return err;
98 };
99 let Some(finding) = details.findings.first() else {
100 return err;
101 };
102 if !finding.message.starts_with("unsupported schema_version") {
103 return err;
104 }
105 match text.lines().position(|l| l.starts_with("schema_version:")) {
106 Some(i) => {
107 CoreError::deploy_tree_invalid(file, Some(i + 1), Some(1), finding.message.clone())
108 }
109 None => err,
110 }
111}
112
113fn position_of(bytes: &[u8], at: usize) -> (usize, usize) {
115 let before = &bytes[..at.min(bytes.len())];
116 let line = before.iter().filter(|b| **b == b'\n').count() + 1;
117 let line_start = before
118 .iter()
119 .rposition(|b| *b == b'\n')
120 .map_or(0, |i| i + 1);
121 (line, before.len() - line_start + 1)
122}
123
124fn map_error(file: &str, e: &yaml_serde::Error) -> CoreError {
125 let text = e.to_string();
126 let message = scrub_values(strip_position(&text));
127 let positionless = ["missing field", "duplicate field"]
128 .iter()
129 .any(|p| message.starts_with(p) || message.contains(&format!(": {p}")));
130 let (line, column) = if positionless {
131 (None, None)
132 } else {
133 match e.location() {
134 Some(loc) => (Some(loc.line()), Some(loc.column())),
135 None => (None, None),
136 }
137 };
138 CoreError::deploy_tree_invalid(file, line, column, message)
139}
140
141fn strip_position(msg: &str) -> &str {
144 match msg.rfind(" at line ") {
145 Some(i) if msg[i..].contains(" column ") => &msg[..i],
146 _ => msg,
147 }
148}
149
150fn scrub_values(msg: &str) -> String {
163 scrub_invalid(&scrub_variant(msg))
164}
165
166fn scrub_variant(msg: &str) -> String {
171 const LEAD: &str = "unknown variant ";
172 if msg.contains("deserializing nested enum in ") {
173 return "deserializing nested enum from YAML is not supported".to_string();
174 }
175 let Some(at) = msg.find(LEAD) else {
176 return msg.to_string();
177 };
178 let head = &msg[..at + LEAD.len() - 1]; match msg.rfind(", expected ") {
180 Some(end) if end >= at + LEAD.len() => format!("{head}{}", &msg[end..]),
181 _ => head.to_string(),
182 }
183}
184
185fn scrub_invalid(msg: &str) -> String {
188 const LEADS: [&str; 2] = ["invalid type: ", "invalid value: "];
189 const KINDS: [&str; 5] = [
190 "string ",
191 "integer ",
192 "floating point ",
193 "boolean ",
194 "character ",
195 ];
196 for lead in LEADS {
197 let Some(at) = msg.find(lead) else { continue };
198 let after_lead = at + lead.len();
199 let rest = &msg[after_lead..];
200 for kind in KINDS {
201 let Some(value_part) = rest.strip_prefix(kind) else {
202 continue;
203 };
204 let value_len = quoted_len(value_part);
205 if value_len == 0 {
206 continue;
207 }
208 let head = &msg[..after_lead + kind.len() - 1]; let tail = &value_part[value_len..];
210 return format!("{head}{tail}");
211 }
212 }
213 msg.to_string()
214}
215
216fn quoted_len(s: &str) -> usize {
219 let mut chars = s.char_indices();
220 let Some((_, open)) = chars.next() else {
221 return 0;
222 };
223 if !matches!(open, '"' | '`' | '\'') {
224 return 0;
225 }
226 let mut escaped = false;
227 for (i, c) in chars {
228 if escaped {
229 escaped = false;
230 } else if c == '\\' && open == '"' {
231 escaped = true;
232 } else if c == open {
233 return i + c.len_utf8();
234 }
235 }
236 s.len()
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::error::CoreError;
244 use std::time::{Duration, Instant};
245
246 #[derive(Debug, Deserialize, PartialEq)]
247 #[serde(deny_unknown_fields)]
248 struct Probe {
249 schema_version: SchemaVersion,
250 #[serde(default)]
251 port: Option<u16>,
252 #[serde(default)]
253 nested: Option<Nested>,
254 }
255
256 #[derive(Debug, Deserialize, PartialEq)]
257 #[serde(deny_unknown_fields)]
258 struct Nested {
259 #[serde(default)]
260 a: Option<String>,
261 }
262
263 fn err(file: &str, yaml: &str) -> String {
264 strict_parse::<Probe>(file, yaml.as_bytes())
265 .expect_err("must fail")
266 .to_string()
267 }
268
269 fn finding(e: CoreError) -> crate::error::DeployFinding {
270 match e {
271 CoreError::DeployTreeInvalid(d) => d.findings.into_iter().next().expect("one finding"),
272 other => panic!("expected deploy_tree_invalid, got {other}"),
273 }
274 }
275
276 fn fail(yaml: &str) -> crate::error::DeployFinding {
277 finding(strict_parse::<Probe>("m.yaml", yaml.as_bytes()).expect_err("must fail"))
278 }
279
280 #[test]
281 fn unknown_top_level_field_has_position() {
282 let f = fail("schema_version: 1\nbogus: 2\n");
283 assert_eq!((f.line, f.column), (Some(2), Some(1)));
284 assert!(f.message.contains("bogus"), "{}", f.message);
285 assert!(!f.message.contains(" at line "), "{}", f.message);
286 assert_eq!(f.file, "m.yaml");
287 }
288
289 #[test]
290 fn unknown_nested_field_has_position() {
291 let f = fail("schema_version: 1\nnested:\n a: x\n extra: 1\n");
292 assert_eq!((f.line, f.column), (Some(4), Some(3)));
293 assert!(f.message.contains("extra"), "{}", f.message);
294 }
295
296 #[test]
297 fn wrong_type_is_positioned_and_scrubbed() {
298 let f = fail("schema_version: 1\nport: s3cr3t-canary\n");
299 assert_eq!(f.line, Some(2));
300 assert!(!f.message.contains("s3cr3t-canary"), "{}", f.message);
301 let shown = err("m.yaml", "schema_version: 1\nport: s3cr3t-canary\n");
302 assert!(!shown.contains("s3cr3t-canary"), "{shown}");
303
304 let shown = err("m.yaml", "schema_version: 1\nport: 'a\"b\\\"c-canary2'\n");
306 assert!(!shown.contains("canary2"), "{shown}");
307
308 let shown = err("m.yaml", "schema_version: 1\nport: 99999\n");
310 assert!(!shown.contains("99999"), "{shown}");
311 }
312
313 #[test]
314 fn enum_canary_is_scrubbed_but_variants_are_kept() {
315 #[derive(Debug, Deserialize, PartialEq)]
316 #[serde(rename_all = "lowercase")]
317 enum Kind {
318 Windows,
319 Linux,
320 }
321 #[derive(Debug, Deserialize)]
322 #[serde(deny_unknown_fields)]
323 struct WithEnum {
324 #[allow(dead_code)]
325 schema_version: SchemaVersion,
326 #[allow(dead_code)]
327 kind: Kind,
328 }
329 let run = |yaml: &str| {
330 let e = strict_parse::<WithEnum>("t.yaml", yaml.as_bytes()).expect_err("bad variant");
331 (e.to_string(), finding(e))
332 };
333
334 let (shown, f) = run("schema_version: 1\nkind: s3cr3t-canary\n");
335 assert!(!shown.contains("s3cr3t-canary"), "{shown}");
336 assert_eq!(f.line, Some(2));
337 assert!(shown.contains("unknown variant"), "{shown}");
338 assert!(shown.contains("expected `windows` or `linux`"), "{shown}");
339
340 let (shown, _) = run("schema_version: 1\nkind: 'evil`, expected `windows`-canary3 `x'\n");
342 assert!(!shown.contains("canary3"), "{shown}");
343 assert!(shown.contains("expected `windows` or `linux`"), "{shown}");
344
345 let (shown, _) = run("schema_version: 1\nkind: !tagcanary4 {a: 1}\n");
347 assert!(!shown.contains("tagcanary4"), "{shown}");
348
349 let (shown, _) = run("schema_version: 1\nkind: 8675309\n");
351 assert!(!shown.contains("8675309"), "{shown}");
352 }
353
354 #[test]
355 fn wrong_schema_version_is_positioned() {
356 let f = fail("schema_version: 2\n");
357 assert_eq!(f.message, "unsupported schema_version 2; this ign reads 1");
358 assert_eq!(f.line, Some(1));
359 let f = fail("bogus: 1\nschema_version: 2\n");
361 assert!(f.message.starts_with("unsupported schema_version 2"));
362 assert_eq!(f.line, Some(2));
363 }
364
365 #[test]
366 fn syntax_error_is_positioned() {
367 let f = fail("schema_version: 1\nnested:\n\ta: x\n");
368 assert_eq!(f.line, Some(3));
369 assert!(f.column.is_some());
370 }
371
372 #[test]
373 fn missing_and_duplicate_fields_name_file_only() {
374 let missing = fail("port: 1\n");
375 assert_eq!((missing.line, missing.column), (None, None));
376 assert!(
377 missing.message.contains("missing field"),
378 "{}",
379 missing.message
380 );
381 let shown = err("targets/prod.yaml", "port: 1\n");
382 assert!(shown.contains("targets/prod.yaml: "), "{shown}");
383
384 let dup = fail("schema_version: 1\nport: 1\nport: 2\n");
385 assert_eq!((dup.line, dup.column), (None, None));
386 assert!(dup.message.contains("duplicate field"), "{}", dup.message);
387 }
388
389 #[test]
390 fn anchors_and_aliases_are_accepted() {
391 let ok: Probe = strict_parse(
392 "m.yaml",
393 b"schema_version: 1\nnested: &n\n a: x\nport: 80\n",
394 )
395 .expect("anchors parse");
396 assert_eq!(ok.port, Some(80));
397 #[derive(Debug, Deserialize)]
399 struct Loose {
400 #[allow(dead_code)]
401 schema_version: SchemaVersion,
402 a: String,
403 b: String,
404 }
405 let ok: Loose =
406 strict_parse("m.yaml", b"schema_version: 1\na: &x hello\nb: *x\n").expect("alias");
407 assert_eq!((ok.a.as_str(), ok.b.as_str()), ("hello", "hello"));
408 }
409
410 #[test]
411 fn alias_bomb_is_refused() {
412 let mut yaml = String::from(
416 "schema_version: 1\nl0: &l0 [\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\"]\n",
417 );
418 for level in 1..=9 {
419 let refs = vec![format!("*l{}", level - 1); 9].join(",");
420 yaml.push_str(&format!("l{level}: &l{level} [{refs}]\n"));
421 }
422 #[derive(Debug, Deserialize)]
423 #[serde(deny_unknown_fields)]
424 struct Consumes {
425 #[allow(dead_code)]
426 schema_version: SchemaVersion,
427 #[allow(dead_code)]
428 l0: serde_json::Value,
429 #[allow(dead_code)]
430 l1: serde_json::Value,
431 #[allow(dead_code)]
432 l2: serde_json::Value,
433 #[allow(dead_code)]
434 l3: serde_json::Value,
435 #[allow(dead_code)]
436 l4: serde_json::Value,
437 #[allow(dead_code)]
438 l5: serde_json::Value,
439 #[allow(dead_code)]
440 l6: serde_json::Value,
441 #[allow(dead_code)]
442 l7: serde_json::Value,
443 #[allow(dead_code)]
444 l8: serde_json::Value,
445 #[allow(dead_code)]
446 l9: serde_json::Value,
447 }
448 let started = Instant::now();
449 let r = strict_parse::<Consumes>("bomb.yaml", yaml.as_bytes());
450 assert!(
451 started.elapsed() < Duration::from_secs(5),
452 "bomb took {:?}",
453 started.elapsed()
454 );
455 let shown = r.expect_err("the bomb must be refused").to_string();
456 assert!(shown.contains("bomb.yaml"), "{shown}");
457 assert!(shown.contains("repetition limit exceeded"), "{shown}");
458 assert_eq!(
459 CoreError::DEPLOY_SLUGS[0],
460 strict_parse::<Consumes>("bomb.yaml", yaml.as_bytes())
461 .expect_err("again")
462 .code()
463 );
464 }
465
466 #[test]
467 fn size_cap_boundary() {
468 let head = b"schema_version: 1\n#";
470 let mut at_cap = head.to_vec();
471 at_cap.resize(YAML_MAX_BYTES, b'x');
472 assert_eq!(at_cap.len(), YAML_MAX_BYTES);
473 strict_parse::<Probe>("m.yaml", &at_cap).expect("exactly the cap parses");
474
475 at_cap.push(b'x');
476 let shown = strict_parse::<Probe>("m.yaml", &at_cap)
477 .expect_err("over the cap")
478 .to_string();
479 assert!(shown.contains("m.yaml"), "{shown}");
480 assert!(shown.contains("1048576"), "{shown}");
481 assert!(shown.contains("1048577"), "{shown}");
482 }
483
484 #[test]
485 fn carriage_return_is_refused_with_line() {
486 let f = fail("schema_version: 1\r\n");
487 assert_eq!((f.line, f.column), (Some(1), Some(18)));
488 assert!(f.message.contains("0x0D"), "{}", f.message);
489 let f = fail("schema_version: 1\nport: 1\r\n");
490 assert_eq!(f.line, Some(2));
491 }
492
493 #[test]
494 fn non_utf8_is_refused() {
495 let e = strict_parse::<Probe>("m.yaml", b"schema_version: 1\n# \xff\xfe\n")
496 .expect_err("invalid utf-8");
497 let f = finding(e);
498 assert!(f.message.contains("UTF-8"), "{}", f.message);
499 assert_eq!(f.line, Some(2));
500 }
501
502 #[test]
503 fn strict_parse_is_pure() {
504 for yaml in [
505 "schema_version: 1\nport: 80\n",
506 "schema_version: 1\nbogus: 1\n",
507 "schema_version: 9\n",
508 "port: 1\n",
509 ] {
510 let a = strict_parse::<Probe>("m.yaml", yaml.as_bytes());
511 let b = strict_parse::<Probe>("m.yaml", yaml.as_bytes());
512 assert_eq!(format!("{a:?}"), format!("{b:?}"), "{yaml:?}");
513 }
514 }
515}