1use crate::category::Category;
69use crate::error::{Result, SanitizeError};
70use crate::scanner::ScanPattern;
71
72pub 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
84const SALT_LEN: usize = 32;
90
91const NONCE_LEN: usize = 12;
93
94const PBKDF2_ITERATIONS: u32 = 600_000;
96
97const MIN_ENCRYPTED_LEN: usize = SALT_LEN + NONCE_LEN + 16;
99
100const MAX_SECRETS_PLAINTEXT_BYTES: usize = 10 * 1024 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SecretEntry {
117 #[serde(default)]
122 pub pattern: String,
123
124 #[serde(default = "default_kind")]
132 pub kind: String,
133
134 #[serde(default = "default_category")]
140 pub category: String,
141
142 #[serde(default)]
148 pub label: Option<String>,
149
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub values: Vec<String>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub min_length: Option<usize>,
167
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub max_length: Option<usize>,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub threshold: Option<f64>,
176
177 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum SecretsFormat {
211 Json,
212 Yaml,
213 Toml,
214}
215
216impl SecretsFormat {
217 pub fn from_extension(path: &str) -> Option<Self> {
219 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 pub fn detect(content: &[u8]) -> Self {
237 let s = String::from_utf8_lossy(content);
238 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 Self::Json
251 } else if first_meaningful.starts_with('-') || first_meaningful.starts_with("---") {
252 Self::Yaml
253 } else {
254 Self::Toml
256 }
257 }
258}
259
260#[derive(Deserialize)]
266struct TomlSecrets {
267 secrets: Vec<SecretEntry>,
268}
269
270#[derive(Serialize)]
272struct TomlSecretsRef<'a> {
273 secrets: &'a [SecretEntry],
274}
275
276fn 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
288pub 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 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 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 let ciphertext = cipher
333 .encrypt(nonce, plaintext)
334 .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
335
336 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
345pub 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
384pub 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
438pub 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
471pub 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
508pub 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
544pub 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 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 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
610fn truncate_label(s: &str) -> String {
612 if s.len() <= MAX_LABEL_CHARS {
613 s.to_string()
614 } else {
615 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
625pub 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 drop(entries);
665 Ok((result, allow))
666}
667
668pub 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 drop(entries);
699 Ok((result, allow))
700}
701
702pub fn looks_encrypted(data: &[u8]) -> bool {
724 if data.len() < MIN_ENCRYPTED_LEN {
725 return false;
728 }
729 if let Ok(text) = std::str::from_utf8(data) {
732 let trimmed = text.trim_start();
733 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 true
759}
760
761pub 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#[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 #[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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 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 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 assert_eq!(
1130 SecretsFormat::detect(b"# toml comment\nkey = \"value\""),
1131 SecretsFormat::Toml
1132 );
1133 }
1134
1135 #[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 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 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 #[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 #[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 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 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 assert!(
1277 truncated.chars().count() <= MAX_LABEL_CHARS,
1278 "char count {} exceeds limit: {truncated}",
1279 truncated.chars().count()
1280 );
1281 }
1282
1283 #[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 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 #[test]
1358 fn field_name_entries_skipped_by_entries_to_patterns() {
1359 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 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}