1use crate::error::{Result, SanitizeError};
13use crate::processor::limits::{DEFAULT_DEPTH, YAML_INPUT_SIZE, YAML_NODE_COUNT};
14use crate::processor::{
15 build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
16};
17use crate::store::MappingStore;
18use saphyr_parser::{Event, Parser, ScalarStyle};
19use serde_yaml_ng::Value;
20
21fn quoted_scalar_end(src: &[u8], style: ScalarStyle) -> Option<usize> {
29 let quote = match style {
30 ScalarStyle::DoubleQuoted => b'"',
31 ScalarStyle::SingleQuoted => b'\'',
32 _ => return None,
33 };
34 if src.first() != Some("e) {
35 return None;
36 }
37 let mut i = 1;
38 while i < src.len() {
39 let b = src[i];
40 if style == ScalarStyle::DoubleQuoted && b == b'\\' {
42 i += 2;
43 continue;
44 }
45 if b == quote {
46 if style == ScalarStyle::SingleQuoted && src.get(i + 1) == Some("e) {
48 i += 2;
49 continue;
50 }
51 return Some(i + 1);
52 }
53 i += 1;
54 }
55 None
56}
57
58enum YamlFrame {
60 Mapping {
63 path: String,
64 expecting_key: bool,
65 current_key: Option<String>,
66 },
67 Sequence { path: String, key: String },
69}
70
71fn yaml_value_position(frames: &[YamlFrame]) -> (String, String) {
73 match frames.last() {
74 Some(YamlFrame::Mapping {
75 path, current_key, ..
76 }) => {
77 let key = current_key.clone().unwrap_or_default();
78 (build_path(path, &key), key)
79 }
80 Some(YamlFrame::Sequence { path, key }) => (path.clone(), key.clone()),
81 None => (String::new(), String::new()),
82 }
83}
84
85fn yaml_note_value_consumed(frames: &mut [YamlFrame]) {
88 if let Some(YamlFrame::Mapping { expecting_key, .. }) = frames.last_mut() {
89 *expecting_key = true;
90 }
91}
92
93fn yaml_scalar_replacement(token: &str, style: ScalarStyle, span_src: &[u8]) -> String {
103 match style {
104 ScalarStyle::Literal | ScalarStyle::Folded => {
105 let indent: String = span_src
106 .iter()
107 .take_while(|&&b| b == b' ' || b == b'\t')
108 .map(|&b| b as char)
109 .collect();
110 let trailing_nl = if span_src.last() == Some(&b'\n') {
111 "\n"
112 } else {
113 ""
114 };
115 format!("{indent}{token}{trailing_nl}")
116 }
117 _ => format!("\"{token}\""),
118 }
119}
120
121pub struct YamlProcessor;
123
124impl Processor for YamlProcessor {
125 fn name(&self) -> &'static str {
126 "yaml"
127 }
128
129 fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
130 if profile.processor == "yaml" {
131 return true;
132 }
133 let text = String::from_utf8_lossy(content);
135 let trimmed = text.trim_start();
136 trimmed.starts_with("---")
137 || trimmed.starts_with("- ")
138 || trimmed.starts_with('{')
139 || trimmed.contains(": ")
140 }
141
142 fn process(
143 &self,
144 content: &[u8],
145 profile: &FileTypeProfile,
146 store: &MappingStore,
147 ) -> Result<Vec<u8>> {
148 let text = crate::processor::check_size_and_decode(content, "YAML", YAML_INPUT_SIZE)?;
150
151 let mut value: Value =
152 serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::ParseError {
153 format: "YAML".into(),
154 message: format!("YAML parse error: {}", e),
155 })?;
156
157 let node_count = count_yaml_nodes(&value);
162 if node_count > YAML_NODE_COUNT {
163 return Err(SanitizeError::InputTooLarge {
164 size: node_count,
165 limit: YAML_NODE_COUNT,
166 });
167 }
168
169 walk_yaml(&mut value, "", profile, store, 0)?;
170
171 let output = serde_yaml_ng::to_string(&value).map_err(|e| {
172 SanitizeError::IoError(std::io::Error::other(format!("YAML serialize error: {e}")))
173 })?;
174
175 Ok(output.into_bytes())
176 }
177
178 fn process_to_edits(
185 &self,
186 content: &[u8],
187 profile: &FileTypeProfile,
188 store: &MappingStore,
189 ) -> Result<Option<Vec<Replacement>>> {
190 let text = crate::processor::check_size_and_decode(content, "YAML", YAML_INPUT_SIZE)?;
191 let mut edits = Vec::new();
192 let mut frames: Vec<YamlFrame> = Vec::new();
193
194 let char_to_byte: Option<Vec<usize>> = if text.is_ascii() {
201 None
202 } else {
203 Some(
204 text.char_indices()
205 .map(|(b, _)| b)
206 .chain(std::iter::once(text.len()))
207 .collect(),
208 )
209 };
210 let to_byte = |char_idx: usize| -> usize {
211 char_to_byte
212 .as_ref()
213 .map_or(char_idx, |m| m.get(char_idx).copied().unwrap_or(text.len()))
214 };
215
216 for event in Parser::new_from_str(text) {
217 let (event, span) = event.map_err(|e| SanitizeError::ParseError {
218 format: "YAML".into(),
219 message: format!("YAML parse error: {e}"),
220 })?;
221 match event {
222 Event::Scalar(value, style, _aid, _tag) => {
223 let is_key = matches!(
224 frames.last(),
225 Some(YamlFrame::Mapping {
226 expecting_key: true,
227 ..
228 })
229 );
230 if is_key {
231 if let Some(YamlFrame::Mapping {
232 expecting_key,
233 current_key,
234 ..
235 }) = frames.last_mut()
236 {
237 *current_key = Some(value.into_owned());
238 *expecting_key = false;
239 }
240 } else {
241 let (path, key) = yaml_value_position(&frames);
242 if let Some(token) = edit_token(&key, &path, &value, profile, store)? {
243 let start = to_byte(span.start.index());
244 let mut end = to_byte(span.end.index());
245 if let Some(real) = quoted_scalar_end(&content[start..end], style) {
250 end = start + real;
251 }
252 let repl = yaml_scalar_replacement(&token, style, &content[start..end]);
253 edits.push(Replacement {
254 start,
255 end,
256 value: repl,
257 });
258 }
259 yaml_note_value_consumed(&mut frames);
260 }
261 }
262 Event::MappingStart(..) => {
263 let (path, _key) = yaml_value_position(&frames);
264 frames.push(YamlFrame::Mapping {
265 path,
266 expecting_key: true,
267 current_key: None,
268 });
269 }
270 Event::SequenceStart(..) => {
271 let (path, key) = yaml_value_position(&frames);
272 frames.push(YamlFrame::Sequence { path, key });
273 }
274 Event::MappingEnd | Event::SequenceEnd => {
275 frames.pop();
276 yaml_note_value_consumed(&mut frames);
277 }
278 Event::Alias(_) => {
279 yaml_note_value_consumed(&mut frames);
282 }
283 _ => {}
284 }
285 }
286 Ok(Some(edits))
287 }
288}
289
290fn count_yaml_nodes(value: &Value) -> usize {
294 count_yaml_nodes_inner(value, 0)
295}
296
297fn count_yaml_nodes_inner(value: &Value, depth: usize) -> usize {
300 if depth > DEFAULT_DEPTH {
301 return 1; }
303 match value {
304 Value::Mapping(map) => {
305 1 + map
306 .iter()
307 .map(|(k, v)| {
308 count_yaml_nodes_inner(k, depth + 1) + count_yaml_nodes_inner(v, depth + 1)
309 })
310 .sum::<usize>()
311 }
312 Value::Sequence(seq) => {
313 1 + seq
314 .iter()
315 .map(|v| count_yaml_nodes_inner(v, depth + 1))
316 .sum::<usize>()
317 }
318 Value::Tagged(tagged) => 1 + count_yaml_nodes_inner(&tagged.value, depth + 1),
319 _ => 1, }
321}
322
323impl TreeNode for Value {
324 fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
325 where
326 F: FnMut(&str, &mut Self) -> Result<()>,
327 {
328 if let Self::Mapping(map) = self {
329 let keys: Vec<Self> = map.keys().cloned().collect();
330 for key in keys {
331 let key_str = yaml_key_to_string(&key);
332 if let Some(v) = map.get_mut(&key) {
333 f(&key_str, v)?;
334 }
335 }
336 }
337 Ok(())
338 }
339
340 fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
341 where
342 F: FnMut(&mut Self) -> Result<()>,
343 {
344 if let Self::Sequence(seq) = self {
345 for item in seq.iter_mut() {
346 f(item)?;
347 }
348 }
349 Ok(())
350 }
351
352 fn as_str_mut(&mut self) -> Option<&mut String> {
353 if let Self::String(s) = self {
354 Some(s)
355 } else {
356 None
357 }
358 }
359
360 fn is_scalar(&self) -> bool {
361 matches!(self, Self::Number(_) | Self::Bool(_))
362 }
363
364 fn scalar_to_string(&self) -> String {
365 yaml_scalar_to_string(self)
366 }
367
368 fn set_string(&mut self, s: String) {
369 *self = Self::String(s);
370 }
371}
372
373fn walk_yaml(
375 value: &mut Value,
376 prefix: &str,
377 profile: &FileTypeProfile,
378 store: &MappingStore,
379 depth: usize,
380) -> Result<()> {
381 walk_tree(value, prefix, profile, store, depth, "YAML")
382}
383
384fn yaml_key_to_string(key: &Value) -> String {
385 match key {
386 Value::String(s) => s.clone(),
387 Value::Number(n) => n.to_string(),
388 Value::Bool(b) => b.to_string(),
389 _ => format!("{:?}", key),
390 }
391}
392
393fn yaml_scalar_to_string(v: &Value) -> String {
394 match v {
395 Value::String(s) => s.clone(),
396 Value::Number(n) => n.to_string(),
397 Value::Bool(b) => b.to_string(),
398 _ => String::new(),
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::category::Category;
406 use crate::generator::HmacGenerator;
407 use crate::processor::profile::FieldRule;
408 use std::sync::Arc;
409
410 fn make_store() -> MappingStore {
411 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
412 MappingStore::new(gen, None)
413 }
414
415 #[test]
416 fn basic_yaml_replacement() {
417 let store = make_store();
418 let proc = YamlProcessor;
419
420 let content = b"database:\n host: db.corp.com\n password: s3cret\nport: 5432\n";
421 let profile = FileTypeProfile::new(
422 "yaml",
423 vec![
424 FieldRule::new("database.password").with_category(Category::Custom("pw".into())),
425 FieldRule::new("database.host").with_category(Category::Hostname),
426 ],
427 );
428
429 let result = proc.process(content, &profile, &store).unwrap();
430 let out = String::from_utf8(result).unwrap();
431
432 assert!(!out.contains("s3cret"));
433 assert!(!out.contains("db.corp.com"));
434 assert!(out.contains("5432"));
436 }
437
438 #[test]
439 fn can_handle_by_profile_name() {
440 let proc = YamlProcessor;
441 let profile = FileTypeProfile::new("yaml", vec![]).with_extension(".yaml");
442 assert!(proc.can_handle(b"anything", &profile));
443 }
444
445 #[test]
446 fn can_handle_detects_document_marker() {
447 let proc = YamlProcessor;
448 let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
449 assert!(proc.can_handle(b"---\nkey: value\n", &profile));
450 }
451
452 #[test]
453 fn can_handle_detects_key_value_heuristic() {
454 let proc = YamlProcessor;
455 let profile = FileTypeProfile::new("other", vec![]).with_extension(".conf");
456 assert!(proc.can_handle(b"host: localhost\nport: 5432\n", &profile));
457 }
458
459 #[test]
460 fn can_handle_detects_sequence_heuristic() {
461 let proc = YamlProcessor;
462 let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
463 assert!(proc.can_handle(b"- item1\n- item2\n", &profile));
464 }
465
466 #[test]
467 fn can_handle_rejects_plaintext() {
468 let proc = YamlProcessor;
469 let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
470 assert!(!proc.can_handle(b"just plain text with no yaml markers", &profile));
471 }
472
473 #[test]
474 fn non_string_scalars_not_targeted_pass_through() {
475 let store = make_store();
476 let proc = YamlProcessor;
477 let content = b"enabled: true\ncount: 42\nsecret: hunter2\n";
479 let profile = FileTypeProfile::new(
480 "yaml",
481 vec![FieldRule::new("secret").with_category(Category::Custom("pw".into()))],
482 );
483 let result = proc.process(content, &profile, &store).unwrap();
484 let out = String::from_utf8(result).unwrap();
485 assert!(!out.contains("hunter2"), "secret must be replaced");
486 assert!(out.contains("42"), "integer must be preserved");
487 }
488
489 #[test]
490 fn deeply_nested_yaml_replaced() {
491 let store = make_store();
492 let proc = YamlProcessor;
493 let content = b"a:\n b:\n c:\n secret: hunter2\n";
494 let profile = FileTypeProfile::new(
495 "yaml",
496 vec![FieldRule::new("a.b.c.secret").with_category(Category::Custom("pw".into()))],
497 );
498 let result = proc.process(content, &profile, &store).unwrap();
499 let out = String::from_utf8(result).unwrap();
500 assert!(!out.contains("hunter2"));
501 assert!(out.contains("secret:"));
504 let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
505 assert!(parsed["a"]["b"]["c"]["secret"].as_str().is_some());
506 }
507
508 #[test]
509 fn invalid_utf8_returns_parse_error() {
510 let store = make_store();
511 let proc = YamlProcessor;
512 let bad = b"\xff\xfe invalid";
513 let profile = FileTypeProfile::new("yaml", vec![]);
514 let err = proc.process(bad, &profile, &store).unwrap_err();
515 assert!(matches!(
516 err,
517 crate::error::SanitizeError::ParseError { .. }
518 ));
519 }
520
521 #[test]
522 fn invalid_yaml_returns_parse_error() {
523 let store = make_store();
524 let proc = YamlProcessor;
525 let bad = b"key: [unclosed";
526 let profile = FileTypeProfile::new("yaml", vec![]);
527 let err = proc.process(bad, &profile, &store).unwrap_err();
528 assert!(matches!(
529 err,
530 crate::error::SanitizeError::ParseError { .. }
531 ));
532 }
533
534 #[test]
535 fn yaml_sequence_traversal() {
536 let store = make_store();
537 let proc = YamlProcessor;
538
539 let content = b"users:\n - email: a@b.com\n - email: c@d.com\n";
540 let profile = FileTypeProfile::new(
541 "yaml",
542 vec![FieldRule::new("users.email").with_category(Category::Email)],
543 );
544
545 let result = proc.process(content, &profile, &store).unwrap();
546 let out = String::from_utf8(result).unwrap();
547
548 assert!(!out.contains("a@b.com"));
549 assert!(!out.contains("c@d.com"));
550 assert!(out.contains("users:"));
552 let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
553 assert_eq!(parsed["users"].as_sequence().unwrap().len(), 2);
554 }
555
556 #[test]
561 fn edits_redact_all_scalar_styles_and_preserve_comments() {
562 let store = make_store();
563 let proc = YamlProcessor;
564 let content = b"# top\ndb:\n a: plain-SEC1 # inline\n b: \"dq-SEC2\"\n c: 'sq-SEC3'\n d: \"x\\\"y-SEC4\"\n host: keep.local\n";
565 let profile = FileTypeProfile::new(
566 "yaml",
567 vec![
568 FieldRule::new("db.a").with_category(Category::Custom("k".into())),
569 FieldRule::new("db.b").with_category(Category::Custom("k".into())),
570 FieldRule::new("db.c").with_category(Category::Custom("k".into())),
571 FieldRule::new("db.d").with_category(Category::Custom("k".into())),
572 ],
573 );
574 let edits = proc
575 .process_to_edits(content, &profile, &store)
576 .unwrap()
577 .unwrap();
578 let out = crate::processor::apply_edits(content, edits);
579 let text = String::from_utf8(out).unwrap();
580 for leak in ["SEC1", "SEC2", "SEC3", "SEC4"] {
581 assert!(!text.contains(leak), "leaked {leak}: {text}");
582 }
583 assert!(text.contains("# top"), "top comment dropped: {text}");
584 assert!(text.contains("# inline"), "inline comment dropped: {text}");
585 assert!(
586 text.contains("host: keep.local"),
587 "non-secret changed: {text}"
588 );
589 assert!(
591 serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&text).is_ok(),
592 "invalid YAML: {text}"
593 );
594 }
595
596 #[test]
600 fn edits_keep_block_scalars_valid() {
601 let store = make_store();
602 let proc = YamlProcessor;
603 let content = b"lit: |\n line1-SEC1\n line2-SEC2\nfold: >\n folded-SEC3\nnext: keep\n";
604 let profile = FileTypeProfile::new(
605 "yaml",
606 vec![
607 FieldRule::new("lit").with_category(Category::Custom("k".into())),
608 FieldRule::new("fold").with_category(Category::Custom("k".into())),
609 ],
610 );
611 let edits = proc
612 .process_to_edits(content, &profile, &store)
613 .unwrap()
614 .unwrap();
615 let out = crate::processor::apply_edits(content, edits);
616 let text = String::from_utf8(out).unwrap();
617 for leak in ["SEC1", "SEC2", "SEC3"] {
618 assert!(!text.contains(leak), "leaked {leak}: {text}");
619 }
620 assert!(text.contains("fold:"), "fold key absorbed: {text}");
622 assert!(text.contains("next: keep"), "next key absorbed: {text}");
623 let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text).unwrap();
624 assert_eq!(parsed["next"].as_str(), Some("keep"));
625 }
626}