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)]
145 pub label: Option<String>,
146
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub values: Vec<String>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub min_length: Option<usize>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub max_length: Option<usize>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub threshold: Option<f64>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub charset: Option<String>,
178}
179
180impl Drop for SecretEntry {
181 fn drop(&mut self) {
182 self.pattern.zeroize();
183 self.kind.zeroize();
184 self.category.zeroize();
185 if let Some(ref mut l) = self.label {
186 l.zeroize();
187 }
188 for v in &mut self.values {
189 v.zeroize();
190 }
191 if let Some(ref mut s) = self.charset {
192 s.zeroize();
193 }
194 }
195}
196
197fn default_kind() -> String {
198 "literal".into()
199}
200
201fn default_category() -> String {
202 "custom:secret".into()
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum SecretsFormat {
208 Json,
209 Yaml,
210 Toml,
211}
212
213impl SecretsFormat {
214 pub fn from_extension(path: &str) -> Option<Self> {
216 let base = path.strip_suffix(".enc").unwrap_or(path);
218 let ext = std::path::Path::new(base).extension();
219 if ext.is_some_and(|e| e.eq_ignore_ascii_case("json")) {
220 Some(Self::Json)
221 } else if ext
222 .is_some_and(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
223 {
224 Some(Self::Yaml)
225 } else if ext.is_some_and(|e| e.eq_ignore_ascii_case("toml")) {
226 Some(Self::Toml)
227 } else {
228 None
229 }
230 }
231
232 pub fn detect(content: &[u8]) -> Self {
234 let s = String::from_utf8_lossy(content);
235 let first_meaningful = s
239 .lines()
240 .map(str::trim)
241 .find(|l| !l.is_empty() && !l.starts_with('#'))
242 .unwrap_or("");
243 if first_meaningful.starts_with('[') || first_meaningful.starts_with('{') {
244 Self::Json
248 } else if first_meaningful.starts_with('-') || first_meaningful.starts_with("---") {
249 Self::Yaml
250 } else {
251 Self::Toml
253 }
254 }
255}
256
257#[derive(Deserialize)]
263struct TomlSecrets {
264 secrets: Vec<SecretEntry>,
265}
266
267#[derive(Serialize)]
269struct TomlSecretsRef<'a> {
270 secrets: &'a [SecretEntry],
271}
272
273fn derive_key(password: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
279 let mut key = Zeroizing::new([0u8; 32]);
280 pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, PBKDF2_ITERATIONS, key.as_mut())
281 .expect("PBKDF2 output length is valid");
282 key
283}
284
285pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
309 if password.is_empty() {
310 return Err(SanitizeError::SecretsEmptyPassword);
311 }
312
313 let mut rng = rand::rng();
314
315 let mut salt = [0u8; SALT_LEN];
317 rng.fill_bytes(&mut salt);
318
319 let mut nonce_bytes = [0u8; NONCE_LEN];
320 rng.fill_bytes(&mut nonce_bytes);
321 let nonce = Nonce::from_slice(&nonce_bytes);
322
323 let key = derive_key(password.as_bytes(), &salt);
325 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
326 .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
327
328 let ciphertext = cipher
330 .encrypt(nonce, plaintext)
331 .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
332
333 let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
335 output.extend_from_slice(&salt);
336 output.extend_from_slice(&nonce_bytes);
337 output.extend_from_slice(&ciphertext);
338
339 Ok(output)
340}
341
342pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
360 if encrypted.len() < MIN_ENCRYPTED_LEN {
361 return Err(SanitizeError::SecretsTooShort);
362 }
363
364 let salt = &encrypted[..SALT_LEN];
365 let nonce_bytes = &encrypted[SALT_LEN..SALT_LEN + NONCE_LEN];
366 let ciphertext = &encrypted[SALT_LEN + NONCE_LEN..];
367
368 let nonce = Nonce::from_slice(nonce_bytes);
369
370 let key = derive_key(password.as_bytes(), salt);
371 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
372 .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
373
374 let plaintext = cipher
375 .decrypt(nonce, ciphertext)
376 .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
377
378 Ok(Zeroizing::new(plaintext))
379}
380
381pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
396 if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
397 return Err(SanitizeError::SecretsFormatError {
398 format: "secrets file".into(),
399 message: format!(
400 "file is {} bytes, exceeding the {} byte limit — \
401 secrets files should be small YAML/JSON/TOML pattern lists",
402 plaintext.len(),
403 MAX_SECRETS_PLAINTEXT_BYTES,
404 ),
405 });
406 }
407 let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
408 let text = std::str::from_utf8(plaintext)
409 .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
410
411 match fmt {
412 SecretsFormat::Json => {
413 serde_json::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
414 format: "JSON".into(),
415 message: e.to_string(),
416 })
417 }
418 SecretsFormat::Yaml => {
419 serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
420 format: "YAML".into(),
421 message: e.to_string(),
422 })
423 }
424 SecretsFormat::Toml => {
425 let wrapper: TomlSecrets =
426 toml::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
427 format: "TOML".into(),
428 message: e.to_string(),
429 })?;
430 Ok(wrapper.secrets)
431 }
432 }
433}
434
435pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
443 match format {
444 SecretsFormat::Json => {
445 serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
446 format: "JSON-serialize".into(),
447 message: e.to_string(),
448 })
449 }
450 SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
451 .map(|s| s.into_bytes())
452 .map_err(|e| SanitizeError::SecretsFormatError {
453 format: "YAML-serialize".into(),
454 message: e.to_string(),
455 }),
456 SecretsFormat::Toml => {
457 let wrapper = TomlSecretsRef { secrets: entries };
458 toml::to_string_pretty(&wrapper)
459 .map(|s| s.into_bytes())
460 .map_err(|e| SanitizeError::SecretsFormatError {
461 format: "TOML-serialize".into(),
462 message: e.to_string(),
463 })
464 }
465 }
466}
467
468pub fn parse_category(s: &str) -> Category {
479 match s {
480 "email" => Category::Email,
481 "name" => Category::Name,
482 "phone" => Category::Phone,
483 "ipv4" => Category::IpV4,
484 "ipv6" => Category::IpV6,
485 "credit_card" => Category::CreditCard,
486 "ssn" => Category::Ssn,
487 "hostname" => Category::Hostname,
488 "mac_address" => Category::MacAddress,
489 "container_id" => Category::ContainerId,
490 "uuid" => Category::Uuid,
491 "jwt" => Category::Jwt,
492 "auth_token" => Category::AuthToken,
493 "file_path" => Category::FilePath,
494 "windows_sid" => Category::WindowsSid,
495 "url" => Category::Url,
496 "aws_arn" => Category::AwsArn,
497 "azure_resource_id" => Category::AzureResourceId,
498 other => {
499 let tag = other.strip_prefix("custom:").unwrap_or(other);
500 Category::Custom(tag.into())
501 }
502 }
503}
504
505pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
530 let mut patterns = Vec::new();
531 for entry in entries.iter().filter(|e| e.kind == "allow") {
532 if !entry.values.is_empty() {
533 patterns.extend(entry.values.iter().cloned());
534 } else if !entry.pattern.is_empty() {
535 patterns.push(entry.pattern.clone());
536 }
537 }
538 patterns
539}
540
541pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
549 let mut patterns = Vec::with_capacity(entries.len());
550 let mut errors = Vec::new();
551
552 for (i, entry) in entries.iter().enumerate() {
553 if entry.kind == "allow"
554 || entry.kind == "entropy"
555 || entry.kind == "field-name"
556 || entry.pattern.is_empty()
557 {
558 continue;
559 }
560 let category = parse_category(&entry.category);
561 let label = entry
562 .label
563 .clone()
564 .unwrap_or_else(|| truncate_label(&entry.pattern));
565
566 let result = match entry.kind.as_str() {
567 "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
568 "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
569 other => {
570 errors.push((
571 i,
572 SanitizeError::InvalidConfig(format!(
573 "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
574 other
575 )),
576 ));
577 continue;
578 }
579 };
580
581 match result {
582 Ok(pat) => patterns.push(pat),
583 Err(e) => errors.push((i, e)),
584 }
585 }
586
587 (patterns, errors)
588}
589
590const MAX_LABEL_CHARS: usize = 32;
591
592fn truncate_label(s: &str) -> String {
594 if s.len() <= MAX_LABEL_CHARS {
595 s.to_string()
596 } else {
597 let cut = s
600 .char_indices()
601 .nth(MAX_LABEL_CHARS - 1)
602 .map_or(s.len(), |(i, _)| i);
603 format!("{}…", &s[..cut])
604 }
605}
606
607pub fn load_encrypted_secrets(
636 encrypted_bytes: &[u8],
637 password: &str,
638 format: Option<SecretsFormat>,
639) -> Result<(PatternCompileResult, Vec<String>)> {
640 let plaintext = decrypt_secrets(encrypted_bytes, password)?;
641 let entries = parse_secrets(&plaintext, format)?;
642 let allow = extract_allow_patterns(&entries);
643 let result = entries_to_patterns(&entries);
644 drop(entries);
647 Ok((result, allow))
648}
649
650pub fn load_plaintext_secrets(
672 plaintext: &[u8],
673 format: Option<SecretsFormat>,
674) -> Result<(PatternCompileResult, Vec<String>)> {
675 let entries = parse_secrets(plaintext, format)?;
676 let allow = extract_allow_patterns(&entries);
677 let result = entries_to_patterns(&entries);
678 drop(entries);
681 Ok((result, allow))
682}
683
684pub fn looks_encrypted(data: &[u8]) -> bool {
706 if data.len() < MIN_ENCRYPTED_LEN {
707 return false;
710 }
711 if let Ok(text) = std::str::from_utf8(data) {
714 let trimmed = text.trim_start();
715 let first_char = trimmed.chars().next();
730 let has_marker = trimmed.starts_with('[')
731 || trimmed.starts_with('{')
732 || trimmed.starts_with('-')
733 || trimmed.starts_with('#')
734 || first_char.is_some_and(|c| c.is_ascii_alphabetic());
735 if has_marker {
736 return false;
737 }
738 }
739 true
741}
742
743pub fn load_secrets_auto(
772 data: &[u8],
773 password: Option<&str>,
774 format: Option<SecretsFormat>,
775 force_plaintext: bool,
776) -> Result<((PatternCompileResult, Vec<String>), bool)> {
777 if force_plaintext || !looks_encrypted(data) {
778 let (result, allow) = load_plaintext_secrets(data, format)?;
779 Ok(((result, allow), false))
780 } else {
781 let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
782 let (result, allow) = load_encrypted_secrets(data, pw, format)?;
783 Ok(((result, allow), true))
784 }
785}
786
787#[cfg(test)]
792mod tests {
793 use super::*;
794
795 fn sample_json() -> &'static str {
796 r#"[
797 {
798 "pattern": "alice@corp\\.com",
799 "kind": "regex",
800 "category": "email",
801 "label": "alice_email"
802 },
803 {
804 "pattern": "sk-proj-abc123secret",
805 "kind": "literal",
806 "category": "custom:api_key",
807 "label": "openai_key"
808 }
809 ]"#
810 }
811
812 fn sample_yaml() -> &'static str {
813 r#"- pattern: "alice@corp\\.com"
814 kind: regex
815 category: email
816 label: alice_email
817- pattern: sk-proj-abc123secret
818 kind: literal
819 category: "custom:api_key"
820 label: openai_key
821"#
822 }
823
824 fn sample_toml() -> &'static str {
825 r#"[[secrets]]
826pattern = "alice@corp\\.com"
827kind = "regex"
828category = "email"
829label = "alice_email"
830
831[[secrets]]
832pattern = "sk-proj-abc123secret"
833kind = "literal"
834category = "custom:api_key"
835label = "openai_key"
836"#
837 }
838
839 #[test]
842 fn parse_json_entries() {
843 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
844 assert_eq!(entries.len(), 2);
845 assert_eq!(entries[0].kind, "regex");
846 assert_eq!(entries[0].category, "email");
847 assert_eq!(entries[1].kind, "literal");
848 }
849
850 #[test]
851 fn parse_yaml_entries() {
852 let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
853 assert_eq!(entries.len(), 2);
854 assert_eq!(entries[0].label, Some("alice_email".into()));
855 }
856
857 #[test]
858 fn parse_toml_entries() {
859 let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
860 assert_eq!(entries.len(), 2);
861 assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
862 }
863
864 #[test]
865 fn parse_auto_detect_json() {
866 let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
867 assert_eq!(entries.len(), 2);
868 }
869
870 #[test]
871 fn parse_auto_detect_yaml() {
872 let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
873 assert_eq!(entries.len(), 2);
874 }
875
876 #[test]
879 fn parse_builtin_categories() {
880 assert_eq!(parse_category("email"), Category::Email);
881 assert_eq!(parse_category("ipv4"), Category::IpV4);
882 assert_eq!(parse_category("ssn"), Category::Ssn);
883 }
884
885 #[test]
886 fn parse_custom_category() {
887 match parse_category("custom:api_key") {
888 Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
889 other => panic!("expected Custom, got {:?}", other),
890 }
891 }
892
893 #[test]
894 fn parse_unknown_category_becomes_custom() {
895 match parse_category("foobar") {
896 Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
897 other => panic!("expected Custom, got {:?}", other),
898 }
899 }
900
901 #[test]
904 fn entries_to_patterns_success() {
905 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
906 let (patterns, errors) = entries_to_patterns(&entries);
907 assert_eq!(patterns.len(), 2);
908 assert!(errors.is_empty());
909 }
910
911 #[test]
912 fn entries_to_patterns_bad_regex() {
913 let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
914 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
915 let (patterns, errors) = entries_to_patterns(&entries);
916 assert!(patterns.is_empty());
917 assert_eq!(errors.len(), 1);
918 assert_eq!(errors[0].0, 0);
919 }
920
921 #[test]
924 fn encrypt_decrypt_roundtrip() {
925 let plaintext = sample_json().as_bytes();
926 let password = "test-password-42";
927
928 let encrypted = encrypt_secrets(plaintext, password).unwrap();
929
930 assert!(encrypted.len() > plaintext.len());
932
933 let decrypted = decrypt_secrets(&encrypted, password).unwrap();
934 assert_eq!(decrypted.as_slice(), plaintext);
935 }
936
937 #[test]
938 fn decrypt_wrong_password_fails() {
939 let plaintext = b"hello";
940 let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
941 let result = decrypt_secrets(&encrypted, "wrong");
942 assert!(result.is_err());
943 }
944
945 #[test]
946 fn decrypt_truncated_blob_fails() {
947 let result = decrypt_secrets(&[0u8; 10], "any");
948 assert!(result.is_err());
949 }
950
951 #[test]
952 fn decrypt_tampered_blob_fails() {
953 let plaintext = b"hello world";
954 let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
955 let last = encrypted.len() - 1;
957 encrypted[last] ^= 0xFF;
958 let result = decrypt_secrets(&encrypted, "pw");
959 assert!(result.is_err());
960 }
961
962 #[test]
963 fn encrypt_empty_password_rejected() {
964 let result = encrypt_secrets(b"hello", "");
965 assert!(result.is_err());
966 }
967
968 #[test]
971 fn full_pipeline_json() {
972 let plaintext = sample_json().as_bytes();
973 let password = "pipeline-test";
974
975 let encrypted = encrypt_secrets(plaintext, password).unwrap();
976 let ((patterns, errors), _allow) =
977 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
978
979 assert_eq!(patterns.len(), 2);
980 assert!(errors.is_empty());
981 assert_eq!(patterns[0].label(), "alice_email");
982 assert_eq!(patterns[1].label(), "openai_key");
983 }
984
985 #[test]
986 fn full_pipeline_yaml() {
987 let plaintext = sample_yaml().as_bytes();
988 let password = "yaml-test";
989
990 let encrypted = encrypt_secrets(plaintext, password).unwrap();
991 let ((patterns, errors), _allow) =
992 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
993
994 assert_eq!(patterns.len(), 2);
995 assert!(errors.is_empty());
996 }
997
998 #[test]
999 fn full_pipeline_toml() {
1000 let plaintext = sample_toml().as_bytes();
1001 let password = "toml-test";
1002
1003 let encrypted = encrypt_secrets(plaintext, password).unwrap();
1004 let ((patterns, errors), _allow) =
1005 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
1006
1007 assert_eq!(patterns.len(), 2);
1008 assert!(errors.is_empty());
1009 }
1010
1011 #[test]
1014 fn load_plaintext_secrets_works() {
1015 let ((patterns, errors), _allow) =
1016 load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1017 assert_eq!(patterns.len(), 2);
1018 assert!(errors.is_empty());
1019 }
1020
1021 #[test]
1024 fn serialize_roundtrip_json() {
1025 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1026 let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1027 let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1028 assert_eq!(entries.len(), reparsed.len());
1029 assert_eq!(entries[0].pattern, reparsed[0].pattern);
1030 }
1031
1032 #[test]
1035 fn format_from_extension() {
1036 assert_eq!(
1037 SecretsFormat::from_extension("secrets.json"),
1038 Some(SecretsFormat::Json)
1039 );
1040 assert_eq!(
1041 SecretsFormat::from_extension("secrets.json.enc"),
1042 Some(SecretsFormat::Json)
1043 );
1044 assert_eq!(
1045 SecretsFormat::from_extension("secrets.yaml"),
1046 Some(SecretsFormat::Yaml)
1047 );
1048 assert_eq!(
1049 SecretsFormat::from_extension("secrets.yml.enc"),
1050 Some(SecretsFormat::Yaml)
1051 );
1052 assert_eq!(
1053 SecretsFormat::from_extension("secrets.toml"),
1054 Some(SecretsFormat::Toml)
1055 );
1056 assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1057 }
1058
1059 #[test]
1060 fn detect_yaml_with_leading_comment_header() {
1061 let content = "# Global sanitize allowlist — add patterns here.\n# Auto-loaded on every plain run.\n\n- pattern: foo\n kind: allow\n";
1065 assert_eq!(
1066 SecretsFormat::detect(content.as_bytes()),
1067 SecretsFormat::Yaml
1068 );
1069 }
1070
1071 #[test]
1072 fn detect_yaml_comment_header_parses_correctly() {
1073 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";
1075 let entries = parse_secrets(content.as_bytes(), None)
1076 .expect("auto-provisioned secrets file with comment header must parse");
1077 assert_eq!(entries.len(), 1);
1078 assert_eq!(entries[0].kind, "allow");
1079 assert!(entries[0].values.contains(&"localhost".to_string()));
1080 }
1081
1082 #[test]
1083 fn detect_json_array() {
1084 assert_eq!(
1085 SecretsFormat::detect(b"[{\"pattern\": \"foo\"}]"),
1086 SecretsFormat::Json
1087 );
1088 }
1089
1090 #[test]
1091 fn detect_toml_fallback() {
1092 assert_eq!(
1094 SecretsFormat::detect(b"# toml comment\nkey = \"value\""),
1095 SecretsFormat::Toml
1096 );
1097 }
1098
1099 #[test]
1102 fn default_kind_is_literal() {
1103 let json = r#"[{"pattern": "foo"}]"#;
1104 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1105 assert_eq!(entries[0].kind, "literal");
1106 }
1107
1108 #[test]
1109 fn default_category_is_custom_secret() {
1110 let json = r#"[{"pattern": "foo"}]"#;
1111 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1112 assert_eq!(entries[0].category, "custom:secret");
1113 }
1114
1115 #[test]
1116 fn default_label_from_pattern() {
1117 let json = r#"[{"pattern": "short"}]"#;
1118 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1119 let (patterns, _) = entries_to_patterns(&entries);
1120 assert_eq!(patterns[0].label(), "short");
1121 }
1122
1123 #[test]
1126 fn looks_encrypted_json_plaintext() {
1127 assert!(!looks_encrypted(sample_json().as_bytes()));
1128 }
1129
1130 #[test]
1131 fn looks_encrypted_yaml_plaintext() {
1132 assert!(!looks_encrypted(sample_yaml().as_bytes()));
1133 }
1134
1135 #[test]
1136 fn looks_encrypted_toml_plaintext() {
1137 assert!(!looks_encrypted(sample_toml().as_bytes()));
1138 }
1139
1140 #[test]
1141 fn looks_encrypted_actual_encrypted() {
1142 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1143 assert!(looks_encrypted(&encrypted));
1144 }
1145
1146 #[test]
1147 fn looks_encrypted_too_short() {
1148 assert!(!looks_encrypted(&[0u8; 10]));
1149 }
1150
1151 #[test]
1154 fn auto_load_plaintext_json() {
1155 let data = sample_json().as_bytes();
1156 let (((pats, errs), _allow), was_enc) =
1157 load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1158 assert!(!was_enc);
1159 assert_eq!(pats.len(), 2);
1160 assert!(errs.is_empty());
1161 }
1162
1163 #[test]
1164 fn auto_load_encrypted_json() {
1165 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1166 let (((pats, errs), _allow), was_enc) =
1167 load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1168 assert!(was_enc);
1169 assert_eq!(pats.len(), 2);
1170 assert!(errs.is_empty());
1171 }
1172
1173 #[test]
1174 fn auto_load_force_plaintext() {
1175 let data = sample_json().as_bytes();
1176 let (((pats, _), _allow), was_enc) =
1177 load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1178 assert!(!was_enc);
1179 assert_eq!(pats.len(), 2);
1180 }
1181
1182 #[test]
1183 fn auto_load_encrypted_no_password_fails() {
1184 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1185 let result = load_secrets_auto(&encrypted, None, None, false);
1186 assert!(result.is_err());
1187 }
1188
1189 #[test]
1190 fn parse_secrets_rejects_oversized_input() {
1191 let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1193 let result = parse_secrets(&oversized, None);
1194 assert!(result.is_err());
1195 let msg = result.unwrap_err().to_string();
1196 assert!(
1197 msg.contains("exceeding") || msg.contains("limit"),
1198 "unexpected error message: {msg}"
1199 );
1200 }
1201
1202 #[test]
1203 fn parse_secrets_accepts_input_at_limit() {
1204 let tiny = b"[]";
1208 let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1209 assert!(
1210 result.is_ok(),
1211 "unexpected error: {:?}",
1212 result.unwrap_err()
1213 );
1214 }
1215
1216 #[test]
1217 fn truncate_label_at_boundary() {
1218 let short = "a".repeat(32);
1219 assert_eq!(truncate_label(&short), short);
1220
1221 let long = "a".repeat(33);
1222 let truncated = truncate_label(&long);
1223 assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1224 assert!(
1227 truncated.chars().count() <= MAX_LABEL_CHARS,
1228 "char count {} exceeds limit: {truncated}",
1229 truncated.chars().count()
1230 );
1231 }
1232
1233 #[test]
1236 fn allow_single_pattern_field() {
1237 let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1238 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1239 let patterns = extract_allow_patterns(&entries);
1240 assert_eq!(patterns, vec!["localhost"]);
1241 }
1242
1243 #[test]
1244 fn allow_values_list_used_instead_of_pattern() {
1245 let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1246 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1247 let patterns = extract_allow_patterns(&entries);
1248 assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1249 }
1250
1251 #[test]
1252 fn allow_values_list_yaml() {
1253 let yaml =
1254 "- kind: allow\n values:\n - localhost\n - \"127.0.0.1\"\n - \"0.0.0.0\"\n";
1255 let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1256 let patterns = extract_allow_patterns(&entries);
1257 assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1258 }
1259
1260 #[test]
1261 fn allow_values_list_toml() {
1262 let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1263 let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1264 let patterns = extract_allow_patterns(&entries);
1265 assert_eq!(patterns, vec!["localhost", "true", "false"]);
1266 }
1267
1268 #[test]
1269 fn allow_mixed_single_and_multi_value_entries() {
1270 let json = r#"[
1271 {"kind":"allow","pattern":"localhost"},
1272 {"kind":"allow","values":["true","false","null"]},
1273 {"kind":"allow","pattern":"*.internal"}
1274 ]"#;
1275 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1276 let patterns = extract_allow_patterns(&entries);
1277 assert_eq!(
1278 patterns,
1279 vec!["localhost", "true", "false", "null", "*.internal"]
1280 );
1281 }
1282
1283 #[test]
1284 fn allow_entries_skipped_by_entries_to_patterns() {
1285 let json = r#"[
1286 {"pattern":"secret","kind":"literal"},
1287 {"kind":"allow","values":["localhost","true"]}
1288 ]"#;
1289 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1290 let (patterns, errors) = entries_to_patterns(&entries);
1291 assert_eq!(patterns.len(), 1);
1292 assert!(errors.is_empty());
1293 assert_eq!(patterns[0].label(), "secret");
1294 }
1295
1296 #[test]
1297 fn allow_empty_values_falls_back_to_pattern() {
1298 let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1300 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1301 let patterns = extract_allow_patterns(&entries);
1302 assert_eq!(patterns, vec!["localhost"]);
1303 }
1304
1305 #[test]
1308 fn field_name_entries_skipped_by_entries_to_patterns() {
1309 let json = r#"[
1312 {"pattern":"secret","kind":"literal"},
1313 {"pattern":"^password$","kind":"field-name","threshold":3.0}
1314 ]"#;
1315 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1316 let (patterns, errors) = entries_to_patterns(&entries);
1317 assert_eq!(
1318 patterns.len(),
1319 1,
1320 "only the literal entry should produce a pattern"
1321 );
1322 assert!(errors.is_empty());
1323 assert_eq!(patterns[0].label(), "secret");
1324 }
1325
1326 #[test]
1327 fn field_name_entry_parses_correctly() {
1328 let yaml = "- kind: field-name\n pattern: \"^(password|secret)$\"\n threshold: 3.0\n label: my-signal\n";
1329 let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1330 assert_eq!(entries.len(), 1);
1331 assert_eq!(entries[0].kind, "field-name");
1332 assert_eq!(entries[0].pattern, "^(password|secret)$");
1333 assert_eq!(entries[0].threshold, Some(3.0));
1334 assert_eq!(entries[0].label, Some("my-signal".into()));
1335 }
1336
1337 #[test]
1338 fn field_name_entry_not_extracted_as_allow_pattern() {
1339 let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1341 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1342 let allow = extract_allow_patterns(&entries);
1343 assert!(allow.is_empty());
1344 }
1345}