Skip to main content

rust_sanitize/
secrets.rs

1//! Encrypted secrets management.
2//!
3//! This module provides **in-memory-only** decryption of user-supplied
4//! secrets files. Secrets are never written to disk in plaintext form;
5//! they are loaded from an AES-256-GCM encrypted `.enc` file, decrypted
6//! into memory, parsed, and converted directly into [`ScanPattern`]s
7//! for the streaming scanner.
8//!
9//! # Encryption Format
10//!
11//! ```text
12//! ┌────────────────────────────────┬──────────────┬─────────────────────────────┐
13//! │  Salt (32 B)                   │  Nonce (12 B)│  AES-256-GCM Ciphertext     │
14//! └────────────────────────────────┴──────────────┴─────────────────────────────┘
15//! ```
16//!
17//! - **Salt** (32 bytes): random, used for PBKDF2-derived key.
18//! - **Nonce** (12 bytes): random, for AES-256-GCM.
19//! - **Ciphertext**: authenticated encryption of the plaintext secrets
20//!   file (JSON / YAML / TOML).
21//!
22//! The 256-bit AES key is derived from the user password using
23//! PBKDF2-HMAC-SHA256 with 600 000 iterations, which meets current
24//! OWASP recommendations.
25//!
26//! # Key Derivation
27//!
28//! ```text
29//! key = PBKDF2-HMAC-SHA256(password, salt, iterations=600_000, dkLen=32)
30//! ```
31//!
32//! # Secrets File Schema
33//!
34//! The plaintext secrets file (before encryption) must deserialize to
35//! `Vec<SecretEntry>`:
36//!
37//! ```json
38//! [
39//!   {
40//!     "pattern": "alice@corp\\.com",
41//!     "kind": "regex",
42//!     "category": "email",
43//!     "label": "alice_email"
44//!   },
45//!   {
46//!     "pattern": "sk-proj-abc123secret",
47//!     "kind": "literal",
48//!     "category": "custom:api_key",
49//!     "label": "openai_key"
50//!   }
51//! ]
52//! ```
53//!
54//! # Thread Safety
55//!
56//! All public types are `Send + Sync`. Decrypted secrets use
57//! [`zeroize::Zeroizing`] to scrub plaintext from memory on drop.
58//!
59//! # Security Considerations
60//!
61//! - AES-256-GCM provides both confidentiality and integrity (AEAD).
62//! - PBKDF2 with 600 000 iterations resists offline brute-force attacks.
63//! - Decrypted plaintext is held in [`Zeroizing<Vec<u8>>`] and zeroed
64//!   on drop.
65//! - The plaintext secrets file is never written to disk by this crate.
66//! - Nonce and salt are generated with OS CSPRNG (`rand`).
67
68use crate::category::Category;
69use crate::error::{Result, SanitizeError};
70use crate::scanner::ScanPattern;
71
72/// Result of compiling secret entries into patterns.
73/// Contains successfully compiled patterns and a list of (index, error) for failures.
74pub type PatternCompileResult = (Vec<ScanPattern>, Vec<(usize, SanitizeError)>);
75
76use aes_gcm::aead::{Aead, KeyInit};
77use aes_gcm::{Aes256Gcm, Nonce};
78use hmac::Hmac;
79use rand::RngCore;
80use serde::{Deserialize, Serialize};
81use sha2::Sha256;
82use zeroize::{Zeroize, Zeroizing};
83
84// ---------------------------------------------------------------------------
85// Constants
86// ---------------------------------------------------------------------------
87
88/// Salt length for PBKDF2 key derivation (bytes).
89const SALT_LEN: usize = 32;
90
91/// AES-GCM nonce length (bytes). Must be 12 for AES-256-GCM.
92const NONCE_LEN: usize = 12;
93
94/// PBKDF2 iteration count — OWASP 2023+ recommendation.
95const PBKDF2_ITERATIONS: u32 = 600_000;
96
97/// Minimum ciphertext size: salt + nonce + at least 16-byte AES-GCM tag.
98const MIN_ENCRYPTED_LEN: usize = SALT_LEN + NONCE_LEN + 16;
99
100/// Maximum size of a plaintext secrets file accepted by [`parse_secrets`].
101/// Prevents OOM from accidentally passing a large binary or log file as secrets.
102const MAX_SECRETS_PLAINTEXT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
103
104// ---------------------------------------------------------------------------
105// Secrets file schema
106// ---------------------------------------------------------------------------
107
108/// A single secret entry as stored in the (plaintext) secrets file.
109///
110/// After decryption the entries are parsed from JSON, YAML, or TOML and
111/// converted into [`ScanPattern`]s.
112///
113/// Implements [`Drop`] via [`Zeroize`] to scrub sensitive pattern data
114/// from memory when no longer needed (S-1 fix).
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SecretEntry {
117    /// The pattern string (regex or literal text).
118    ///
119    /// For `kind: allow` entries this is the single allowlist pattern.
120    /// Omit when using [`values`](Self::values) instead.
121    #[serde(default)]
122    pub pattern: String,
123
124    /// `"regex"`, `"literal"`, `"allow"`, `"entropy"`, or `"field-name"`.
125    ///
126    /// `"field-name"` entries are not compiled into scanner patterns — they
127    /// are extracted separately and injected into structured-processor profiles
128    /// as field-name signals.  The `pattern` field is a case-insensitive
129    /// regex matched against bare field/key names; `threshold` controls the
130    /// entropy gate (defaults to `3.5` bits/char when omitted).
131    #[serde(default = "default_kind")]
132    pub kind: String,
133
134    /// Category string. Supported values:
135    /// `email`, `name`, `phone`, `ipv4`, `ipv6`, `credit_card`, `ssn`,
136    /// `hostname`, `mac_address`, `container_id`, `uuid`, `jwt`,
137    /// `auth_token`, `file_path`, `windows_sid`, `url`, `aws_arn`,
138    /// `azure_resource_id`, or `custom:<tag>`.
139    #[serde(default = "default_category")]
140    pub category: String,
141
142    /// Human-readable label for stats reporting (appears in the redaction
143    /// summary, findings, and reports). When omitted: a `regex` pattern defaults
144    /// to a truncated form of its (non-secret) pattern text; a `literal` pattern
145    /// — whose text *is* the secret value — defaults to `literal:<category>` so
146    /// the value is never exposed in reporting output.
147    #[serde(default)]
148    pub label: Option<String>,
149
150    /// Multiple allowlist patterns for `kind: allow` entries.
151    ///
152    /// When non-empty, used instead of `pattern`. Allows a single entry to
153    /// allowlist many values compactly:
154    ///
155    /// ```toml
156    /// [[secrets]]
157    /// kind = "allow"
158    /// values = ["localhost", "true", "false", "null", "0.0.0.0"]
159    /// ```
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub values: Vec<String>,
162
163    // ── Entropy-detection fields (only used when kind = "entropy") ──────────
164    /// Minimum token length to consider (default: 20).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub min_length: Option<usize>,
167
168    /// Maximum token length to consider (default: 200).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub max_length: Option<usize>,
171
172    /// Shannon entropy threshold in bits per character (default: 4.5).
173    /// Tokens whose entropy is at or above this value are flagged.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub threshold: Option<f64>,
176
177    /// Character set the token must consist of exclusively.
178    /// `"alphanumeric"` (default), `"base64"`, `"hex"`, or `"any"`.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub charset: Option<String>,
181}
182
183impl Drop for SecretEntry {
184    fn drop(&mut self) {
185        self.pattern.zeroize();
186        self.kind.zeroize();
187        self.category.zeroize();
188        if let Some(ref mut l) = self.label {
189            l.zeroize();
190        }
191        for v in &mut self.values {
192            v.zeroize();
193        }
194        if let Some(ref mut s) = self.charset {
195            s.zeroize();
196        }
197    }
198}
199
200fn default_kind() -> String {
201    "literal".into()
202}
203
204fn default_category() -> String {
205    "custom:secret".into()
206}
207
208/// Supported plaintext file formats for secrets.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum SecretsFormat {
211    Json,
212    Yaml,
213    Toml,
214}
215
216impl SecretsFormat {
217    /// Detect format from file extension.
218    pub fn from_extension(path: &str) -> Option<Self> {
219        // Strip .enc suffix first if present.
220        let base = path.strip_suffix(".enc").unwrap_or(path);
221        let ext = std::path::Path::new(base).extension();
222        if ext.is_some_and(|e| e.eq_ignore_ascii_case("json")) {
223            Some(Self::Json)
224        } else if ext
225            .is_some_and(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
226        {
227            Some(Self::Yaml)
228        } else if ext.is_some_and(|e| e.eq_ignore_ascii_case("toml")) {
229            Some(Self::Toml)
230        } else {
231            None
232        }
233    }
234
235    /// Try to auto-detect format from content.
236    pub fn detect(content: &[u8]) -> Self {
237        let s = String::from_utf8_lossy(content);
238        // Skip leading comment lines — both YAML and TOML use `#`, so a file
239        // that opens with comments must be scanned further to find the first
240        // meaningful token.
241        let first_meaningful = s
242            .lines()
243            .map(str::trim)
244            .find(|l| !l.is_empty() && !l.starts_with('#'))
245            .unwrap_or("");
246        if first_meaningful.starts_with('[') || first_meaningful.starts_with('{') {
247            // `[` is ambiguous: JSON arrays and TOML table headers both start
248            // with it. We pick JSON here because our secrets files are never
249            // bare TOML tables, and a wrong guess produces a clear parse error.
250            Self::Json
251        } else if first_meaningful.starts_with('-') || first_meaningful.starts_with("---") {
252            Self::Yaml
253        } else {
254            // Fallback: assume TOML
255            Self::Toml
256        }
257    }
258}
259
260// ---------------------------------------------------------------------------
261// TOML wrapper — serde_toml expects a top-level table
262// ---------------------------------------------------------------------------
263
264/// Wrapper for TOML deserialization: `secrets = [...]`
265#[derive(Deserialize)]
266struct TomlSecrets {
267    secrets: Vec<SecretEntry>,
268}
269
270/// Wrapper for TOML serialization.
271#[derive(Serialize)]
272struct TomlSecretsRef<'a> {
273    secrets: &'a [SecretEntry],
274}
275
276// ---------------------------------------------------------------------------
277// Key derivation
278// ---------------------------------------------------------------------------
279
280/// Derive a 256-bit AES key from a password and salt using PBKDF2.
281fn derive_key(password: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
282    let mut key = Zeroizing::new([0u8; 32]);
283    pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, PBKDF2_ITERATIONS, key.as_mut())
284        .expect("PBKDF2 output length is valid");
285    key
286}
287
288// ---------------------------------------------------------------------------
289// Encryption
290// ---------------------------------------------------------------------------
291
292/// Encrypt a plaintext secrets file.
293///
294/// Returns the encrypted blob: `salt (32) || nonce (12) || ciphertext`.
295///
296/// # Arguments
297///
298/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
299/// - `password` — user-supplied password.
300///
301/// # Errors
302///
303/// Returns [`SanitizeError::SecretsEmptyPassword`] if the password is empty, or
304/// [`SanitizeError::SecretsCipherError`] if encryption fails.
305///
306/// # Security
307///
308/// - Salt and nonce are generated with CSPRNG.
309/// - Key is derived with PBKDF2 (600 000 iterations).
310/// - AES-256-GCM provides authenticated encryption.
311pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
312    if password.is_empty() {
313        return Err(SanitizeError::SecretsEmptyPassword);
314    }
315
316    let mut rng = rand::rng();
317
318    // Generate random salt and nonce.
319    let mut salt = [0u8; SALT_LEN];
320    rng.fill_bytes(&mut salt);
321
322    let mut nonce_bytes = [0u8; NONCE_LEN];
323    rng.fill_bytes(&mut nonce_bytes);
324    let nonce = Nonce::from_slice(&nonce_bytes);
325
326    // Derive key.
327    let key = derive_key(password.as_bytes(), &salt);
328    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
329        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
330
331    // Encrypt.
332    let ciphertext = cipher
333        .encrypt(nonce, plaintext)
334        .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
335
336    // Assemble: salt || nonce || ciphertext
337    let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
338    output.extend_from_slice(&salt);
339    output.extend_from_slice(&nonce_bytes);
340    output.extend_from_slice(&ciphertext);
341
342    Ok(output)
343}
344
345// ---------------------------------------------------------------------------
346// Decryption
347// ---------------------------------------------------------------------------
348
349/// Decrypt an encrypted secrets blob in memory.
350///
351/// Returns the plaintext wrapped in [`Zeroizing`] so it is scrubbed on drop.
352///
353/// # Arguments
354///
355/// - `encrypted` — `salt (32) || nonce (12) || ciphertext`.
356/// - `password` — user-supplied password.
357///
358/// # Errors
359///
360/// - [`SanitizeError::SecretsTooShort`] if the blob is too short,
361///   [`SanitizeError::SecretsDecryptFailed`] if the password is wrong or the ciphertext has been tampered with.
362pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
363    if encrypted.len() < MIN_ENCRYPTED_LEN {
364        return Err(SanitizeError::SecretsTooShort);
365    }
366
367    let salt = &encrypted[..SALT_LEN];
368    let nonce_bytes = &encrypted[SALT_LEN..SALT_LEN + NONCE_LEN];
369    let ciphertext = &encrypted[SALT_LEN + NONCE_LEN..];
370
371    let nonce = Nonce::from_slice(nonce_bytes);
372
373    let key = derive_key(password.as_bytes(), salt);
374    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
375        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
376
377    let plaintext = cipher
378        .decrypt(nonce, ciphertext)
379        .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
380
381    Ok(Zeroizing::new(plaintext))
382}
383
384// ---------------------------------------------------------------------------
385// Parsing
386// ---------------------------------------------------------------------------
387
388/// Parse a decrypted plaintext into secret entries.
389///
390/// Supports JSON, YAML, and TOML. Format is auto-detected if `format`
391/// is `None`.
392///
393/// # Errors
394///
395/// Returns [`SanitizeError::SecretsInvalidUtf8`] if the plaintext is not
396/// valid UTF-8, [`SanitizeError::SecretsFormatError`] if it cannot be parsed
397/// in the specified format or if the file exceeds the size limit.
398pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
399    if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
400        return Err(SanitizeError::SecretsFormatError {
401            format: "secrets file".into(),
402            message: format!(
403                "file is {} bytes, exceeding the {} byte limit — \
404                 secrets files should be small YAML/JSON/TOML pattern lists",
405                plaintext.len(),
406                MAX_SECRETS_PLAINTEXT_BYTES,
407            ),
408        });
409    }
410    let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
411    let text = std::str::from_utf8(plaintext)
412        .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
413
414    match fmt {
415        SecretsFormat::Json => {
416            serde_json::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
417                format: "JSON".into(),
418                message: e.to_string(),
419            })
420        }
421        SecretsFormat::Yaml => {
422            serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
423                format: "YAML".into(),
424                message: e.to_string(),
425            })
426        }
427        SecretsFormat::Toml => {
428            let wrapper: TomlSecrets =
429                toml::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
430                    format: "TOML".into(),
431                    message: e.to_string(),
432                })?;
433            Ok(wrapper.secrets)
434        }
435    }
436}
437
438/// Serialize secret entries back into a plaintext format.
439///
440/// Used by the encryption helper CLI.
441///
442/// # Errors
443///
444/// Returns [`SanitizeError::SecretsFormatError`] if serialization fails.
445pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
446    match format {
447        SecretsFormat::Json => {
448            serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
449                format: "JSON-serialize".into(),
450                message: e.to_string(),
451            })
452        }
453        SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
454            .map(|s| s.into_bytes())
455            .map_err(|e| SanitizeError::SecretsFormatError {
456                format: "YAML-serialize".into(),
457                message: e.to_string(),
458            }),
459        SecretsFormat::Toml => {
460            let wrapper = TomlSecretsRef { secrets: entries };
461            toml::to_string_pretty(&wrapper)
462                .map(|s| s.into_bytes())
463                .map_err(|e| SanitizeError::SecretsFormatError {
464                    format: "TOML-serialize".into(),
465                    message: e.to_string(),
466                })
467        }
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Category parsing
473// ---------------------------------------------------------------------------
474
475/// Parse a category string into a [`Category`].
476///
477/// Accepted values: `email`, `name`, `phone`, `ipv4`, `ipv6`,
478/// `credit_card`, `ssn`, `hostname`, `mac_address`, `container_id`,
479/// `uuid`, `jwt`, `auth_token`, `file_path`, `windows_sid`, `url`,
480/// `aws_arn`, `azure_resource_id`, or `custom:<tag>`.
481pub fn parse_category(s: &str) -> Category {
482    match s {
483        "email" => Category::Email,
484        "name" => Category::Name,
485        "phone" => Category::Phone,
486        "ipv4" => Category::IpV4,
487        "ipv6" => Category::IpV6,
488        "credit_card" => Category::CreditCard,
489        "ssn" => Category::Ssn,
490        "hostname" => Category::Hostname,
491        "mac_address" => Category::MacAddress,
492        "container_id" => Category::ContainerId,
493        "uuid" => Category::Uuid,
494        "jwt" => Category::Jwt,
495        "auth_token" => Category::AuthToken,
496        "file_path" => Category::FilePath,
497        "windows_sid" => Category::WindowsSid,
498        "url" => Category::Url,
499        "aws_arn" => Category::AwsArn,
500        "azure_resource_id" => Category::AzureResourceId,
501        other => {
502            let tag = other.strip_prefix("custom:").unwrap_or(other);
503            Category::Custom(tag.into())
504        }
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Conversion to ScanPatterns
510// ---------------------------------------------------------------------------
511
512/// Extract allowlist patterns from a set of entries.
513///
514/// Entries with `kind: allow` are returned as raw pattern strings to be
515/// compiled into an [`AllowlistMatcher`](crate::allowlist::AllowlistMatcher). They are skipped by
516/// [`entries_to_patterns`].
517///
518/// Each entry contributes either its `values` list (when non-empty) or its
519/// `pattern` field (when `values` is absent), so both forms are supported:
520///
521/// ```toml
522/// # single pattern
523/// [[secrets]]
524/// kind = "allow"
525/// pattern = "localhost"
526///
527/// # compact multi-value form
528/// [[secrets]]
529/// kind = "allow"
530/// values = ["true", "false", "null", "0.0.0.0"]
531/// ```
532pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
533    let mut patterns = Vec::new();
534    for entry in entries.iter().filter(|e| e.kind == "allow") {
535        if !entry.values.is_empty() {
536            patterns.extend(entry.values.iter().cloned());
537        } else if !entry.pattern.is_empty() {
538            patterns.push(entry.pattern.clone());
539        }
540    }
541    patterns
542}
543
544/// Convert parsed [`SecretEntry`]s into compiled [`ScanPattern`]s.
545///
546/// Entries with `kind: allow` are silently skipped — they are handled by
547/// [`extract_allow_patterns`] instead.
548///
549/// Invalid entries (e.g. bad regex) are collected as errors and
550/// returned alongside the successfully compiled patterns.
551pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
552    let mut patterns = Vec::with_capacity(entries.len());
553    let mut errors = Vec::new();
554
555    for (i, entry) in entries.iter().enumerate() {
556        if entry.kind == "allow"
557            || entry.kind == "entropy"
558            || entry.kind == "field-name"
559            || entry.pattern.is_empty()
560        {
561            continue;
562        }
563        let category = parse_category(&entry.category);
564        // A `literal` pattern's text IS the secret value, and labels surface in
565        // the redaction summary, findings, reports, and logs — so a literal must
566        // never default its label to its own pattern (that leaks the secret).
567        // Fall back to the category instead. A `regex` pattern is not itself a
568        // secret, so its (truncated) text remains a safe, informative default.
569        let label = entry.label.clone().unwrap_or_else(|| {
570            if entry.kind == "literal" {
571                format!("literal:{}", entry.category)
572            } else {
573                truncate_label(&entry.pattern)
574            }
575        });
576
577        let result = match entry.kind.as_str() {
578            "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
579            "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
580            other => {
581                errors.push((
582                    i,
583                    SanitizeError::InvalidConfig(format!(
584                        "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
585                        other
586                    )),
587                ));
588                continue;
589            }
590        };
591
592        match result {
593            Ok(pat) => {
594                // Apply the entry's optional match-length bounds. Unset bounds
595                // keep the pattern's defaults (min 0 for regex / literal length
596                // for literals, max unbounded).
597                let min = entry.min_length.unwrap_or(pat.min_length);
598                let max = entry.max_length.unwrap_or(pat.max_length);
599                patterns.push(pat.with_length_bounds(min, max));
600            }
601            Err(e) => errors.push((i, e)),
602        }
603    }
604
605    (patterns, errors)
606}
607
608const MAX_LABEL_CHARS: usize = 32;
609
610/// Truncate to a maximum label length.
611fn truncate_label(s: &str) -> String {
612    if s.len() <= MAX_LABEL_CHARS {
613        s.to_string()
614    } else {
615        // Find a char boundary just before the limit to avoid panicking on
616        // multi-byte UTF-8 characters (e.g. Unicode in user-supplied patterns).
617        let cut = s
618            .char_indices()
619            .nth(MAX_LABEL_CHARS - 1)
620            .map_or(s.len(), |(i, _)| i);
621        format!("{}…", &s[..cut])
622    }
623}
624
625// ---------------------------------------------------------------------------
626// High-level: load encrypted secrets → ScanPatterns
627// ---------------------------------------------------------------------------
628
629/// Load, decrypt, parse, and compile an encrypted secrets file into
630/// [`ScanPattern`]s ready for the streaming scanner.
631///
632/// This is the primary entry point for CLI integration.
633///
634/// # Arguments
635///
636/// - `encrypted_bytes` — raw bytes of the `.enc` file.
637/// - `password` — user-supplied password.
638/// - `format` — optional explicit format override.
639///
640/// # Returns
641///
642/// `(patterns, warnings)` where `warnings` contains indices and errors
643/// for entries that failed to compile.
644///
645/// # Security
646///
647/// The decrypted plaintext is held in zeroizing memory and dropped
648/// immediately after parsing.
649///
650/// # Errors
651///
652/// Returns a secrets-related [`SanitizeError`] if decryption or parsing fails.
653pub fn load_encrypted_secrets(
654    encrypted_bytes: &[u8],
655    password: &str,
656    format: Option<SecretsFormat>,
657) -> Result<(PatternCompileResult, Vec<String>)> {
658    let plaintext = decrypt_secrets(encrypted_bytes, password)?;
659    let entries = parse_secrets(&plaintext, format)?;
660    let allow = extract_allow_patterns(&entries);
661    let result = entries_to_patterns(&entries);
662    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
663    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
664    drop(entries);
665    Ok((result, allow))
666}
667
668/// Load and parse a plaintext secrets file into [`ScanPattern`]s.
669///
670/// This function mirrors [`load_encrypted_secrets`] but skips
671/// AES decryption and password prompts entirely. It preserves
672/// memory hygiene by zeroizing parsed entries after compilation.
673///
674/// # Arguments
675///
676/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
677/// - `format` — optional explicit format override.
678///
679/// # Security
680///
681/// Even for unencrypted secrets, entries are zeroized after pattern
682/// compilation to minimise the window during which sensitive values
683/// reside in memory.
684///
685/// # Errors
686///
687/// Returns a secrets-related [`SanitizeError`] if parsing or pattern
688/// compilation fails.
689pub fn load_plaintext_secrets(
690    plaintext: &[u8],
691    format: Option<SecretsFormat>,
692) -> Result<(PatternCompileResult, Vec<String>)> {
693    let entries = parse_secrets(plaintext, format)?;
694    let allow = extract_allow_patterns(&entries);
695    let result = entries_to_patterns(&entries);
696    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
697    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
698    drop(entries);
699    Ok((result, allow))
700}
701
702/// Detect whether raw file bytes look like an AES-256-GCM encrypted
703/// secrets blob (binary with salt+nonce header) or a plaintext secrets
704/// file (UTF-8 JSON / YAML / TOML).
705///
706/// Returns `true` if the content appears to be encrypted.
707///
708/// Heuristic:
709/// 1. Files shorter than the minimum encrypted length cannot be valid
710///    ciphertext — return `false`.
711/// 2. The **entire** content is checked for UTF-8 validity (not just the
712///    first few bytes). Only if the whole file is valid UTF-8 and begins
713///    with a recognisable plaintext marker (`[`, `{`, `-`, `#`) is it
714///    treated as plaintext — return `false`.
715/// 3. Binary content (not valid UTF-8) or UTF-8 without a plaintext
716///    marker is assumed to be encrypted — return `true`.
717///
718/// Note: a pathological plaintext file that is valid UTF-8 but lacks a
719/// leading plaintext marker (e.g. a TOML file whose first non-whitespace
720/// character is a letter) will be misclassified as encrypted and produce
721/// a `SecretsDecryptFailed` error. Use `force_plaintext: true` in
722/// [`load_secrets_auto`] to bypass the heuristic in that case.
723pub fn looks_encrypted(data: &[u8]) -> bool {
724    if data.len() < MIN_ENCRYPTED_LEN {
725        // Too short for a valid encrypted blob — might be a tiny
726        // plaintext file, but definitely not encrypted.
727        return false;
728    }
729    // If the file is valid UTF-8 and starts with a recognisable
730    // plaintext marker, treat it as plaintext.
731    if let Ok(text) = std::str::from_utf8(data) {
732        let trimmed = text.trim_start();
733        // Recognisable plaintext markers:
734        //   JSON  → '[' (array) or '{' (object)
735        //   YAML  → '-' (sequence item) or '---' (document start)
736        //   TOML  → '#' (comment) or '[' (table header)
737        //   TOML bare-key → any ASCII letter (e.g. `pattern = "foo"`)
738        //
739        // starts_with('[') already covers "[["; starts_with('-') covers "---".
740        // The ASCII-letter check handles TOML files whose first meaningful token
741        // is a bare key — without it those files were misclassified as encrypted
742        // and produced a confusing "decryption failed" error.
743        //
744        // A valid AES-256-GCM ciphertext starts with 32 bytes of random salt and
745        // 12 bytes of random nonce; the probability that those 44 bytes are entirely
746        // valid UTF-8 AND begin with an ASCII letter is negligible (< 2^-6).
747        let first_char = trimmed.chars().next();
748        let has_marker = trimmed.starts_with('[')
749            || trimmed.starts_with('{')
750            || trimmed.starts_with('-')
751            || trimmed.starts_with('#')
752            || first_char.is_some_and(|c| c.is_ascii_alphabetic());
753        if has_marker {
754            return false;
755        }
756    }
757    // Binary / non-UTF-8 → assume encrypted.
758    true
759}
760
761/// Unified loader: auto-detect encrypted vs plaintext and load
762/// secret patterns accordingly.
763///
764/// When `force_plaintext` is `true`, decryption is skipped regardless
765/// of file content. When `false`, the function uses [`looks_encrypted`]
766/// to choose the path automatically.
767///
768/// # Arguments
769///
770/// - `data` — raw bytes read from the secrets file.
771/// - `password` — password for decryption (ignored when plaintext).
772/// - `format` — optional format override.
773/// - `force_plaintext` — if `true`, always treat as plaintext.
774///
775/// # Returns
776///
777/// `(patterns, warnings, was_encrypted)` — the compiled patterns,
778/// any compile warnings, and a flag indicating which path was taken.
779///
780/// # Errors
781///
782/// Returns a secrets-related [`SanitizeError`] if decryption or parsing
783/// fails, or if a password is required but not provided.
784/// Returns `((patterns, warnings, allow_patterns), was_encrypted)`.
785///
786/// `allow_patterns` are the raw strings from `kind: allow` entries in the
787/// secrets file — the caller should combine these with any `--allow` CLI
788/// values and pass the merged list to [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher::new).
789pub fn load_secrets_auto(
790    data: &[u8],
791    password: Option<&str>,
792    format: Option<SecretsFormat>,
793    force_plaintext: bool,
794) -> Result<((PatternCompileResult, Vec<String>), bool)> {
795    if force_plaintext || !looks_encrypted(data) {
796        let (result, allow) = load_plaintext_secrets(data, format)?;
797        Ok(((result, allow), false))
798    } else {
799        let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
800        let (result, allow) = load_encrypted_secrets(data, pw, format)?;
801        Ok(((result, allow), true))
802    }
803}
804
805// ---------------------------------------------------------------------------
806// Unit tests
807// ---------------------------------------------------------------------------
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    fn sample_json() -> &'static str {
814        r#"[
815            {
816                "pattern": "alice@corp\\.com",
817                "kind": "regex",
818                "category": "email",
819                "label": "alice_email"
820            },
821            {
822                "pattern": "sk-proj-abc123secret",
823                "kind": "literal",
824                "category": "custom:api_key",
825                "label": "openai_key"
826            }
827        ]"#
828    }
829
830    fn sample_yaml() -> &'static str {
831        r#"- pattern: "alice@corp\\.com"
832  kind: regex
833  category: email
834  label: alice_email
835- pattern: sk-proj-abc123secret
836  kind: literal
837  category: "custom:api_key"
838  label: openai_key
839"#
840    }
841
842    fn sample_toml() -> &'static str {
843        r#"[[secrets]]
844pattern = "alice@corp\\.com"
845kind = "regex"
846category = "email"
847label = "alice_email"
848
849[[secrets]]
850pattern = "sk-proj-abc123secret"
851kind = "literal"
852category = "custom:api_key"
853label = "openai_key"
854"#
855    }
856
857    // ---- Parsing ----
858
859    #[test]
860    fn parse_json_entries() {
861        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
862        assert_eq!(entries.len(), 2);
863        assert_eq!(entries[0].kind, "regex");
864        assert_eq!(entries[0].category, "email");
865        assert_eq!(entries[1].kind, "literal");
866    }
867
868    #[test]
869    fn parse_yaml_entries() {
870        let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
871        assert_eq!(entries.len(), 2);
872        assert_eq!(entries[0].label, Some("alice_email".into()));
873    }
874
875    #[test]
876    fn parse_toml_entries() {
877        let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
878        assert_eq!(entries.len(), 2);
879        assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
880    }
881
882    #[test]
883    fn parse_auto_detect_json() {
884        let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
885        assert_eq!(entries.len(), 2);
886    }
887
888    #[test]
889    fn parse_auto_detect_yaml() {
890        let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
891        assert_eq!(entries.len(), 2);
892    }
893
894    // ---- Category parsing ----
895
896    #[test]
897    fn parse_builtin_categories() {
898        assert_eq!(parse_category("email"), Category::Email);
899        assert_eq!(parse_category("ipv4"), Category::IpV4);
900        assert_eq!(parse_category("ssn"), Category::Ssn);
901    }
902
903    #[test]
904    fn parse_custom_category() {
905        match parse_category("custom:api_key") {
906            Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
907            other => panic!("expected Custom, got {:?}", other),
908        }
909    }
910
911    #[test]
912    fn parse_unknown_category_becomes_custom() {
913        match parse_category("foobar") {
914            Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
915            other => panic!("expected Custom, got {:?}", other),
916        }
917    }
918
919    // ---- Entries to patterns ----
920
921    #[test]
922    fn entries_to_patterns_success() {
923        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
924        let (patterns, errors) = entries_to_patterns(&entries);
925        assert_eq!(patterns.len(), 2);
926        assert!(errors.is_empty());
927    }
928
929    #[test]
930    fn entries_to_patterns_applies_length_bounds() {
931        let json = r#"[
932            {"pattern": "[0-9]+", "kind": "regex", "category": "custom:num",
933             "min_length": 4, "max_length": 8},
934            {"pattern": "[a-z]+", "kind": "regex", "category": "custom:word"}
935        ]"#;
936        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
937        let (patterns, errors) = entries_to_patterns(&entries);
938        assert!(errors.is_empty());
939        assert_eq!(patterns.len(), 2);
940        assert_eq!(patterns[0].min_length, 4);
941        assert_eq!(patterns[0].max_length, 8);
942        // Unset bounds keep regex defaults (0 / unbounded).
943        assert_eq!(patterns[1].min_length, 0);
944        assert_eq!(patterns[1].max_length, usize::MAX);
945    }
946
947    #[test]
948    fn entries_to_patterns_bad_regex() {
949        let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
950        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
951        let (patterns, errors) = entries_to_patterns(&entries);
952        assert!(patterns.is_empty());
953        assert_eq!(errors.len(), 1);
954        assert_eq!(errors[0].0, 0);
955    }
956
957    // ---- Encrypt / Decrypt round-trip ----
958
959    #[test]
960    fn encrypt_decrypt_roundtrip() {
961        let plaintext = sample_json().as_bytes();
962        let password = "test-password-42";
963
964        let encrypted = encrypt_secrets(plaintext, password).unwrap();
965
966        // Encrypted blob must be larger than plaintext (salt + nonce + tag).
967        assert!(encrypted.len() > plaintext.len());
968
969        let decrypted = decrypt_secrets(&encrypted, password).unwrap();
970        assert_eq!(decrypted.as_slice(), plaintext);
971    }
972
973    #[test]
974    fn decrypt_wrong_password_fails() {
975        let plaintext = b"hello";
976        let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
977        let result = decrypt_secrets(&encrypted, "wrong");
978        assert!(result.is_err());
979    }
980
981    #[test]
982    fn decrypt_truncated_blob_fails() {
983        let result = decrypt_secrets(&[0u8; 10], "any");
984        assert!(result.is_err());
985    }
986
987    #[test]
988    fn decrypt_tampered_blob_fails() {
989        let plaintext = b"hello world";
990        let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
991        // Flip a byte in the ciphertext portion.
992        let last = encrypted.len() - 1;
993        encrypted[last] ^= 0xFF;
994        let result = decrypt_secrets(&encrypted, "pw");
995        assert!(result.is_err());
996    }
997
998    #[test]
999    fn encrypt_empty_password_rejected() {
1000        let result = encrypt_secrets(b"hello", "");
1001        assert!(result.is_err());
1002    }
1003
1004    // ---- Full pipeline: encrypt → decrypt → parse → patterns ----
1005
1006    #[test]
1007    fn full_pipeline_json() {
1008        let plaintext = sample_json().as_bytes();
1009        let password = "pipeline-test";
1010
1011        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1012        let ((patterns, errors), _allow) =
1013            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
1014
1015        assert_eq!(patterns.len(), 2);
1016        assert!(errors.is_empty());
1017        assert_eq!(patterns[0].label(), "alice_email");
1018        assert_eq!(patterns[1].label(), "openai_key");
1019    }
1020
1021    #[test]
1022    fn full_pipeline_yaml() {
1023        let plaintext = sample_yaml().as_bytes();
1024        let password = "yaml-test";
1025
1026        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1027        let ((patterns, errors), _allow) =
1028            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
1029
1030        assert_eq!(patterns.len(), 2);
1031        assert!(errors.is_empty());
1032    }
1033
1034    #[test]
1035    fn full_pipeline_toml() {
1036        let plaintext = sample_toml().as_bytes();
1037        let password = "toml-test";
1038
1039        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1040        let ((patterns, errors), _allow) =
1041            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
1042
1043        assert_eq!(patterns.len(), 2);
1044        assert!(errors.is_empty());
1045    }
1046
1047    // ---- Plaintext loader ----
1048
1049    #[test]
1050    fn load_plaintext_secrets_works() {
1051        let ((patterns, errors), _allow) =
1052            load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1053        assert_eq!(patterns.len(), 2);
1054        assert!(errors.is_empty());
1055    }
1056
1057    // ---- Serialization round-trip ----
1058
1059    #[test]
1060    fn serialize_roundtrip_json() {
1061        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1062        let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1063        let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1064        assert_eq!(entries.len(), reparsed.len());
1065        assert_eq!(entries[0].pattern, reparsed[0].pattern);
1066    }
1067
1068    // ---- Format detection ----
1069
1070    #[test]
1071    fn format_from_extension() {
1072        assert_eq!(
1073            SecretsFormat::from_extension("secrets.json"),
1074            Some(SecretsFormat::Json)
1075        );
1076        assert_eq!(
1077            SecretsFormat::from_extension("secrets.json.enc"),
1078            Some(SecretsFormat::Json)
1079        );
1080        assert_eq!(
1081            SecretsFormat::from_extension("secrets.yaml"),
1082            Some(SecretsFormat::Yaml)
1083        );
1084        assert_eq!(
1085            SecretsFormat::from_extension("secrets.yml.enc"),
1086            Some(SecretsFormat::Yaml)
1087        );
1088        assert_eq!(
1089            SecretsFormat::from_extension("secrets.toml"),
1090            Some(SecretsFormat::Toml)
1091        );
1092        assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1093    }
1094
1095    #[test]
1096    fn detect_yaml_with_leading_comment_header() {
1097        // Regression: the auto-provisioned global secrets file opens with '#'
1098        // comment lines. Before the fix, detect() saw '#' first, fell through
1099        // to the TOML fallback, and failed to parse valid YAML.
1100        let content = "# Global sanitize allowlist — add patterns here.\n# Auto-loaded on every plain run.\n\n- pattern: foo\n  kind: allow\n";
1101        assert_eq!(
1102            SecretsFormat::detect(content.as_bytes()),
1103            SecretsFormat::Yaml
1104        );
1105    }
1106
1107    #[test]
1108    fn detect_yaml_comment_header_parses_correctly() {
1109        // Round-trip: same shape as the auto-provisioned file must load without error.
1110        let content = "# Global sanitize allowlist — add patterns or kind:regex entries here.\n# Auto-loaded on every plain run. Edit freely; deleted values take effect immediately.\n\n- pattern: ''\n  kind: allow\n  category: ''\n  values:\n  - localhost\n  - 127.0.0.1\n";
1111        let entries = parse_secrets(content.as_bytes(), None)
1112            .expect("auto-provisioned secrets file with comment header must parse");
1113        assert_eq!(entries.len(), 1);
1114        assert_eq!(entries[0].kind, "allow");
1115        assert!(entries[0].values.contains(&"localhost".to_string()));
1116    }
1117
1118    #[test]
1119    fn detect_json_array() {
1120        assert_eq!(
1121            SecretsFormat::detect(b"[{\"pattern\": \"foo\"}]"),
1122            SecretsFormat::Json
1123        );
1124    }
1125
1126    #[test]
1127    fn detect_toml_fallback() {
1128        // TOML that doesn't open with '[' or '{' — must not be mistaken for YAML.
1129        assert_eq!(
1130            SecretsFormat::detect(b"# toml comment\nkey = \"value\""),
1131            SecretsFormat::Toml
1132        );
1133    }
1134
1135    // ---- Defaults ----
1136
1137    #[test]
1138    fn default_kind_is_literal() {
1139        let json = r#"[{"pattern": "foo"}]"#;
1140        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1141        assert_eq!(entries[0].kind, "literal");
1142    }
1143
1144    #[test]
1145    fn default_category_is_custom_secret() {
1146        let json = r#"[{"pattern": "foo"}]"#;
1147        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1148        assert_eq!(entries[0].category, "custom:secret");
1149    }
1150
1151    #[test]
1152    fn literal_default_label_is_category_not_value() {
1153        // A `literal` pattern's text IS the secret value, and labels surface in
1154        // summaries / reports / logs — so the default label must be the
1155        // category, never the value.
1156        let json = r#"[{"pattern": "short"}]"#;
1157        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1158        let (patterns, _) = entries_to_patterns(&entries);
1159        assert_eq!(patterns[0].label(), "literal:custom:secret");
1160        assert!(!patterns[0].label().contains("short"));
1161    }
1162
1163    #[test]
1164    fn regex_default_label_is_pattern() {
1165        // A regex pattern is not itself a secret, so its text remains the
1166        // informative default label.
1167        let json = r#"[{"pattern": "ab[0-9]+", "kind": "regex"}]"#;
1168        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1169        let (patterns, _) = entries_to_patterns(&entries);
1170        assert_eq!(patterns[0].label(), "ab[0-9]+");
1171    }
1172
1173    // ---- looks_encrypted ----
1174
1175    #[test]
1176    fn looks_encrypted_json_plaintext() {
1177        assert!(!looks_encrypted(sample_json().as_bytes()));
1178    }
1179
1180    #[test]
1181    fn looks_encrypted_yaml_plaintext() {
1182        assert!(!looks_encrypted(sample_yaml().as_bytes()));
1183    }
1184
1185    #[test]
1186    fn looks_encrypted_toml_plaintext() {
1187        assert!(!looks_encrypted(sample_toml().as_bytes()));
1188    }
1189
1190    #[test]
1191    fn looks_encrypted_actual_encrypted() {
1192        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1193        assert!(looks_encrypted(&encrypted));
1194    }
1195
1196    #[test]
1197    fn looks_encrypted_too_short() {
1198        assert!(!looks_encrypted(&[0u8; 10]));
1199    }
1200
1201    // ---- load_secrets_auto ----
1202
1203    #[test]
1204    fn auto_load_plaintext_json() {
1205        let data = sample_json().as_bytes();
1206        let (((pats, errs), _allow), was_enc) =
1207            load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1208        assert!(!was_enc);
1209        assert_eq!(pats.len(), 2);
1210        assert!(errs.is_empty());
1211    }
1212
1213    #[test]
1214    fn auto_load_encrypted_json() {
1215        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1216        let (((pats, errs), _allow), was_enc) =
1217            load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1218        assert!(was_enc);
1219        assert_eq!(pats.len(), 2);
1220        assert!(errs.is_empty());
1221    }
1222
1223    #[test]
1224    fn auto_load_force_plaintext() {
1225        let data = sample_json().as_bytes();
1226        let (((pats, _), _allow), was_enc) =
1227            load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1228        assert!(!was_enc);
1229        assert_eq!(pats.len(), 2);
1230    }
1231
1232    #[test]
1233    fn auto_load_encrypted_no_password_fails() {
1234        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1235        let result = load_secrets_auto(&encrypted, None, None, false);
1236        assert!(result.is_err());
1237    }
1238
1239    #[test]
1240    fn parse_secrets_rejects_oversized_input() {
1241        // Construct input just over the 10 MiB cap.
1242        let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1243        let result = parse_secrets(&oversized, None);
1244        assert!(result.is_err());
1245        let msg = result.unwrap_err().to_string();
1246        assert!(
1247            msg.contains("exceeding") || msg.contains("limit"),
1248            "unexpected error message: {msg}"
1249        );
1250    }
1251
1252    #[test]
1253    fn parse_secrets_accepts_input_at_limit() {
1254        // Valid JSON just at the cap boundary — should succeed or fail on
1255        // parse, not on the size check. We use a tiny valid payload here
1256        // to confirm the size gate does not block small files.
1257        let tiny = b"[]";
1258        let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1259        assert!(
1260            result.is_ok(),
1261            "unexpected error: {:?}",
1262            result.unwrap_err()
1263        );
1264    }
1265
1266    #[test]
1267    fn truncate_label_at_boundary() {
1268        let short = "a".repeat(32);
1269        assert_eq!(truncate_label(&short), short);
1270
1271        let long = "a".repeat(33);
1272        let truncated = truncate_label(&long);
1273        assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1274        // Character count (not byte count) must be within the limit.
1275        // The trailing '…' is 1 char; the rest must be < MAX_LABEL_CHARS.
1276        assert!(
1277            truncated.chars().count() <= MAX_LABEL_CHARS,
1278            "char count {} exceeds limit: {truncated}",
1279            truncated.chars().count()
1280        );
1281    }
1282
1283    // ---- Multi-value allow entries ----
1284
1285    #[test]
1286    fn allow_single_pattern_field() {
1287        let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1288        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1289        let patterns = extract_allow_patterns(&entries);
1290        assert_eq!(patterns, vec!["localhost"]);
1291    }
1292
1293    #[test]
1294    fn allow_values_list_used_instead_of_pattern() {
1295        let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1296        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1297        let patterns = extract_allow_patterns(&entries);
1298        assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1299    }
1300
1301    #[test]
1302    fn allow_values_list_yaml() {
1303        let yaml =
1304            "- kind: allow\n  values:\n    - localhost\n    - \"127.0.0.1\"\n    - \"0.0.0.0\"\n";
1305        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1306        let patterns = extract_allow_patterns(&entries);
1307        assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1308    }
1309
1310    #[test]
1311    fn allow_values_list_toml() {
1312        let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1313        let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1314        let patterns = extract_allow_patterns(&entries);
1315        assert_eq!(patterns, vec!["localhost", "true", "false"]);
1316    }
1317
1318    #[test]
1319    fn allow_mixed_single_and_multi_value_entries() {
1320        let json = r#"[
1321            {"kind":"allow","pattern":"localhost"},
1322            {"kind":"allow","values":["true","false","null"]},
1323            {"kind":"allow","pattern":"*.internal"}
1324        ]"#;
1325        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1326        let patterns = extract_allow_patterns(&entries);
1327        assert_eq!(
1328            patterns,
1329            vec!["localhost", "true", "false", "null", "*.internal"]
1330        );
1331    }
1332
1333    #[test]
1334    fn allow_entries_skipped_by_entries_to_patterns() {
1335        let json = r#"[
1336            {"pattern":"secret","kind":"literal"},
1337            {"kind":"allow","values":["localhost","true"]}
1338        ]"#;
1339        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1340        let (patterns, errors) = entries_to_patterns(&entries);
1341        assert_eq!(patterns.len(), 1);
1342        assert!(errors.is_empty());
1343        assert_eq!(patterns[0].label(), "literal:custom:secret");
1344    }
1345
1346    #[test]
1347    fn allow_empty_values_falls_back_to_pattern() {
1348        // An entry with an empty `values` list should still use `pattern`.
1349        let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1350        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1351        let patterns = extract_allow_patterns(&entries);
1352        assert_eq!(patterns, vec!["localhost"]);
1353    }
1354
1355    // ── kind: field-name ─────────────────────────────────────────────────────
1356
1357    #[test]
1358    fn field_name_entries_skipped_by_entries_to_patterns() {
1359        // kind:field-name entries must not produce ScanPatterns — they are
1360        // handled separately as FieldNameSignals injected into profiles.
1361        let json = r#"[
1362            {"pattern":"secret","kind":"literal"},
1363            {"pattern":"^password$","kind":"field-name","threshold":3.0}
1364        ]"#;
1365        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1366        let (patterns, errors) = entries_to_patterns(&entries);
1367        assert_eq!(
1368            patterns.len(),
1369            1,
1370            "only the literal entry should produce a pattern"
1371        );
1372        assert!(errors.is_empty());
1373        assert_eq!(patterns[0].label(), "literal:custom:secret");
1374    }
1375
1376    #[test]
1377    fn field_name_entry_parses_correctly() {
1378        let yaml = "- kind: field-name\n  pattern: \"^(password|secret)$\"\n  threshold: 3.0\n  label: my-signal\n";
1379        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1380        assert_eq!(entries.len(), 1);
1381        assert_eq!(entries[0].kind, "field-name");
1382        assert_eq!(entries[0].pattern, "^(password|secret)$");
1383        assert_eq!(entries[0].threshold, Some(3.0));
1384        assert_eq!(entries[0].label, Some("my-signal".into()));
1385    }
1386
1387    #[test]
1388    fn field_name_entry_not_extracted_as_allow_pattern() {
1389        // kind:field-name entries must not bleed into the allowlist.
1390        let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1391        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1392        let allow = extract_allow_patterns(&entries);
1393        assert!(allow.is_empty());
1394    }
1395}