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 trimmed = s.trim_start();
236 if trimmed.starts_with('[') || trimmed.starts_with('{') {
237 Self::Json
241 } else if trimmed.starts_with('-') || trimmed.starts_with("---") {
242 Self::Yaml
243 } else {
244 Self::Toml
246 }
247 }
248}
249
250#[derive(Deserialize)]
256struct TomlSecrets {
257 secrets: Vec<SecretEntry>,
258}
259
260#[derive(Serialize)]
262struct TomlSecretsRef<'a> {
263 secrets: &'a [SecretEntry],
264}
265
266fn derive_key(password: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
272 let mut key = Zeroizing::new([0u8; 32]);
273 pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, PBKDF2_ITERATIONS, key.as_mut())
274 .expect("PBKDF2 output length is valid");
275 key
276}
277
278pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
302 if password.is_empty() {
303 return Err(SanitizeError::SecretsEmptyPassword);
304 }
305
306 let mut rng = rand::rng();
307
308 let mut salt = [0u8; SALT_LEN];
310 rng.fill_bytes(&mut salt);
311
312 let mut nonce_bytes = [0u8; NONCE_LEN];
313 rng.fill_bytes(&mut nonce_bytes);
314 let nonce = Nonce::from_slice(&nonce_bytes);
315
316 let key = derive_key(password.as_bytes(), &salt);
318 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
319 .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
320
321 let ciphertext = cipher
323 .encrypt(nonce, plaintext)
324 .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
325
326 let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
328 output.extend_from_slice(&salt);
329 output.extend_from_slice(&nonce_bytes);
330 output.extend_from_slice(&ciphertext);
331
332 Ok(output)
333}
334
335pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
353 if encrypted.len() < MIN_ENCRYPTED_LEN {
354 return Err(SanitizeError::SecretsTooShort);
355 }
356
357 let salt = &encrypted[..SALT_LEN];
358 let nonce_bytes = &encrypted[SALT_LEN..SALT_LEN + NONCE_LEN];
359 let ciphertext = &encrypted[SALT_LEN + NONCE_LEN..];
360
361 let nonce = Nonce::from_slice(nonce_bytes);
362
363 let key = derive_key(password.as_bytes(), salt);
364 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
365 .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
366
367 let plaintext = cipher
368 .decrypt(nonce, ciphertext)
369 .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
370
371 Ok(Zeroizing::new(plaintext))
372}
373
374pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
389 if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
390 return Err(SanitizeError::SecretsFormatError {
391 format: "secrets file".into(),
392 message: format!(
393 "file is {} bytes, exceeding the {} byte limit — \
394 secrets files should be small YAML/JSON/TOML pattern lists",
395 plaintext.len(),
396 MAX_SECRETS_PLAINTEXT_BYTES,
397 ),
398 });
399 }
400 let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
401 let text = std::str::from_utf8(plaintext)
402 .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
403
404 match fmt {
405 SecretsFormat::Json => {
406 serde_json::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
407 format: "JSON".into(),
408 message: e.to_string(),
409 })
410 }
411 SecretsFormat::Yaml => {
412 serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
413 format: "YAML".into(),
414 message: e.to_string(),
415 })
416 }
417 SecretsFormat::Toml => {
418 let wrapper: TomlSecrets =
419 toml::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
420 format: "TOML".into(),
421 message: e.to_string(),
422 })?;
423 Ok(wrapper.secrets)
424 }
425 }
426}
427
428pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
436 match format {
437 SecretsFormat::Json => {
438 serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
439 format: "JSON-serialize".into(),
440 message: e.to_string(),
441 })
442 }
443 SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
444 .map(|s| s.into_bytes())
445 .map_err(|e| SanitizeError::SecretsFormatError {
446 format: "YAML-serialize".into(),
447 message: e.to_string(),
448 }),
449 SecretsFormat::Toml => {
450 let wrapper = TomlSecretsRef { secrets: entries };
451 toml::to_string_pretty(&wrapper)
452 .map(|s| s.into_bytes())
453 .map_err(|e| SanitizeError::SecretsFormatError {
454 format: "TOML-serialize".into(),
455 message: e.to_string(),
456 })
457 }
458 }
459}
460
461pub fn parse_category(s: &str) -> Category {
472 match s {
473 "email" => Category::Email,
474 "name" => Category::Name,
475 "phone" => Category::Phone,
476 "ipv4" => Category::IpV4,
477 "ipv6" => Category::IpV6,
478 "credit_card" => Category::CreditCard,
479 "ssn" => Category::Ssn,
480 "hostname" => Category::Hostname,
481 "mac_address" => Category::MacAddress,
482 "container_id" => Category::ContainerId,
483 "uuid" => Category::Uuid,
484 "jwt" => Category::Jwt,
485 "auth_token" => Category::AuthToken,
486 "file_path" => Category::FilePath,
487 "windows_sid" => Category::WindowsSid,
488 "url" => Category::Url,
489 "aws_arn" => Category::AwsArn,
490 "azure_resource_id" => Category::AzureResourceId,
491 other => {
492 let tag = other.strip_prefix("custom:").unwrap_or(other);
493 Category::Custom(tag.into())
494 }
495 }
496}
497
498pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
525 let mut patterns = Vec::new();
526 for entry in entries.iter().filter(|e| e.kind == "allow") {
527 if !entry.values.is_empty() {
528 patterns.extend(entry.values.iter().cloned());
529 } else if !entry.pattern.is_empty() {
530 patterns.push(entry.pattern.clone());
531 }
532 }
533 patterns
534}
535
536pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
544 let mut patterns = Vec::with_capacity(entries.len());
545 let mut errors = Vec::new();
546
547 for (i, entry) in entries.iter().enumerate() {
548 if entry.kind == "allow"
549 || entry.kind == "entropy"
550 || entry.kind == "field-name"
551 || entry.pattern.is_empty()
552 {
553 continue;
554 }
555 let category = parse_category(&entry.category);
556 let label = entry
557 .label
558 .clone()
559 .unwrap_or_else(|| truncate_label(&entry.pattern));
560
561 let result = match entry.kind.as_str() {
562 "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
563 "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
564 other => {
565 errors.push((
566 i,
567 SanitizeError::InvalidConfig(format!(
568 "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
569 other
570 )),
571 ));
572 continue;
573 }
574 };
575
576 match result {
577 Ok(pat) => patterns.push(pat),
578 Err(e) => errors.push((i, e)),
579 }
580 }
581
582 (patterns, errors)
583}
584
585const MAX_LABEL_CHARS: usize = 32;
586
587fn truncate_label(s: &str) -> String {
589 if s.len() <= MAX_LABEL_CHARS {
590 s.to_string()
591 } else {
592 let cut = s
595 .char_indices()
596 .nth(MAX_LABEL_CHARS - 1)
597 .map_or(s.len(), |(i, _)| i);
598 format!("{}…", &s[..cut])
599 }
600}
601
602pub fn load_encrypted_secrets(
631 encrypted_bytes: &[u8],
632 password: &str,
633 format: Option<SecretsFormat>,
634) -> Result<(PatternCompileResult, Vec<String>)> {
635 let plaintext = decrypt_secrets(encrypted_bytes, password)?;
636 let entries = parse_secrets(&plaintext, format)?;
637 let allow = extract_allow_patterns(&entries);
638 let result = entries_to_patterns(&entries);
639 drop(entries);
642 Ok((result, allow))
643}
644
645pub fn load_plaintext_secrets(
667 plaintext: &[u8],
668 format: Option<SecretsFormat>,
669) -> Result<(PatternCompileResult, Vec<String>)> {
670 let entries = parse_secrets(plaintext, format)?;
671 let allow = extract_allow_patterns(&entries);
672 let result = entries_to_patterns(&entries);
673 drop(entries);
676 Ok((result, allow))
677}
678
679pub fn looks_encrypted(data: &[u8]) -> bool {
701 if data.len() < MIN_ENCRYPTED_LEN {
702 return false;
705 }
706 if let Ok(text) = std::str::from_utf8(data) {
709 let trimmed = text.trim_start();
710 let has_marker = trimmed.starts_with('[')
713 || trimmed.starts_with('{')
714 || trimmed.starts_with('-')
715 || trimmed.starts_with('#');
716 if has_marker {
717 return false;
718 }
719 }
720 true
722}
723
724pub fn load_secrets_auto(
753 data: &[u8],
754 password: Option<&str>,
755 format: Option<SecretsFormat>,
756 force_plaintext: bool,
757) -> Result<((PatternCompileResult, Vec<String>), bool)> {
758 if force_plaintext || !looks_encrypted(data) {
759 let (result, allow) = load_plaintext_secrets(data, format)?;
760 Ok(((result, allow), false))
761 } else {
762 let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
763 let (result, allow) = load_encrypted_secrets(data, pw, format)?;
764 Ok(((result, allow), true))
765 }
766}
767
768#[cfg(test)]
773mod tests {
774 use super::*;
775
776 fn sample_json() -> &'static str {
777 r#"[
778 {
779 "pattern": "alice@corp\\.com",
780 "kind": "regex",
781 "category": "email",
782 "label": "alice_email"
783 },
784 {
785 "pattern": "sk-proj-abc123secret",
786 "kind": "literal",
787 "category": "custom:api_key",
788 "label": "openai_key"
789 }
790 ]"#
791 }
792
793 fn sample_yaml() -> &'static str {
794 r#"- pattern: "alice@corp\\.com"
795 kind: regex
796 category: email
797 label: alice_email
798- pattern: sk-proj-abc123secret
799 kind: literal
800 category: "custom:api_key"
801 label: openai_key
802"#
803 }
804
805 fn sample_toml() -> &'static str {
806 r#"[[secrets]]
807pattern = "alice@corp\\.com"
808kind = "regex"
809category = "email"
810label = "alice_email"
811
812[[secrets]]
813pattern = "sk-proj-abc123secret"
814kind = "literal"
815category = "custom:api_key"
816label = "openai_key"
817"#
818 }
819
820 #[test]
823 fn parse_json_entries() {
824 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
825 assert_eq!(entries.len(), 2);
826 assert_eq!(entries[0].kind, "regex");
827 assert_eq!(entries[0].category, "email");
828 assert_eq!(entries[1].kind, "literal");
829 }
830
831 #[test]
832 fn parse_yaml_entries() {
833 let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
834 assert_eq!(entries.len(), 2);
835 assert_eq!(entries[0].label, Some("alice_email".into()));
836 }
837
838 #[test]
839 fn parse_toml_entries() {
840 let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
841 assert_eq!(entries.len(), 2);
842 assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
843 }
844
845 #[test]
846 fn parse_auto_detect_json() {
847 let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
848 assert_eq!(entries.len(), 2);
849 }
850
851 #[test]
852 fn parse_auto_detect_yaml() {
853 let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
854 assert_eq!(entries.len(), 2);
855 }
856
857 #[test]
860 fn parse_builtin_categories() {
861 assert_eq!(parse_category("email"), Category::Email);
862 assert_eq!(parse_category("ipv4"), Category::IpV4);
863 assert_eq!(parse_category("ssn"), Category::Ssn);
864 }
865
866 #[test]
867 fn parse_custom_category() {
868 match parse_category("custom:api_key") {
869 Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
870 other => panic!("expected Custom, got {:?}", other),
871 }
872 }
873
874 #[test]
875 fn parse_unknown_category_becomes_custom() {
876 match parse_category("foobar") {
877 Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
878 other => panic!("expected Custom, got {:?}", other),
879 }
880 }
881
882 #[test]
885 fn entries_to_patterns_success() {
886 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
887 let (patterns, errors) = entries_to_patterns(&entries);
888 assert_eq!(patterns.len(), 2);
889 assert!(errors.is_empty());
890 }
891
892 #[test]
893 fn entries_to_patterns_bad_regex() {
894 let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
895 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
896 let (patterns, errors) = entries_to_patterns(&entries);
897 assert!(patterns.is_empty());
898 assert_eq!(errors.len(), 1);
899 assert_eq!(errors[0].0, 0);
900 }
901
902 #[test]
905 fn encrypt_decrypt_roundtrip() {
906 let plaintext = sample_json().as_bytes();
907 let password = "test-password-42";
908
909 let encrypted = encrypt_secrets(plaintext, password).unwrap();
910
911 assert!(encrypted.len() > plaintext.len());
913
914 let decrypted = decrypt_secrets(&encrypted, password).unwrap();
915 assert_eq!(decrypted.as_slice(), plaintext);
916 }
917
918 #[test]
919 fn decrypt_wrong_password_fails() {
920 let plaintext = b"hello";
921 let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
922 let result = decrypt_secrets(&encrypted, "wrong");
923 assert!(result.is_err());
924 }
925
926 #[test]
927 fn decrypt_truncated_blob_fails() {
928 let result = decrypt_secrets(&[0u8; 10], "any");
929 assert!(result.is_err());
930 }
931
932 #[test]
933 fn decrypt_tampered_blob_fails() {
934 let plaintext = b"hello world";
935 let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
936 let last = encrypted.len() - 1;
938 encrypted[last] ^= 0xFF;
939 let result = decrypt_secrets(&encrypted, "pw");
940 assert!(result.is_err());
941 }
942
943 #[test]
944 fn encrypt_empty_password_rejected() {
945 let result = encrypt_secrets(b"hello", "");
946 assert!(result.is_err());
947 }
948
949 #[test]
952 fn full_pipeline_json() {
953 let plaintext = sample_json().as_bytes();
954 let password = "pipeline-test";
955
956 let encrypted = encrypt_secrets(plaintext, password).unwrap();
957 let ((patterns, errors), _allow) =
958 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
959
960 assert_eq!(patterns.len(), 2);
961 assert!(errors.is_empty());
962 assert_eq!(patterns[0].label(), "alice_email");
963 assert_eq!(patterns[1].label(), "openai_key");
964 }
965
966 #[test]
967 fn full_pipeline_yaml() {
968 let plaintext = sample_yaml().as_bytes();
969 let password = "yaml-test";
970
971 let encrypted = encrypt_secrets(plaintext, password).unwrap();
972 let ((patterns, errors), _allow) =
973 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
974
975 assert_eq!(patterns.len(), 2);
976 assert!(errors.is_empty());
977 }
978
979 #[test]
980 fn full_pipeline_toml() {
981 let plaintext = sample_toml().as_bytes();
982 let password = "toml-test";
983
984 let encrypted = encrypt_secrets(plaintext, password).unwrap();
985 let ((patterns, errors), _allow) =
986 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
987
988 assert_eq!(patterns.len(), 2);
989 assert!(errors.is_empty());
990 }
991
992 #[test]
995 fn load_plaintext_secrets_works() {
996 let ((patterns, errors), _allow) =
997 load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
998 assert_eq!(patterns.len(), 2);
999 assert!(errors.is_empty());
1000 }
1001
1002 #[test]
1005 fn serialize_roundtrip_json() {
1006 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1007 let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1008 let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1009 assert_eq!(entries.len(), reparsed.len());
1010 assert_eq!(entries[0].pattern, reparsed[0].pattern);
1011 }
1012
1013 #[test]
1016 fn format_from_extension() {
1017 assert_eq!(
1018 SecretsFormat::from_extension("secrets.json"),
1019 Some(SecretsFormat::Json)
1020 );
1021 assert_eq!(
1022 SecretsFormat::from_extension("secrets.json.enc"),
1023 Some(SecretsFormat::Json)
1024 );
1025 assert_eq!(
1026 SecretsFormat::from_extension("secrets.yaml"),
1027 Some(SecretsFormat::Yaml)
1028 );
1029 assert_eq!(
1030 SecretsFormat::from_extension("secrets.yml.enc"),
1031 Some(SecretsFormat::Yaml)
1032 );
1033 assert_eq!(
1034 SecretsFormat::from_extension("secrets.toml"),
1035 Some(SecretsFormat::Toml)
1036 );
1037 assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1038 }
1039
1040 #[test]
1043 fn default_kind_is_literal() {
1044 let json = r#"[{"pattern": "foo"}]"#;
1045 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1046 assert_eq!(entries[0].kind, "literal");
1047 }
1048
1049 #[test]
1050 fn default_category_is_custom_secret() {
1051 let json = r#"[{"pattern": "foo"}]"#;
1052 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1053 assert_eq!(entries[0].category, "custom:secret");
1054 }
1055
1056 #[test]
1057 fn default_label_from_pattern() {
1058 let json = r#"[{"pattern": "short"}]"#;
1059 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1060 let (patterns, _) = entries_to_patterns(&entries);
1061 assert_eq!(patterns[0].label(), "short");
1062 }
1063
1064 #[test]
1067 fn looks_encrypted_json_plaintext() {
1068 assert!(!looks_encrypted(sample_json().as_bytes()));
1069 }
1070
1071 #[test]
1072 fn looks_encrypted_yaml_plaintext() {
1073 assert!(!looks_encrypted(sample_yaml().as_bytes()));
1074 }
1075
1076 #[test]
1077 fn looks_encrypted_toml_plaintext() {
1078 assert!(!looks_encrypted(sample_toml().as_bytes()));
1079 }
1080
1081 #[test]
1082 fn looks_encrypted_actual_encrypted() {
1083 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1084 assert!(looks_encrypted(&encrypted));
1085 }
1086
1087 #[test]
1088 fn looks_encrypted_too_short() {
1089 assert!(!looks_encrypted(&[0u8; 10]));
1090 }
1091
1092 #[test]
1095 fn auto_load_plaintext_json() {
1096 let data = sample_json().as_bytes();
1097 let (((pats, errs), _allow), was_enc) =
1098 load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1099 assert!(!was_enc);
1100 assert_eq!(pats.len(), 2);
1101 assert!(errs.is_empty());
1102 }
1103
1104 #[test]
1105 fn auto_load_encrypted_json() {
1106 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1107 let (((pats, errs), _allow), was_enc) =
1108 load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1109 assert!(was_enc);
1110 assert_eq!(pats.len(), 2);
1111 assert!(errs.is_empty());
1112 }
1113
1114 #[test]
1115 fn auto_load_force_plaintext() {
1116 let data = sample_json().as_bytes();
1117 let (((pats, _), _allow), was_enc) =
1118 load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1119 assert!(!was_enc);
1120 assert_eq!(pats.len(), 2);
1121 }
1122
1123 #[test]
1124 fn auto_load_encrypted_no_password_fails() {
1125 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1126 let result = load_secrets_auto(&encrypted, None, None, false);
1127 assert!(result.is_err());
1128 }
1129
1130 #[test]
1131 fn parse_secrets_rejects_oversized_input() {
1132 let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1134 let result = parse_secrets(&oversized, None);
1135 assert!(result.is_err());
1136 let msg = result.unwrap_err().to_string();
1137 assert!(
1138 msg.contains("exceeding") || msg.contains("limit"),
1139 "unexpected error message: {msg}"
1140 );
1141 }
1142
1143 #[test]
1144 fn parse_secrets_accepts_input_at_limit() {
1145 let tiny = b"[]";
1149 let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1150 assert!(
1151 result.is_ok(),
1152 "unexpected error: {:?}",
1153 result.unwrap_err()
1154 );
1155 }
1156
1157 #[test]
1158 fn truncate_label_at_boundary() {
1159 let short = "a".repeat(32);
1160 assert_eq!(truncate_label(&short), short);
1161
1162 let long = "a".repeat(33);
1163 let truncated = truncate_label(&long);
1164 assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1165 assert!(
1168 truncated.chars().count() <= MAX_LABEL_CHARS,
1169 "char count {} exceeds limit: {truncated}",
1170 truncated.chars().count()
1171 );
1172 }
1173
1174 #[test]
1177 fn allow_single_pattern_field() {
1178 let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1179 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1180 let patterns = extract_allow_patterns(&entries);
1181 assert_eq!(patterns, vec!["localhost"]);
1182 }
1183
1184 #[test]
1185 fn allow_values_list_used_instead_of_pattern() {
1186 let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1187 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1188 let patterns = extract_allow_patterns(&entries);
1189 assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1190 }
1191
1192 #[test]
1193 fn allow_values_list_yaml() {
1194 let yaml =
1195 "- kind: allow\n values:\n - localhost\n - \"127.0.0.1\"\n - \"0.0.0.0\"\n";
1196 let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1197 let patterns = extract_allow_patterns(&entries);
1198 assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1199 }
1200
1201 #[test]
1202 fn allow_values_list_toml() {
1203 let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1204 let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1205 let patterns = extract_allow_patterns(&entries);
1206 assert_eq!(patterns, vec!["localhost", "true", "false"]);
1207 }
1208
1209 #[test]
1210 fn allow_mixed_single_and_multi_value_entries() {
1211 let json = r#"[
1212 {"kind":"allow","pattern":"localhost"},
1213 {"kind":"allow","values":["true","false","null"]},
1214 {"kind":"allow","pattern":"*.internal"}
1215 ]"#;
1216 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1217 let patterns = extract_allow_patterns(&entries);
1218 assert_eq!(
1219 patterns,
1220 vec!["localhost", "true", "false", "null", "*.internal"]
1221 );
1222 }
1223
1224 #[test]
1225 fn allow_entries_skipped_by_entries_to_patterns() {
1226 let json = r#"[
1227 {"pattern":"secret","kind":"literal"},
1228 {"kind":"allow","values":["localhost","true"]}
1229 ]"#;
1230 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1231 let (patterns, errors) = entries_to_patterns(&entries);
1232 assert_eq!(patterns.len(), 1);
1233 assert!(errors.is_empty());
1234 assert_eq!(patterns[0].label(), "secret");
1235 }
1236
1237 #[test]
1238 fn allow_empty_values_falls_back_to_pattern() {
1239 let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1241 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1242 let patterns = extract_allow_patterns(&entries);
1243 assert_eq!(patterns, vec!["localhost"]);
1244 }
1245
1246 #[test]
1249 fn field_name_entries_skipped_by_entries_to_patterns() {
1250 let json = r#"[
1253 {"pattern":"secret","kind":"literal"},
1254 {"pattern":"^password$","kind":"field-name","threshold":3.0}
1255 ]"#;
1256 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1257 let (patterns, errors) = entries_to_patterns(&entries);
1258 assert_eq!(
1259 patterns.len(),
1260 1,
1261 "only the literal entry should produce a pattern"
1262 );
1263 assert!(errors.is_empty());
1264 assert_eq!(patterns[0].label(), "secret");
1265 }
1266
1267 #[test]
1268 fn field_name_entry_parses_correctly() {
1269 let yaml = "- kind: field-name\n pattern: \"^(password|secret)$\"\n threshold: 3.0\n label: my-signal\n";
1270 let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1271 assert_eq!(entries.len(), 1);
1272 assert_eq!(entries[0].kind, "field-name");
1273 assert_eq!(entries[0].pattern, "^(password|secret)$");
1274 assert_eq!(entries[0].threshold, Some(3.0));
1275 assert_eq!(entries[0].label, Some("my-signal".into()));
1276 }
1277
1278 #[test]
1279 fn field_name_entry_not_extracted_as_allow_pattern() {
1280 let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1282 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1283 let allow = extract_allow_patterns(&entries);
1284 assert!(allow.is_empty());
1285 }
1286}