Skip to main content

llm_kernel/dlp/
scan.rs

1//! L1 — deterministic content scan.
2//!
3//! Single pass over static compiled rules: credentials, Korean PII, and
4//! machine-local filesystem paths. Returns byte spans, categories, severity,
5//! and an overall [`Sensitivity`] grade. Infallible — no I/O, no model.
6//!
7//! ```
8//! use llm_kernel::dlp::{scan, Sensitivity};
9//!
10//! let report = scan("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345");
11//! assert!(report.sensitivity >= Sensitivity::Confidential);
12//! assert!(!report.redact_spans.is_empty());
13//! ```
14
15use crate::provider::policy::Sensitivity;
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18use std::sync::LazyLock;
19
20/// Byte-offset range `[start, end)` into the scanned text.
21///
22/// **Byte** offsets, not char indices: the `regex` crate reports byte offsets
23/// and Korean text is multibyte. Slice with `&text[span.start..span.end]`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25pub struct Span {
26    /// Byte offset of the first byte of the match.
27    pub start: usize,
28    /// Byte offset one past the last byte of the match.
29    pub end: usize,
30}
31
32/// Severity of a single finding.
33///
34/// Variant order is the ordering (`Low < Medium < High < Critical`).
35#[non_exhaustive]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Severity {
39    /// Informational.
40    Low,
41    /// Likely personal or machine-revealing, not a credential.
42    Medium,
43    /// Sensitive personal data or a probable credential.
44    High,
45    /// Structurally unmistakable credential or strong PII.
46    Critical,
47}
48
49/// Coarse finding category (drives severity floor and sensitivity).
50///
51/// Fine-grained detector identity is [`Finding::rule`]. New variants may be
52/// added in any minor release.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum FindingCategory {
57    /// Credentials: API keys, tokens, private keys, DB connection strings.
58    Secret,
59    /// Korean PII: RRN (주민등록번호), bank accounts, mobile numbers.
60    KoreanPii,
61    /// Machine-local filesystem paths.
62    FileSystemPath,
63}
64
65/// One detection.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct Finding {
68    /// Coarse category.
69    pub category: FindingCategory,
70    /// Detector label (e.g. `"rrn_kr"`, `"github_token"`) — audit identity,
71    /// never contains matched text.
72    pub rule: String,
73    /// Severity of this finding.
74    pub severity: Severity,
75    /// Byte span of the matched text.
76    pub span: Span,
77}
78
79/// Result of [`scan`].
80#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
81pub struct ScanReport {
82    /// All detections, ascending by span start.
83    pub findings: Vec<Finding>,
84    /// Sorted, deduplicated spans to redact.
85    pub redact_spans: Vec<Span>,
86    /// Severity floor of the findings.
87    pub sensitivity: Sensitivity,
88}
89
90struct Rule {
91    category: FindingCategory,
92    label: &'static str,
93    severity: Severity,
94    pattern: Regex,
95    /// True when the redactable span is capture group 1 (context-anchored
96    /// patterns where the leading context must not be consumed — the `regex`
97    /// crate has no lookbehind and `~` has no word boundary).
98    group1_span: bool,
99}
100
101// (label, category, severity, pattern, group1_span)
102//
103// Deferred detector families (one-line adds when needed): email, ssn_us,
104// card_pan, Korean 사업자번호 (business registration), source-code/infra,
105// healthcare PHI, finance MNPI.
106const TABLE: &[(&str, FindingCategory, Severity, &str, bool)] = &[
107    (
108        "bearer_header",
109        FindingCategory::Secret,
110        Severity::Critical,
111        r"(?i)\bauthorization\s*:\s*bearer\s+[A-Za-z0-9._\-]{20,}",
112        false,
113    ),
114    (
115        "key_value_assignment",
116        FindingCategory::Secret,
117        Severity::High,
118        // Group 1 (the value) is the redactable span. Optional quotes on
119        // either side of the separator let the rule fire inside JSON bodies
120        // (`"api_key": "…"`), and the value charset excludes quotes/braces
121        // so a redacted span never removes JSON structure characters.
122        // A backslash is only consumed as a two-byte pair (`\\.`), never
123        // standalone, so a span never ends on the leading byte of a
124        // wire-format escape (`\"`) and splicing cannot split the pair
125        // (claudy DLP proxy contract: byte identity outside the secret).
126        r#"(?i)(?:password|passwd|token|key|secret|api_key|apikey|access_token|private_key)\s*["']?\s*[=:]\s*["']?((?:\\.|[^\s"'{}\\]){8,})"#,
127        true,
128    ),
129    (
130        "anthropic_key",
131        FindingCategory::Secret,
132        Severity::Critical,
133        r"\bsk-ant-[A-Za-z0-9_-]{16,}\b",
134        false,
135    ),
136    (
137        "private_key_header",
138        FindingCategory::Secret,
139        Severity::Critical,
140        r"(?i)-----BEGIN\s+(?:RSA|EC|DSA|OPENSSH|PGP)?\s*PRIVATE KEY",
141        false,
142    ),
143    (
144        "aws_access_key_id",
145        FindingCategory::Secret,
146        Severity::Critical,
147        r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
148        false,
149    ),
150    (
151        "aws_secret_key",
152        FindingCategory::Secret,
153        Severity::Critical,
154        r#"(?i)aws_secret_access_key\s*[=:]\s*["']?[A-Za-z0-9/+=]{16,}"#,
155        false,
156    ),
157    (
158        "github_token",
159        FindingCategory::Secret,
160        Severity::Critical,
161        r"\bgh[pousr]_[A-Za-z0-9]{36,}\b",
162        false,
163    ),
164    (
165        "openai_style_key",
166        FindingCategory::Secret,
167        Severity::Critical,
168        r"\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b",
169        false,
170    ),
171    (
172        "stripe_secret_key",
173        FindingCategory::Secret,
174        Severity::Critical,
175        r"\bsk_live_[A-Za-z0-9]{16,}\b",
176        false,
177    ),
178    (
179        "figma_token",
180        FindingCategory::Secret,
181        Severity::Critical,
182        r"\bfigd_[A-Za-z0-9]{20,}\b",
183        false,
184    ),
185    (
186        "slack_token",
187        FindingCategory::Secret,
188        Severity::Critical,
189        r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b",
190        false,
191    ),
192    (
193        "db_connection_string",
194        FindingCategory::Secret,
195        Severity::Critical,
196        r"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s:@]+:[^\s@]+@",
197        false,
198    ),
199    (
200        "bank_account_kr",
201        FindingCategory::KoreanPii,
202        Severity::High,
203        r"(?i)(?:계좌|account)\s*(?:번호|no\.?|number)?\s*[::]?\s*\d{2,6}-\d{2,6}-\d{2,8}",
204        false,
205    ),
206    (
207        "phone_kr",
208        FindingCategory::KoreanPii,
209        Severity::Medium,
210        r"\b01[016789]-\d{3,4}-\d{4}\b",
211        false,
212    ),
213    (
214        "home_path_posix",
215        FindingCategory::FileSystemPath,
216        Severity::Medium,
217        r"/(?:Users|home)/[A-Za-z0-9_.][A-Za-z0-9_./-]*",
218        false,
219    ),
220    (
221        "home_path_windows",
222        FindingCategory::FileSystemPath,
223        Severity::Medium,
224        r"(?i)\b[a-z]:\\users\\[A-Za-z0-9_.][A-Za-z0-9_.\\-]*",
225        false,
226    ),
227    (
228        "tilde_path",
229        FindingCategory::FileSystemPath,
230        Severity::Medium,
231        r#"(?:^|[\s"'`(=:])(~/[A-Za-z0-9_./-]+)"#,
232        true,
233    ),
234];
235
236static RULES: LazyLock<Vec<Rule>> = LazyLock::new(|| {
237    TABLE
238        .iter()
239        .map(|&(label, category, severity, pattern, group1_span)| Rule {
240            category,
241            label,
242            severity,
243            pattern: Regex::new(pattern)
244                .unwrap_or_else(|e| panic!("invalid rule /{pattern}/: {e}")),
245            group1_span,
246        })
247        .collect()
248});
249
250// RRN (주민등록번호) shape — gated by a checksum so same-shaped order numbers
251// and dates do not flag.
252static RRN_SHAPE: LazyLock<Regex> =
253    LazyLock::new(|| Regex::new(r"\b\d{6}-[1-4]\d{6}\b").expect("RRN_SHAPE is valid"));
254
255/// Korean RRN checksum: weights 2..9 then 2..5 over the first 12 digits;
256/// check digit = `(11 - sum % 11) % 10`.
257fn rrn_checksum_valid(digits: &[u8; 13]) -> bool {
258    const WEIGHTS: [u32; 12] = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
259    let sum: u32 = digits
260        .iter()
261        .take(12)
262        .zip(WEIGHTS)
263        .map(|(d, w)| u32::from(*d) * w)
264        .sum();
265    ((11 - sum % 11) % 10) == u32::from(digits[12])
266}
267
268fn severity_to_sensitivity(severity: Severity) -> Sensitivity {
269    match severity {
270        Severity::Critical => Sensitivity::Restricted,
271        Severity::High => Sensitivity::Confidential,
272        Severity::Medium | Severity::Low => Sensitivity::Internal,
273    }
274}
275
276/// Deterministically scan `context` for secrets, Korean PII, and filesystem
277/// paths. Infallible: static compiled rules, no I/O.
278pub fn scan(context: &str) -> ScanReport {
279    let mut findings: Vec<Finding> = Vec::new();
280
281    for rule in RULES.iter() {
282        for caps in rule.pattern.captures_iter(context) {
283            let m = if rule.group1_span {
284                caps.get(1)
285                    .expect("group1_span rule always captures group 1")
286            } else {
287                caps.get(0).expect("capture 0 always present")
288            };
289            findings.push(Finding {
290                category: rule.category,
291                rule: rule.label.to_string(),
292                severity: rule.severity,
293                span: Span {
294                    start: m.start(),
295                    end: m.end(),
296                },
297            });
298        }
299    }
300
301    for m in RRN_SHAPE.find_iter(context) {
302        let text = m.as_str();
303        let mut digits = [0u8; 13];
304        let mut i = 0;
305        for b in text.bytes() {
306            if b.is_ascii_digit() {
307                digits[i] = b - b'0';
308                i += 1;
309            }
310        }
311        if i == 13 && rrn_checksum_valid(&digits) {
312            findings.push(Finding {
313                category: FindingCategory::KoreanPii,
314                rule: "rrn_kr".to_string(),
315                severity: Severity::Critical,
316                span: Span {
317                    start: m.start(),
318                    end: m.end(),
319                },
320            });
321        }
322    }
323
324    findings.sort_by_key(|f| f.span.start);
325
326    let mut redact_spans: Vec<Span> = findings.iter().map(|f| f.span).collect();
327    redact_spans.sort_unstable();
328    redact_spans.dedup();
329
330    let sensitivity = findings
331        .iter()
332        .map(|f| f.severity)
333        .max()
334        .map_or(Sensitivity::Public, severity_to_sensitivity);
335
336    ScanReport {
337        findings,
338        redact_spans,
339        sensitivity,
340    }
341}
342
343/// Replace every span with `****`, multibyte-safe.
344///
345/// Overlapping spans are merged before splicing. Spans must come from
346/// [`scan`] on the same `text` (regex byte offsets are guaranteed char
347/// boundaries).
348///
349/// # Panics
350///
351/// Panics if a span is out of bounds or not on a UTF-8 char boundary of
352/// `text`.
353pub fn apply_redactions(text: &str, spans: &[Span]) -> String {
354    let mut sorted = spans.to_vec();
355    sorted.sort_unstable();
356
357    let mut merged: Vec<Span> = Vec::with_capacity(sorted.len());
358    for s in sorted {
359        match merged.last_mut() {
360            Some(last) if s.start <= last.end => last.end = last.end.max(s.end),
361            _ => merged.push(s),
362        }
363    }
364
365    let mut out = String::with_capacity(text.len());
366    let mut pos = 0usize;
367    for s in merged {
368        if s.start > pos {
369            out.push_str(&text[pos..s.start]);
370        }
371        out.push_str("****");
372        pos = pos.max(s.end);
373    }
374    if pos < text.len() {
375        out.push_str(&text[pos..]);
376    }
377    out
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn rules_hit(text: &str, label: &str) -> Vec<Finding> {
385        scan(text)
386            .findings
387            .into_iter()
388            .filter(|f| f.rule == label)
389            .collect()
390    }
391
392    /// Build a checksum-valid RRN from 12 digits ("YYMMDD" + "SNNNNNN" minus
393    /// the check digit), so tests never hardcode a real-format constant.
394    fn make_valid_rrn(first12: &str) -> String {
395        assert_eq!(first12.len(), 12);
396        let digits: Vec<u8> = first12.bytes().map(|b| b - b'0').collect();
397        let mut arr = [0u8; 13];
398        arr[..12].copy_from_slice(&digits);
399        let mut s = String::new();
400        s.push_str(&first12[..6]);
401        s.push('-');
402        s.push_str(&first12[6..]);
403        s.push_str(&rrn_check_digit(&digits).to_string());
404        arr[12] = rrn_check_digit(&digits);
405        assert!(rrn_checksum_valid(&arr));
406        s
407    }
408
409    fn rrn_check_digit(first12: &[u8]) -> u8 {
410        const WEIGHTS: [u32; 12] = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
411        let sum: u32 = first12
412            .iter()
413            .zip(WEIGHTS)
414            .map(|(d, w)| u32::from(*d) * w)
415            .sum();
416        ((11 - sum % 11) % 10) as u8
417    }
418
419    #[test]
420    fn bearer_header_detected() {
421        let hits = rules_hit(
422            "Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
423            "bearer_header",
424        );
425        assert_eq!(hits.len(), 1);
426        assert_eq!(hits[0].severity, Severity::Critical);
427    }
428
429    #[test]
430    fn bearer_short_token_ignored() {
431        assert!(rules_hit("Authorization: Bearer abc", "bearer_header").is_empty());
432    }
433
434    #[test]
435    fn key_value_assignment_requires_long_value() {
436        let hits = rules_hit("password=hunter2secret", "key_value_assignment");
437        assert_eq!(hits.len(), 1);
438        assert!(rules_hit("key=mode", "key_value_assignment").is_empty());
439        assert!(rules_hit("api_key: short", "key_value_assignment").is_empty());
440    }
441
442    #[test]
443    fn private_key_header_detected() {
444        let hits = rules_hit("-----BEGIN RSA PRIVATE KEY-----", "private_key_header");
445        assert_eq!(hits.len(), 1);
446        assert!(rules_hit("-----BEGIN CERTIFICATE-----", "private_key_header").is_empty());
447    }
448
449    #[test]
450    fn aws_access_key_detected_with_length_bound() {
451        assert_eq!(
452            rules_hit("AKIAIOSFODNN7EXAMPLE", "aws_access_key_id").len(),
453            1
454        );
455        assert!(rules_hit("AKIAIOSFODNN7EXAMPL", "aws_access_key_id").is_empty());
456    }
457
458    #[test]
459    fn aws_secret_key_detected() {
460        assert_eq!(
461            rules_hit(
462                "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
463                "aws_secret_key"
464            )
465            .len(),
466            1
467        );
468    }
469
470    #[test]
471    fn github_token_detected() {
472        assert_eq!(
473            rules_hit("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB", "github_token").len(),
474            1
475        );
476        assert!(rules_hit("ghp_short", "github_token").is_empty());
477    }
478
479    #[test]
480    fn openai_style_key_with_proj_prefix_detected() {
481        // Regression: a charset without `-` misses modern `sk-proj-…` keys.
482        assert_eq!(
483            rules_hit("sk-proj-abc123def456ghi789jkl", "openai_style_key").len(),
484            1
485        );
486        assert_eq!(
487            rules_hit("sk-abcdef0123456789abcdef", "openai_style_key").len(),
488            1
489        );
490    }
491
492    #[test]
493    fn anthropic_key_detected() {
494        assert_eq!(
495            rules_hit("sk-ant-api03-0123456789abcdefGHIJKL", "anthropic_key").len(),
496            1
497        );
498    }
499
500    #[test]
501    fn key_value_span_excludes_json_structure_chars() {
502        // claudy DLP proxy contract: redacting a span inside a JSON body must
503        // never consume quotes/braces — byte identity outside the secret.
504        let json = r#"{"api_key": "abc123def456ghi789"}"#;
505        let report = scan(json);
506        let finding = report
507            .findings
508            .iter()
509            .find(|f| f.rule == "key_value_assignment")
510            .expect("key_value finding");
511        let spanned = &json[finding.span.start..finding.span.end];
512        assert!(!spanned.contains('"'), "span ate a quote: {spanned}");
513        assert_eq!(spanned, "abc123def456ghi789");
514        let redacted = apply_redactions(json, &report.redact_spans);
515        let value: serde_json::Value =
516            serde_json::from_str(&redacted).expect("redacted JSON still parses");
517        assert_eq!(value["api_key"], "****");
518    }
519
520    #[test]
521    fn key_value_span_never_splits_json_escape_pair() {
522        // Wire-format JSON: the secret is followed by an escaped quote
523        // (`\"`, 2 bytes). The span must not stop on the pair's leading
524        // backslash — splicing a lone `\` leaves a bare quote and corrupts
525        // the JSON (issue #99: claudy guard 422s).
526        let wire = r#"{"content":"check password=hunter2secret1\" in config"}"#;
527        let report = scan(wire);
528        let finding = report
529            .findings
530            .iter()
531            .find(|f| f.rule == "key_value_assignment")
532            .expect("key_value finding");
533        let spanned = &wire[finding.span.start..finding.span.end];
534        assert!(
535            !spanned.ends_with('\\'),
536            "span splits a wire escape pair: {spanned:?}"
537        );
538        // The pair is consumed atomically: splice + reparse stays valid.
539        let mut spliced = wire.to_string();
540        spliced.replace_range(finding.span.start..finding.span.end, "[REDACTED:key_value]");
541        let value: serde_json::Value =
542            serde_json::from_str(&spliced).expect("spliced wire still parses");
543        assert_eq!(
544            value["content"],
545            "check password=[REDACTED:key_value] in config"
546        );
547    }
548
549    #[test]
550    fn key_value_value_with_raw_backslashes_still_detected() {
551        // Raw (non-wire) text: a backslash before a non-escape char must
552        // not end the span early — `\\.` pairs it with the next byte
553        // instead of stalling the charset.
554        let hits = rules_hit("password=C:\\secrets\\vault91", "key_value_assignment");
555        assert_eq!(hits.len(), 1);
556        assert_eq!(
557            "password=C:\\secrets\\vault91"[hits[0].span.start..hits[0].span.end].len(),
558            "C:\\secrets\\vault91".len()
559        );
560    }
561
562    #[test]
563    fn stripe_slack_figma_tokens_detected() {
564        // Built by concatenation so the literal never lands in the git blob
565        // (GitHub push protection flags `sk_live_…`-shaped strings even as
566        // test fixtures).
567        let stripe_key = ["sk_", "live_", "0123456789abcdefGHIJ"].concat();
568        assert_eq!(rules_hit(&stripe_key, "stripe_secret_key").len(), 1);
569        assert_eq!(
570            rules_hit("xoxb-1234567890abcdefWXYZ", "slack_token").len(),
571            1
572        );
573        assert_eq!(
574            rules_hit("figd_0123456789abcdefghij", "figma_token").len(),
575            1
576        );
577    }
578
579    #[test]
580    fn db_connection_string_detected() {
581        assert_eq!(
582            rules_hit(
583                "postgres://admin:hunter2@db.example/prod",
584                "db_connection_string"
585            )
586            .len(),
587            1
588        );
589        // No credentials in the URI → no finding.
590        assert!(rules_hit("postgres://db.example/prod", "db_connection_string").is_empty());
591    }
592
593    #[test]
594    fn rrn_valid_checksum_detected() {
595        let rrn = make_valid_rrn("900101123456");
596        let hits = rules_hit(&rrn, "rrn_kr");
597        assert_eq!(hits.len(), 1);
598        assert_eq!(hits[0].severity, Severity::Critical);
599        assert_eq!(scan(&rrn).sensitivity, Sensitivity::Restricted);
600    }
601
602    #[test]
603    fn rrn_invalid_checksum_ignored() {
604        // Same shape, fails the checksum gate.
605        assert!(rules_hit("900101-1234567", "rrn_kr").is_empty());
606    }
607
608    #[test]
609    fn rrn_multibyte_span_is_exact() {
610        let rrn = make_valid_rrn("900101123456");
611        let text = format!("주민번호는 {rrn} 입니다");
612        let report = scan(&text);
613        let f = report
614            .findings
615            .iter()
616            .find(|f| f.rule == "rrn_kr")
617            .expect("rrn finding");
618        assert_eq!(&text[f.span.start..f.span.end], rrn);
619    }
620
621    #[test]
622    fn bank_account_kr_detected() {
623        assert_eq!(
624            rules_hit("계좌 번호: 123-456-789012", "bank_account_kr").len(),
625            1
626        );
627        assert_eq!(
628            rules_hit("account no. 301-0123-4567", "bank_account_kr").len(),
629            1
630        );
631    }
632
633    #[test]
634    fn phone_kr_both_forms_detected() {
635        assert_eq!(rules_hit("010-1234-5678", "phone_kr").len(), 1);
636        assert_eq!(rules_hit("010-123-4567", "phone_kr").len(), 1);
637        assert!(rules_hit("01012345678", "phone_kr").is_empty());
638    }
639
640    #[test]
641    fn filesystem_paths_detected() {
642        assert_eq!(
643            rules_hit("/Users/hackme/notes.md", "home_path_posix").len(),
644            1
645        );
646        assert_eq!(rules_hit("/home/user/.env", "home_path_posix").len(), 1);
647        assert_eq!(
648            rules_hit("C:\\Users\\kim\\doc.txt", "home_path_windows").len(),
649            1
650        );
651    }
652
653    #[test]
654    fn tilde_path_span_excludes_leading_context() {
655        let text = "see ~/secret.md now";
656        let hits = rules_hit(text, "tilde_path");
657        assert_eq!(hits.len(), 1);
658        assert_eq!(&text[hits[0].span.start..hits[0].span.end], "~/secret.md");
659        // Line-start anchor also matches.
660        assert_eq!(rules_hit("~/notes.md", "tilde_path").len(), 1);
661        // Bare `~` with nothing after it does not match.
662        assert!(rules_hit("cd ~ then", "tilde_path").is_empty());
663    }
664
665    #[test]
666    fn clean_text_is_public() {
667        let report = scan("just a normal sentence about the weather");
668        assert_eq!(report.sensitivity, Sensitivity::Public);
669        assert!(report.findings.is_empty());
670        assert!(report.redact_spans.is_empty());
671    }
672
673    #[test]
674    fn empty_text_is_public() {
675        let report = scan("");
676        assert_eq!(report.sensitivity, Sensitivity::Public);
677    }
678
679    #[test]
680    fn sensitivity_floor_follows_max_severity() {
681        // phone_kr (Medium) → Internal
682        assert_eq!(
683            scan("call me at 010-1234-5678").sensitivity,
684            Sensitivity::Internal
685        );
686        // github_token (Critical) → Restricted
687        assert_eq!(
688            scan("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB").sensitivity,
689            Sensitivity::Restricted
690        );
691    }
692
693    #[test]
694    fn redact_spans_sorted_and_deduped() {
695        // bearer_header and key_value_assignment can both hit the same region.
696        let text =
697            "Authorization: Bearer abcdefghijklmnopqrstuvwxyz token=abcdefghijklmnopqrstuvwxyz";
698        let report = scan(text);
699        let mut spans = report.redact_spans.clone();
700        spans.sort_unstable();
701        spans.dedup();
702        assert_eq!(report.redact_spans, spans);
703        assert!(!report.redact_spans.is_empty());
704    }
705
706    #[test]
707    fn apply_redactions_multibyte_safe() {
708        let rrn = make_valid_rrn("900101123456");
709        let text = format!("주민번호는 {rrn} 입니다");
710        let report = scan(&text);
711        let redacted = apply_redactions(&text, &report.redact_spans);
712        assert_eq!(redacted, format!("주민번호는 **** 입니다"));
713    }
714
715    #[test]
716    fn apply_redactions_merges_overlapping_spans() {
717        let text = "abcdefghij";
718        // Overlapping spans covering [1,4) and [2,6).
719        let spans = vec![Span { start: 2, end: 6 }, Span { start: 1, end: 4 }];
720        assert_eq!(apply_redactions(text, &spans), "a****ghij");
721    }
722
723    #[test]
724    fn findings_sorted_ascending() {
725        let text = "path ~/a.md and 010-1234-5678 and ghp_0123456789abcdefghijklmnopqrstuvwxyzAB";
726        let report = scan(text);
727        let starts: Vec<usize> = report.findings.iter().map(|f| f.span.start).collect();
728        let mut sorted = starts.clone();
729        sorted.sort_unstable();
730        assert_eq!(starts, sorted);
731    }
732}