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