1use crate::error::{Result, SanitizeError};
63use crate::processor::limits::DEFAULT_INPUT_SIZE;
64use crate::processor::profile::FieldRule;
65use crate::processor::{
66 find_field_signal, find_matching_rule, process_sub_content, replace_by_signal, replace_value,
67 FileTypeProfile, Processor,
68};
69use crate::store::MappingStore;
70
71struct KvConfig<'a> {
79 delimiter: &'a str,
80 comment_prefix: &'a str,
81 secondary_delimiters: &'a [&'a str],
82 value_strip_suffix: Option<&'a str>,
83 ignore_comments: bool,
84 profile: &'a FileTypeProfile,
85 store: &'a MappingStore,
86}
87
88enum LineState {
94 Normal,
95 Heredoc {
97 end_marker: String,
98 rule: FieldRule,
99 lines: Vec<String>,
100 strip_indent: bool,
104 },
105}
106
107pub struct KeyValueProcessor;
113
114impl Processor for KeyValueProcessor {
115 fn name(&self) -> &'static str {
116 "key_value"
117 }
118
119 fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
120 matches!(profile.processor.as_str(), "key_value" | "key-value")
121 }
122
123 fn process(
124 &self,
125 content: &[u8],
126 profile: &FileTypeProfile,
127 store: &MappingStore,
128 ) -> Result<Vec<u8>> {
129 if content.len() > DEFAULT_INPUT_SIZE {
130 return Err(SanitizeError::InputTooLarge {
131 size: content.len(),
132 limit: DEFAULT_INPUT_SIZE,
133 });
134 }
135
136 let text = String::from_utf8_lossy(content);
137 let delimiter = profile.options.get("delimiter").map_or("=", |s| s.as_str());
138 let comment_prefix = profile
139 .options
140 .get("comment_prefix")
141 .map_or("#", |s| s.as_str());
142 let secondary_delimiter_raw = profile
143 .options
144 .get("secondary_delimiter")
145 .map_or("", |s| s.as_str());
146 let secondary_delimiters: Vec<&str> = if secondary_delimiter_raw.is_empty() {
147 vec![]
148 } else {
149 secondary_delimiter_raw.split(',').collect()
150 };
151 let value_strip_suffix = profile
152 .options
153 .get("value_strip_suffix")
154 .map(|s| s.as_str());
155 let ignore_comments = profile
156 .options
157 .get("ignore_comments")
158 .is_some_and(|s| s == "true");
159
160 let cfg = KvConfig {
161 delimiter,
162 comment_prefix,
163 secondary_delimiters: &secondary_delimiters,
164 value_strip_suffix,
165 ignore_comments,
166 profile,
167 store,
168 };
169
170 let mut output = String::with_capacity(text.len());
171 let mut state = LineState::Normal;
172
173 for line in text.split('\n') {
174 process_line(line, &mut state, &mut output, &cfg)?;
175 }
176
177 while output.ends_with('\n') {
181 output.pop();
182 }
183 if text.ends_with('\n') {
184 output.push('\n');
185 }
186
187 Ok(output.into_bytes())
188 }
189}
190
191#[allow(clippy::too_many_lines)]
196fn process_line(
197 line: &str,
198 state: &mut LineState,
199 output: &mut String,
200 cfg: &KvConfig<'_>,
201) -> Result<()> {
202 match state {
203 LineState::Heredoc {
204 ref end_marker,
205 ref rule,
206 ref mut lines,
207 strip_indent,
208 } => {
209 if line.trim() == end_marker.as_str() {
210 let (content, stripped_indent) = if *strip_indent {
215 strip_min_indent(lines)
216 } else {
217 (lines.join("\n"), 0)
218 };
219 let processed = process_sub_content(&content, rule, cfg.store)?;
220 let final_content = if *strip_indent && stripped_indent > 0 {
221 reindent_content(&processed, stripped_indent)
222 } else {
223 processed
224 };
225 for processed_line in final_content.split('\n') {
226 output.push_str(processed_line);
227 output.push('\n');
228 }
229 output.push_str(line);
230 output.push('\n');
231 *state = LineState::Normal;
232 } else {
233 lines.push(line.to_owned());
234 }
235 }
236 LineState::Normal => {
237 let trimmed = line.trim();
238 if trimmed.is_empty() {
239 output.push_str(line);
240 output.push('\n');
241 return Ok(());
242 }
243 if trimmed.starts_with(cfg.comment_prefix) {
244 if !cfg.ignore_comments {
245 if let Some(prefix_pos) = line.find(cfg.comment_prefix) {
249 let prefix_end = prefix_pos + cfg.comment_prefix.len();
250 let comment_header = &line[..prefix_end];
251 let body = &line[prefix_end..];
252 if let Some(sanitized_body) = try_sanitize_kv_body(body, cfg)? {
253 output.push_str(comment_header);
254 output.push_str(&sanitized_body);
255 output.push('\n');
256 return Ok(());
257 }
258 }
259 }
260 output.push_str(line);
261 output.push('\n');
262 return Ok(());
263 }
264 let line_body = line.trim_start();
268 let indent_len = line.len() - line_body.len();
269 if let Some(delim_pos) = line_body.find(cfg.delimiter) {
270 let raw_key = &line[..indent_len + delim_pos];
272 let after_delim = &line_body[delim_pos + cfg.delimiter.len()..];
273 let key = line_body[..delim_pos].trim();
274 if let Some(rule) = find_matching_rule(key, cfg.profile) {
275 if rule.sub_processor.is_some() {
276 if let Some((marker, strip_indent)) = detect_heredoc(after_delim) {
277 output.push_str(line);
278 output.push('\n');
279 *state = LineState::Heredoc {
280 end_marker: marker,
281 rule: rule.clone(),
282 lines: Vec::new(),
283 strip_indent,
284 };
285 return Ok(());
286 }
287 let (quote_char, inner) = detect_quotes(after_delim.trim());
288 let processed = process_sub_content(inner, rule, cfg.store)?;
289 emit_replaced(
290 raw_key,
291 cfg.delimiter,
292 after_delim,
293 quote_char,
294 &processed,
295 output,
296 );
297 output.push('\n');
298 return Ok(());
299 }
300 let (quote_char, inner) = detect_quotes(after_delim.trim());
301 let (sanitize_inner, suffix) =
302 strip_value_suffix(inner, cfg.value_strip_suffix);
303 let replaced = replace_value(sanitize_inner, rule, cfg.store)?;
304 emit_kv_replacement(
305 raw_key,
306 cfg.delimiter,
307 after_delim,
308 quote_char,
309 &replaced,
310 suffix,
311 output,
312 );
313 output.push('\n');
314 return Ok(());
315 } else if let Some(sig) = find_field_signal(key, &cfg.profile.field_name_signals) {
316 let (quote_char, inner) = detect_quotes(after_delim.trim());
317 let (sanitize_inner, suffix) =
318 strip_value_suffix(inner, cfg.value_strip_suffix);
319 if let Some(replaced) = replace_by_signal(sanitize_inner, sig, cfg.store)? {
320 emit_kv_replacement(
321 raw_key,
322 cfg.delimiter,
323 after_delim,
324 quote_char,
325 &replaced,
326 suffix,
327 output,
328 );
329 output.push('\n');
330 return Ok(());
331 }
332 }
333 }
334 for &sec_delim in cfg.secondary_delimiters {
338 if let Some(delim_pos) = line.find(sec_delim) {
339 let raw_key = &line[..delim_pos];
340 let after_delim = &line[delim_pos + sec_delim.len()..];
341 let trimmed_key = raw_key.trim();
344 let (_, unquoted_key) = detect_quotes(trimmed_key);
345 if let Some(rule) = find_matching_rule(unquoted_key, cfg.profile) {
346 let (quote_char, inner, suffix) =
347 detect_quoted_value_with_suffix(after_delim);
348 let replaced = replace_value(inner, rule, cfg.store)?;
349 emit_replaced_with_suffix(
350 raw_key,
351 sec_delim,
352 after_delim,
353 quote_char,
354 &replaced,
355 suffix,
356 output,
357 );
358 output.push('\n');
359 return Ok(());
360 } else if let Some(sig) =
361 find_field_signal(unquoted_key, &cfg.profile.field_name_signals)
362 {
363 let (quote_char, inner, suffix) =
364 detect_quoted_value_with_suffix(after_delim);
365 if let Some(replaced) = replace_by_signal(inner, sig, cfg.store)? {
366 emit_replaced_with_suffix(
367 raw_key,
368 sec_delim,
369 after_delim,
370 quote_char,
371 &replaced,
372 suffix,
373 output,
374 );
375 output.push('\n');
376 return Ok(());
377 }
378 }
379 }
380 }
381 output.push_str(line);
382 output.push('\n');
383 }
384 }
385 Ok(())
386}
387
388fn strip_value_suffix<'a>(inner: &'a str, strip_suffix: Option<&'a str>) -> (&'a str, &'a str) {
395 match strip_suffix {
396 Some(sfx) if inner.ends_with(sfx) => (&inner[..inner.len() - sfx.len()], sfx),
397 _ => (inner, ""),
398 }
399}
400
401fn emit_kv_replacement(
405 raw_key: &str,
406 delimiter: &str,
407 after_delim: &str,
408 quote_char: Option<char>,
409 replaced: &str,
410 suffix: &str,
411 output: &mut String,
412) {
413 if suffix.is_empty() {
414 emit_replaced(
415 raw_key,
416 delimiter,
417 after_delim,
418 quote_char,
419 replaced,
420 output,
421 );
422 } else {
423 emit_replaced_with_suffix(
424 raw_key,
425 delimiter,
426 after_delim,
427 quote_char,
428 replaced,
429 suffix,
430 output,
431 );
432 }
433}
434
435fn try_sanitize_kv_body(body: &str, cfg: &KvConfig<'_>) -> Result<Option<String>> {
445 let body_trimmed = body.trim_start();
446 let indent_len = body.len() - body_trimmed.len();
447
448 if let Some(delim_pos) = body_trimmed.find(cfg.delimiter) {
450 let raw_key = &body[..indent_len + delim_pos];
451 let after_delim = &body_trimmed[delim_pos + cfg.delimiter.len()..];
452 let key = body_trimmed[..delim_pos].trim();
453 if let Some(rule) = find_matching_rule(key, cfg.profile) {
454 let (quote_char, inner) = detect_quotes(after_delim.trim());
455 let (sanitize_inner, suffix) = strip_value_suffix(inner, cfg.value_strip_suffix);
456 let replaced = replace_value(sanitize_inner, rule, cfg.store)?;
457 let mut out = String::new();
458 emit_kv_replacement(
459 raw_key,
460 cfg.delimiter,
461 after_delim,
462 quote_char,
463 &replaced,
464 suffix,
465 &mut out,
466 );
467 return Ok(Some(out));
468 } else if let Some(sig) = find_field_signal(key, &cfg.profile.field_name_signals) {
469 let (quote_char, inner) = detect_quotes(after_delim.trim());
470 let (sanitize_inner, suffix) = strip_value_suffix(inner, cfg.value_strip_suffix);
471 if let Some(replaced) = replace_by_signal(sanitize_inner, sig, cfg.store)? {
472 let mut out = String::new();
473 emit_kv_replacement(
474 raw_key,
475 cfg.delimiter,
476 after_delim,
477 quote_char,
478 &replaced,
479 suffix,
480 &mut out,
481 );
482 return Ok(Some(out));
483 }
484 }
485 }
486
487 for &sec_delim in cfg.secondary_delimiters {
489 if let Some(delim_pos) = body.find(sec_delim) {
490 let raw_key = &body[..delim_pos];
491 let after_delim = &body[delim_pos + sec_delim.len()..];
492 let trimmed_key = raw_key.trim();
493 let (_, unquoted_key) = detect_quotes(trimmed_key);
494 if let Some(rule) = find_matching_rule(unquoted_key, cfg.profile) {
495 let (quote_char, inner, suffix) = detect_quoted_value_with_suffix(after_delim);
496 let replaced = replace_value(inner, rule, cfg.store)?;
497 let mut out = String::new();
498 emit_replaced_with_suffix(
499 raw_key,
500 sec_delim,
501 after_delim,
502 quote_char,
503 &replaced,
504 suffix,
505 &mut out,
506 );
507 return Ok(Some(out));
508 } else if let Some(sig) =
509 find_field_signal(unquoted_key, &cfg.profile.field_name_signals)
510 {
511 let (quote_char, inner, suffix) = detect_quoted_value_with_suffix(after_delim);
512 if let Some(replaced) = replace_by_signal(inner, sig, cfg.store)? {
513 let mut out = String::new();
514 emit_replaced_with_suffix(
515 raw_key,
516 sec_delim,
517 after_delim,
518 quote_char,
519 &replaced,
520 suffix,
521 &mut out,
522 );
523 return Ok(Some(out));
524 }
525 }
526 }
527 }
528
529 Ok(None)
530}
531
532fn strip_min_indent(lines: &[String]) -> (String, usize) {
543 let min_indent = lines
544 .iter()
545 .filter(|l| !l.trim().is_empty())
546 .map(|l| l.len() - l.trim_start().len())
547 .min()
548 .unwrap_or(0);
549
550 if min_indent == 0 {
551 return (lines.join("\n"), 0);
552 }
553
554 let stripped: String = lines
555 .iter()
556 .map(|l| {
557 if l.trim().is_empty() {
558 l.as_str()
559 } else {
560 &l[min_indent.min(l.len())..]
561 }
562 })
563 .collect::<Vec<_>>()
564 .join("\n");
565
566 (stripped, min_indent)
567}
568
569fn reindent_content(content: &str, indent: usize) -> String {
574 let prefix = " ".repeat(indent);
575 content
576 .lines()
577 .map(|l| {
578 if l.trim().is_empty() {
579 l.to_owned()
580 } else {
581 format!("{prefix}{l}")
582 }
583 })
584 .collect::<Vec<_>>()
585 .join("\n")
586}
587
588fn emit_replaced(
596 raw_key: &str,
597 delimiter: &str,
598 after_delim: &str,
599 quote_char: Option<char>,
600 value: &str,
601 output: &mut String,
602) {
603 emit_replaced_with_suffix(
604 raw_key,
605 delimiter,
606 after_delim,
607 quote_char,
608 value,
609 "",
610 output,
611 );
612}
613
614fn emit_replaced_with_suffix(
621 raw_key: &str,
622 delimiter: &str,
623 after_delim: &str,
624 quote_char: Option<char>,
625 value: &str,
626 suffix: &str,
627 output: &mut String,
628) {
629 let ws = leading_whitespace(after_delim);
630 output.push_str(raw_key);
631 output.push_str(delimiter);
632 output.push_str(ws);
633 if let Some(q) = quote_char {
634 output.push(q);
635 output.push_str(value);
636 output.push(q);
637 } else {
638 output.push_str(value);
639 }
640 output.push_str(suffix);
641}
642
643fn detect_quoted_value_with_suffix(after_delim: &str) -> (Option<char>, &str, &str) {
650 let trimmed = after_delim.trim_start();
651 if let Some(&first) = trimmed.as_bytes().first() {
652 if first == b'\'' || first == b'"' {
653 let q = first as char;
654 if let Some(close_pos) = trimmed[1..].find(q) {
655 let inner = &trimmed[1..=close_pos];
657 let suffix = &trimmed[close_pos + 2..];
659 return (Some(q), inner, suffix);
660 }
661 }
662 }
663 (None, trimmed, "")
664}
665
666fn detect_heredoc(value: &str) -> Option<(String, bool)> {
677 let pos = value.find("<<")?;
678 let rest = &value[pos + 2..];
679
680 let (strip_indent, rest) = if let Some(r) = rest.strip_prefix('~') {
681 (true, r)
682 } else if let Some(r) = rest.strip_prefix('-') {
683 (false, r)
684 } else {
685 (false, rest)
686 };
687
688 let marker = if let Some(inner) = rest.strip_prefix('\'').and_then(|s| s.split('\'').next()) {
689 inner.to_owned()
690 } else if let Some(inner) = rest.strip_prefix('"').and_then(|s| s.split('"').next()) {
691 inner.to_owned()
692 } else {
693 let m: String = rest
695 .chars()
696 .take_while(|c| c.is_alphanumeric() || *c == '_')
697 .collect();
698 if m.is_empty() {
699 return None;
700 }
701 m
702 };
703
704 Some((marker, strip_indent))
705}
706
707fn leading_whitespace(s: &str) -> &str {
710 let trimmed = s.trim_start();
711 &s[..s.len() - trimmed.len()]
712}
713
714fn detect_quotes(value: &str) -> (Option<char>, &str) {
716 if value.len() >= 2 {
717 let first = value.as_bytes()[0];
718 let last = value.as_bytes()[value.len() - 1];
719 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
720 return (Some(first as char), &value[1..value.len() - 1]);
721 }
722 }
723 (None, value)
724}
725
726#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::category::Category;
734 use crate::generator::HmacGenerator;
735 use crate::processor::profile::FieldRule;
736 use crate::store::MappingStore;
737 use std::sync::Arc;
738
739 fn make_store() -> Arc<MappingStore> {
740 let gen = Arc::new(HmacGenerator::new([1u8; 32]));
741 Arc::new(MappingStore::new(gen, None))
742 }
743
744 fn make_profile(fields: Vec<FieldRule>) -> FileTypeProfile {
745 FileTypeProfile::new("key_value", fields)
746 }
747
748 fn process(content: &str, profile: &FileTypeProfile, store: &MappingStore) -> String {
749 let out = KeyValueProcessor
750 .process(content.as_bytes(), profile, store)
751 .unwrap();
752 String::from_utf8(out).unwrap()
753 }
754
755 #[test]
758 fn replaces_matched_key() {
759 let store = make_store();
760 let profile = make_profile(vec![
761 FieldRule::new("password").with_category(Category::Custom("password".into()))
762 ]);
763 let input = "password = secret123\n";
764 let out = process(input, &profile, &store);
765 assert!(out.starts_with("password = "));
766 assert!(!out.contains("secret123"));
767 }
768
769 #[test]
770 fn preserves_unmatched_key() {
771 let store = make_store();
772 let profile = make_profile(vec![FieldRule::new("password")]);
773 let input = "host = db.internal\n";
774 let out = process(input, &profile, &store);
775 assert_eq!(out, input);
776 }
777
778 #[test]
779 fn preserves_quotes() {
780 let store = make_store();
781 let profile = make_profile(vec![FieldRule::new("password")]);
782 let input = "password = \"secret\"\n";
783 let out = process(input, &profile, &store);
784 assert!(out.contains('"'));
785 assert!(!out.contains("secret"));
786 }
787
788 #[test]
789 fn preserves_single_quotes() {
790 let store = make_store();
791 let profile = make_profile(vec![FieldRule::new("key")]);
792 let input = "key = 'value'\n";
793 let out = process(input, &profile, &store);
794 assert!(out.contains('\''));
795 assert!(!out.contains("value"));
796 }
797
798 #[test]
799 fn preserves_comments_when_no_field_matches() {
800 let store = make_store();
801 let profile = make_profile(vec![]);
802 let input = "# this is a comment\nkey = val\n";
803 let out = process(input, &profile, &store);
804 assert!(out.contains("# this is a comment"));
805 }
806
807 #[test]
808 fn sanitizes_commented_out_field_by_default() {
809 let store = make_store();
810 let profile = make_profile(vec![
811 FieldRule::new("*password*").with_category(Category::Custom("password".into()))
812 ]);
813 let input = "# smtp_password = \"hunter2\"\n";
814 let out = process(input, &profile, &store);
815 assert!(
816 out.starts_with("# smtp_password = "),
817 "comment prefix preserved: {out}"
818 );
819 assert!(!out.contains("hunter2"), "secret should be replaced: {out}");
820 }
821
822 #[test]
823 fn sanitizes_commented_field_secondary_delimiter_arrow() {
824 let store = make_store();
825 let mut profile = make_profile(vec![
826 FieldRule::new("*secret*").with_category(Category::Custom("auth_token".into()))
827 ]);
828 profile
829 .options
830 .insert("secondary_delimiter".into(), "=>,:".into());
831 let input = "# 'client_secret' => 'THIS-IS-SECRET',\n";
832 let out = process(input, &profile, &store);
833 assert!(out.starts_with('#'), "comment prefix preserved: {out}");
834 assert!(
835 !out.contains("THIS-IS-SECRET"),
836 "secret should be replaced: {out}"
837 );
838 }
839
840 #[test]
841 fn sanitizes_commented_field_secondary_delimiter_colon() {
842 let store = make_store();
843 let mut profile = make_profile(vec![
844 FieldRule::new("*secret*").with_category(Category::Custom("auth_token".into()))
845 ]);
846 profile
847 .options
848 .insert("secondary_delimiter".into(), "=>,:".into());
849 let input = "# 'client_secret': 'THIS-IS-SECRET',\n";
850 let out = process(input, &profile, &store);
851 assert!(out.starts_with('#'), "comment prefix preserved: {out}");
852 assert!(
853 !out.contains("THIS-IS-SECRET"),
854 "secret should be replaced: {out}"
855 );
856 }
857
858 #[test]
859 fn ignore_comments_option_preserves_verbatim() {
860 let store = make_store();
861 let mut profile = make_profile(vec![
862 FieldRule::new("*password*").with_category(Category::Custom("password".into()))
863 ]);
864 profile
865 .options
866 .insert("ignore_comments".into(), "true".into());
867 let input = "# smtp_password = \"hunter2\"\n";
868 let out = process(input, &profile, &store);
869 assert_eq!(
870 out, input,
871 "with ignore_comments:true the line should be verbatim"
872 );
873 }
874
875 #[test]
876 fn preserves_blank_lines() {
877 let store = make_store();
878 let profile = make_profile(vec![]);
879 let input = "a = 1\n\nb = 2\n";
880 let out = process(input, &profile, &store);
881 assert_eq!(out, input);
882 }
883
884 #[test]
885 fn glob_pattern_matches_ruby_bracket_key() {
886 let store = make_store();
887 let profile =
888 make_profile(vec![FieldRule::new("*['smtp_password']")
889 .with_category(Category::Custom("password".into()))]);
890 let input = "gitlab_rails['smtp_password'] = \"secret\"\n";
891 let out = process(input, &profile, &store);
892 assert!(!out.contains("secret"));
893 assert!(out.contains('"'));
894 }
895
896 #[test]
899 fn detects_heredoc_single_quoted() {
900 let (marker, strip) = detect_heredoc("YAML.load <<-'EOS'").unwrap();
901 assert_eq!(marker, "EOS");
902 assert!(!strip, "<<- does not strip indent");
903 }
904
905 #[test]
906 fn detects_heredoc_double_quoted() {
907 let (marker, strip) = detect_heredoc("JSON.parse <<-\"END\"").unwrap();
908 assert_eq!(marker, "END");
909 assert!(!strip);
910 }
911
912 #[test]
913 fn detects_heredoc_squiggly() {
914 let (marker, strip) = detect_heredoc("<<~YAML").unwrap();
915 assert_eq!(marker, "YAML");
916 assert!(strip, "<<~ must signal strip_indent");
917 }
918
919 #[test]
920 fn detects_heredoc_no_modifier() {
921 let (marker, strip) = detect_heredoc("<<EOS").unwrap();
922 assert_eq!(marker, "EOS");
923 assert!(!strip);
924 }
925
926 #[test]
927 fn no_heredoc_for_plain_value() {
928 assert!(detect_heredoc("\"smtp.server\"").is_none());
929 assert!(detect_heredoc("nil").is_none());
930 }
931
932 #[test]
935 fn sub_processor_yaml_heredoc() {
936 let store = make_store();
937 let sub_fields = vec![
938 FieldRule::new("*.password").with_category(Category::Custom("password".into())),
939 FieldRule::new("*.bind_dn").with_category(Category::Custom("dn".into())),
940 ];
941 let profile = make_profile(vec![FieldRule::new("*['ldap_servers']")
942 .with_sub_processor("yaml")
943 .with_sub_fields(sub_fields)]);
944
945 let input = "\
946gitlab_rails['ldap_servers'] = YAML.load <<-'EOS'
947 main:
948 bind_dn: 'cn=admin,dc=example,dc=com'
949 password: 'real-ldap-password'
950EOS
951other_key = 'untouched'
952";
953 let out = process(input, &profile, &store);
954
955 assert!(out.contains("gitlab_rails['ldap_servers'] = YAML.load <<-'EOS'"));
957 assert!(out.contains("EOS"));
958
959 assert!(!out.contains("real-ldap-password"));
961 assert!(!out.contains("cn=admin,dc=example,dc=com"));
962
963 assert!(out.contains("other_key = 'untouched'"));
965 }
966
967 #[test]
968 fn sub_processor_yaml_heredoc_end_marker_indented() {
969 let store = make_store();
970 let sub_fields =
971 vec![FieldRule::new("*.secret").with_category(Category::Custom("s".into()))];
972 let profile = make_profile(vec![FieldRule::new("config")
973 .with_sub_processor("yaml")
974 .with_sub_fields(sub_fields)]);
975
976 let input = "\
977config = <<-'EOS'
978 app:
979 secret: 'mysecret'
980 EOS
981";
982 let out = process(input, &profile, &store);
983 assert!(!out.contains("mysecret"));
984 assert!(out.contains("EOS"));
985 }
986
987 #[test]
990 fn squiggly_heredoc_strips_and_restores_indent() {
991 let store = make_store();
995 let sub_fields =
996 vec![FieldRule::new("*.password").with_category(Category::Custom("password".into()))];
997 let profile = make_profile(vec![FieldRule::new("*['ldap_servers']")
998 .with_sub_processor("yaml")
999 .with_sub_fields(sub_fields)]);
1000
1001 let input = "\
1003gitlab_rails['ldap_servers'] = YAML.load <<~'EOS'
1004 main:
1005 password: 'real-ldap-password'
1006EOS
1007other_key = 'untouched'
1008";
1009 let out = process(input, &profile, &store);
1010
1011 assert!(
1013 !out.contains("real-ldap-password"),
1014 "secret must be replaced: {out}"
1015 );
1016
1017 let main_line = out
1020 .lines()
1021 .find(|l| l.trim_start().starts_with("main:"))
1022 .expect("main: line must exist in output");
1023 assert!(
1024 main_line.starts_with(" "),
1025 "indentation must be preserved for <<~ heredoc: {out}"
1026 );
1027
1028 assert!(
1030 out.contains("<<~'EOS'"),
1031 "heredoc opener must be preserved: {out}"
1032 );
1033 assert!(
1034 out.contains("\nEOS\n"),
1035 "end marker must be preserved: {out}"
1036 );
1037
1038 assert!(out.contains("other_key = 'untouched'"));
1040 }
1041
1042 #[test]
1043 fn squiggly_heredoc_strip_min_indent_ignores_blank_lines() {
1044 let lines = vec![
1046 " key1: val1".to_owned(),
1047 String::new(), " key2: val2".to_owned(),
1049 ];
1050 let (content, indent) = strip_min_indent(&lines);
1051 assert_eq!(indent, 2);
1052 assert_eq!(content, "key1: val1\n\nkey2: val2");
1053 }
1054
1055 #[test]
1056 fn reindent_content_roundtrips_strip() {
1057 let original_lines = vec![" main:".to_owned(), " password: replaced".to_owned()];
1058 let (stripped, indent) = strip_min_indent(&original_lines);
1059 let restored = reindent_content(&stripped, indent);
1060 assert!(restored.starts_with(" main:"), "first line: {restored}");
1062 assert!(
1063 restored.contains("\n password:"),
1064 "second line: {restored}"
1065 );
1066 }
1067
1068 #[test]
1071 fn sub_processor_inline_json_value() {
1072 let store = make_store();
1073 let sub_fields =
1074 vec![FieldRule::new("password").with_category(Category::Custom("p".into()))];
1075 let profile = make_profile(vec![FieldRule::new("config")
1076 .with_sub_processor("json")
1077 .with_sub_fields(sub_fields)]);
1078
1079 let input = "config = {\"password\": \"topsecret\"}\n";
1080 let out = process(input, &profile, &store);
1081 assert!(!out.contains("topsecret"));
1082 assert!(out.starts_with("config = "));
1083 }
1084
1085 #[test]
1088 fn sub_processor_unknown_returns_error() {
1089 let store = make_store();
1090 let profile = make_profile(vec![FieldRule::new("key")
1091 .with_sub_processor("hcl")
1092 .with_sub_fields(vec![])]);
1093 let input = "key = \"value\"\n";
1094 let result = KeyValueProcessor.process(input.as_bytes(), &profile, &store);
1095 assert!(result.is_err());
1096 }
1097
1098 #[test]
1101 fn field_rule_with_sub_processor() {
1102 let rule = FieldRule::new("*.data")
1103 .with_sub_processor("yaml")
1104 .with_sub_fields(vec![FieldRule::new("*.password")]);
1105 assert_eq!(rule.sub_processor.as_deref(), Some("yaml"));
1106 assert_eq!(rule.sub_fields.len(), 1);
1107 }
1108}