Skip to main content

scour_secrets/processor/
key_value.rs

1//! Key-value processor for `gitlab.rb`-style configuration files.
2//!
3//! Handles files with lines of the form:
4//!
5//! ```text
6//! key = "value"
7//! key = 'value'
8//! key = value
9//! # comment lines are preserved
10//! ```
11//!
12//! The delimiter, comment prefix, and quoting style are configurable
13//! via the profile's `options` map.
14//!
15//! # Profile Options
16//!
17//! | Key              | Default | Description                                  |
18//! |------------------|---------|----------------------------------------------|
19//! | `delimiter`           | `"="`   | The key-value separator.                     |
20//! | `secondary_delimiter` | *(none)*| Optional additional delimiter(s) tried when  |
21//! |                       |         | the primary delimiter's key does not match   |
22//! |                       |         | any field rule. Comma-separate multiple      |
23//! |                       |         | values (e.g. `"=>,:"`). Surrounding quotes  |
24//! |                       |         | are stripped from the key before matching,   |
25//! |                       |         | and any suffix after the value (e.g. a      |
26//! |                       |         | trailing `,`) is preserved in the output.    |
27//! |                       |         | Useful for Ruby hash literals that use `=>`  |
28//! |                       |         | or `:` alongside a `=`-delimited file.       |
29//! | `comment_prefix`      | `"#"`   | Lines starting with this (after whitespace)  |
30//! |                  |         | are treated as comments.                     |
31//! | `ignore_comments`     | `false` | When `true`, comment lines are preserved     |
32//! |                  |         | verbatim and never sanitized. By default,    |
33//! |                  |         | field rules are applied to commented-out     |
34//! |                  |         | key-value lines so that secrets left behind  |
35//! |                  |         | in comments are still replaced.              |
36//! | `value_strip_suffix`  | *(none)*| Strip this suffix from value before          |
37//! |                       |         | sanitizing and re-append it afterwards.      |
38//! |                       |         | Use `";"` for nginx-style `key value;` files.|
39//!
40//! # Heredoc / Sub-processor Support
41//!
42//! When a matched field rule has `sub_processor` set and the value is a
43//! Ruby-style heredoc (`<<-'EOS'`, `<<~EOS`, etc.), the processor switches
44//! into collection mode: it accumulates heredoc lines until the end marker,
45//! then delegates the collected content to the named sub-processor using the
46//! rule's `sub_fields`. This allows structured content embedded inside
47//! key-value files (e.g. YAML inside `gitlab.rb`) to be sanitized at the
48//! field level rather than relying solely on the streaming scanner.
49//!
50//! For non-heredoc values with `sub_processor`, the value (after quote
51//! stripping) is passed directly to the sub-processor.
52//!
53//! # Formatting Preservation
54//!
55//! - Blank lines and indentation are preserved verbatim.
56//! - Comment lines are preserved verbatim when no field rule matches their body,
57//!   or when `ignore_comments: true` is set in the profile options.
58//! - The original quoting style (single, double, or unquoted) is kept.
59//! - Whitespace around the delimiter is preserved where possible.
60//! - Heredoc opening and closing marker lines are preserved verbatim.
61
62use 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
71// ---------------------------------------------------------------------------
72// Per-file configuration (constant across all lines in one processing call)
73// ---------------------------------------------------------------------------
74
75/// Bundles the per-file options that are constant for every line in a
76/// `process_line` invocation, so the call site stays readable and adding
77/// new options doesn't widen the function signature.
78struct 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
88// ---------------------------------------------------------------------------
89// Internal state machine
90// ---------------------------------------------------------------------------
91
92/// Processing state for the line-by-line loop.
93enum LineState {
94    Normal,
95    /// Collecting lines of a heredoc until `end_marker` is seen.
96    Heredoc {
97        end_marker: String,
98        rule: FieldRule,
99        lines: Vec<String>,
100        /// `true` for `<<~` squiggly heredocs: the minimum leading indentation
101        /// is stripped from the body before passing to the sub-processor, then
102        /// re-added to every output line so the file structure is preserved.
103        strip_indent: bool,
104    },
105}
106
107// ---------------------------------------------------------------------------
108// Processor implementation
109// ---------------------------------------------------------------------------
110
111/// Structured processor for key = value configuration files.
112pub 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        // Normalise trailing newline: strip all, then re-add exactly one
178        // iff the original ended with one. This corrects the extra '\n'
179        // that split('\n') produces for a trailing-newline input.
180        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// ---------------------------------------------------------------------------
192// Per-line processing (extracted to stay within clippy line limit)
193// ---------------------------------------------------------------------------
194
195#[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                // For `<<~` squiggly heredocs, strip the minimum common
211                // indentation before sub-processing (matching Ruby semantics),
212                // then re-add that indentation to every output line so the
213                // file structure is preserved verbatim.
214                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                    // Find where the comment prefix starts in the original line
246                    // and split into header (everything up to and including the
247                    // prefix) and body (the rest, which may be a key-value pair).
248                    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            // Search for the delimiter in the indent-stripped line so that
265            // indented directives (e.g. nginx `    proxy_pass URL;`) are found
266            // even when the delimiter is a space character.
267            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                // raw_key preserves leading indent for faithful output reconstruction.
271                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            // Try secondary delimiters in order (e.g. `=>` and `:` for Ruby
335            // hash lines like `'aws_access_key_id' => 'KEY',` or
336            // `'client_secret': 'VALUE',`).
337            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                    // Strip surrounding quotes from the key before matching
342                    // (e.g. `'aws_access_key_id'` → `aws_access_key_id`).
343                    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
388// ---------------------------------------------------------------------------
389// Shared value-replacement helpers
390// ---------------------------------------------------------------------------
391
392/// Strip `cfg.value_strip_suffix` from the end of `inner` if present.
393/// Returns `(value_to_replace, suffix_that_was_stripped)`.
394fn 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
401/// Emit a replaced key-value pair to `output`, choosing `emit_replaced` or
402/// `emit_replaced_with_suffix` depending on whether a suffix was stripped.
403/// Does **not** push a trailing newline — the caller decides.
404fn 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
435// ---------------------------------------------------------------------------
436// Comment-body sanitization
437// ---------------------------------------------------------------------------
438
439/// Try to parse and sanitize `body` (the text after the comment prefix on a
440/// commented-out line) as a key-value pair using the same field rules as normal
441/// lines. Returns `Some(sanitized_body)` — without a trailing newline — when a
442/// field rule matched and the value was replaced; `None` when nothing matched
443/// and the line should be preserved verbatim.
444fn 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    // Try primary delimiter.
449    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    // Try secondary delimiters in order.
488    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
532// ---------------------------------------------------------------------------
533// Heredoc indent helpers
534// ---------------------------------------------------------------------------
535
536/// Strip the minimum common leading indentation from a set of heredoc body lines.
537///
538/// Implements Ruby's `<<~` squiggly-heredoc semantics: empty or whitespace-only
539/// lines are ignored when computing the minimum indentation so they don't force
540/// the minimum to zero. Returns the joined content and the number of spaces
541/// stripped (needed to re-indent the processed output).
542fn 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
569/// Re-indent every non-empty line of `content` by prepending `indent` spaces.
570///
571/// Used to restore the indentation that was stripped by [`strip_min_indent`]
572/// before the content is written back into the heredoc body.
573fn 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
588// ---------------------------------------------------------------------------
589// Helpers
590// ---------------------------------------------------------------------------
591
592/// Reconstruct and append a replaced key-value line to `output`.
593///
594/// Does **not** append a trailing newline; the caller is responsible for that.
595fn 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
614/// Like [`emit_replaced`] but appends a `suffix` after the closing quote.
615///
616/// Used for secondary-delimiter lines (e.g. Ruby hash `'key' => 'value',`)
617/// where a trailing comma or closing brace must be preserved.
618///
619/// Does **not** append a trailing newline; the caller is responsible for that.
620fn 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
643/// Detect a quoted value in `after_delim` and return `(quote_char, inner, suffix)`.
644///
645/// Unlike [`detect_quotes`], this finds the *first* quoted span after any
646/// leading whitespace and captures any trailing suffix (e.g. a comma in a
647/// Ruby hash line `=> 'VALUE',`).  For unquoted values the whole trimmed
648/// string is returned as `inner` with an empty suffix.
649fn 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                // inner: the text between the quotes
656                let inner = &trimmed[1..=close_pos];
657                // suffix: everything after the closing quote (e.g. `,`)
658                let suffix = &trimmed[close_pos + 2..];
659                return (Some(q), inner, suffix);
660            }
661        }
662    }
663    (None, trimmed, "")
664}
665
666/// Detect a Ruby-style heredoc opener in `value`.
667///
668/// Returns `Some((end_marker, strip_indent))`:
669/// - `end_marker` — the string that closes the heredoc (trimmed before comparison).
670/// - `strip_indent` — `true` only for `<<~` squiggly heredocs; the caller must
671///   strip the minimum leading indentation from the body before sub-processing
672///   and re-add it afterward.
673///
674/// `<<-` allows an indented end marker but does **not** strip body indentation
675/// (`strip_indent = false`). `<<` with no modifier also gives `false`.
676fn 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        // Unquoted: read until whitespace or end of string.
694        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
707/// Extract the leading whitespace of `s` (the portion before the first
708/// non-whitespace character).
709fn leading_whitespace(s: &str) -> &str {
710    let trimmed = s.trim_start();
711    &s[..s.len() - trimmed.len()]
712}
713
714/// Detect surrounding quotes and return `(quote_char, inner_value)`.
715fn 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// ---------------------------------------------------------------------------
727// Tests
728// ---------------------------------------------------------------------------
729
730#[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    // ---- basic key = value ----
756
757    #[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    // ---- heredoc detection ----
897
898    #[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    // ---- sub-processor: yaml heredoc ----
933
934    #[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        // Opening and closing lines preserved verbatim.
956        assert!(out.contains("gitlab_rails['ldap_servers'] = YAML.load <<-'EOS'"));
957        assert!(out.contains("EOS"));
958
959        // Sensitive values replaced.
960        assert!(!out.contains("real-ldap-password"));
961        assert!(!out.contains("cn=admin,dc=example,dc=com"));
962
963        // Unrelated key untouched.
964        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    // ---- sub-processor: <<~ squiggly heredoc strips and restores indent ----
988
989    #[test]
990    fn squiggly_heredoc_strips_and_restores_indent() {
991        // `<<~` strips the minimum indentation before sub-processing and
992        // re-adds it afterward so the output file preserves the original
993        // whitespace structure.
994        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        // Body is indented by 2 spaces (typical gitlab.rb <<~ usage).
1002        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        // Secret is replaced.
1012        assert!(
1013            !out.contains("real-ldap-password"),
1014            "secret must be replaced: {out}"
1015        );
1016
1017        // The 2-space indentation on the YAML lines must be preserved in output.
1018        // Check that the `main:` line still starts with exactly two spaces.
1019        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        // Opener and end marker preserved verbatim.
1029        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        // Unrelated key untouched.
1039        assert!(out.contains("other_key = 'untouched'"));
1040    }
1041
1042    #[test]
1043    fn squiggly_heredoc_strip_min_indent_ignores_blank_lines() {
1044        // Blank lines between YAML blocks must not force min_indent to 0.
1045        let lines = vec![
1046            "  key1: val1".to_owned(),
1047            String::new(), // blank — ignored when computing min
1048            "  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        // Each line should start with the original indentation again.
1061        assert!(restored.starts_with("  main:"), "first line: {restored}");
1062        assert!(
1063            restored.contains("\n    password:"),
1064            "second line: {restored}"
1065        );
1066    }
1067
1068    // ---- sub-processor: non-heredoc inline value ----
1069
1070    #[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    // ---- sub-processor: unknown name ----
1086
1087    #[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    // ---- field rule builder ----
1099
1100    #[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}