#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use std::collections::BTreeMap;
static ENVIRONMENT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn environment_lock() -> std::sync::MutexGuard<'static, ()> {
ENVIRONMENT_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct EnvironmentGuard {
values: BTreeMap<&'static str, Option<String>>,
}
impl EnvironmentGuard {
fn new() -> Self {
Self {
values: BTreeMap::new(),
}
}
fn remember(&mut self, key: &'static str) {
self.values
.entry(key)
.or_insert_with(|| std::env::var(key).ok());
}
fn set(&mut self, key: &'static str, value: &str) {
self.remember(key);
unsafe { std::env::set_var(key, value) };
}
fn clear(&mut self, key: &'static str) {
self.remember(key);
unsafe { std::env::remove_var(key) };
}
}
impl Drop for EnvironmentGuard {
fn drop(&mut self) {
for (key, value) in &self.values {
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
fn generate_test_key_32() -> String {
let mut bytes = [0u8; 16];
rand::fill(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn generate_short_key() -> String {
let mut bytes = [0u8; 4];
rand::fill(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[test]
fn secret_string_encryption_round_trips() {
let key = generate_test_key_32();
let encrypted = encrypt_aes_gcm("Sensitive Data 123", &key).expect("encryption should work");
assert_ne!(encrypted, "Sensitive Data 123");
assert_eq!(
decrypt_aes_gcm(&encrypted, &key).expect("decryption should work"),
"Sensitive Data 123"
);
}
#[test]
fn secret_string_redacts_debug_and_reveals_explicitly() {
let secret = SecretString::new("my-cpf-123");
assert_eq!(format!("{secret:?}"), "[ENCRYPTED_SECRET]");
assert_eq!(secret.reveal_audited(), "my-cpf-123");
}
#[test]
fn legacy_payload_requires_nonce_and_authentication_tag() {
let key = generate_test_key_32();
for length in [11, 12, NONCE_LENGTH + TAG_LENGTH - 1] {
let payload = STANDARD.encode(vec![0u8; length]);
assert_eq!(
decrypt_aes_gcm(&payload, &key).expect_err("short payload must fail"),
PrivacyError::PayloadTooShort
);
}
}
#[test]
fn versioned_envelope_authenticates_context_and_key_id() {
let key = generate_test_key_32();
let context = model_context("accounts", "api_token");
let encrypted = encrypt_with_context("sensitive", key.as_bytes(), "key-2026", &context)
.expect("versioned encryption should succeed");
assert!(encrypted.starts_with("RULLST:v2:key-2026:"));
let envelope = parse_envelope(&encrypted).expect("envelope should parse");
assert_eq!(envelope.key_id, "key-2026");
assert_eq!(
decrypt_envelope(&envelope, key.as_bytes(), &context)
.expect("matching context should decrypt"),
"sensitive"
);
assert!(matches!(
decrypt_envelope(
&envelope,
key.as_bytes(),
&model_context("accounts", "different_column")
),
Err(PrivacyError::DecryptionFailed(_))
));
}
#[test]
fn versioned_envelope_rejects_invalid_and_future_formats() {
assert_eq!(
parse_envelope("RULLST:v2:bad:key:id:extra")
.err()
.expect("extra envelope fields must fail"),
PrivacyError::InvalidEnvelope
);
assert_eq!(
parse_envelope("RULLST:v3:default:AA:AA")
.err()
.expect("future envelope versions must fail"),
PrivacyError::UnsupportedEnvelopeVersion("v3".to_string())
);
assert_eq!(
parse_envelope("ENC:v2:default:AA:AA")
.err()
.expect("foreign envelope prefixes must fail"),
PrivacyError::InvalidEnvelope
);
for malformed in [
"RULLST",
"RULLST:v2",
"RULLST:v2:default",
"RULLST:v2:default:AA",
"RULLST:v2::AA:AA",
"RULLST:v2:bad/key:AA:AA",
] {
assert!(parse_envelope(malformed).is_err(), "{malformed}");
}
let tag = URL_SAFE_NO_PAD.encode([0_u8; TAG_LENGTH]);
assert!(matches!(
parse_envelope(&format!("RULLST:v2:default:not-base64!:{tag}")),
Err(PrivacyError::Base64Error(_))
));
assert_eq!(
parse_envelope(&format!("RULLST:v2:default:AA:{tag}"))
.err()
.unwrap(),
PrivacyError::PayloadTooShort
);
let nonce = URL_SAFE_NO_PAD.encode([0_u8; NONCE_LENGTH]);
assert_eq!(
parse_envelope(&format!("RULLST:v2:default:{nonce}:AA"))
.err()
.unwrap(),
PrivacyError::PayloadTooShort
);
}
#[test]
fn configured_keys_accept_raw_base64_and_hex_material() {
let key = b"0123456789abcdef0123456789abcdef";
assert_eq!(
decode_configured_key(std::str::from_utf8(key).expect("ASCII test key"))
.expect("raw key should decode"),
key
);
assert_eq!(
decode_configured_key(&format!("base64:{}", STANDARD.encode(key)))
.expect("base64 key should decode"),
key
);
assert_eq!(
decode_configured_key(
"hex:3031323334353637383961626364656630313233343536373839616263646566"
)
.expect("hex key should decode"),
key
);
assert_eq!(
decode_configured_key("hex:not-hex").expect_err("invalid hex must fail"),
PrivacyError::InvalidKeyEncoding("hex")
);
assert_eq!(
decode_configured_key("base64:!").unwrap_err(),
PrivacyError::InvalidKeyEncoding("base64")
);
assert_eq!(
decode_configured_key("hex:0").unwrap_err(),
PrivacyError::InvalidKeyEncoding("hex")
);
assert_eq!(
decode_configured_key("base64:c2hvcnQ=").unwrap_err(),
PrivacyError::InvalidKeyLength
);
}
#[test]
fn privacy_errors_and_key_validation_are_typed() {
let short_key = generate_short_key();
assert_eq!(
encrypt_aes_gcm("data", &short_key).expect_err("short key must fail"),
PrivacyError::InvalidKeyLength
);
assert_eq!(
decrypt_aes_gcm("data", &short_key).expect_err("short key must fail"),
PrivacyError::InvalidKeyLength
);
let key = generate_test_key_32();
assert!(matches!(
decrypt_aes_gcm("invalid base64!@#", &key).expect_err("invalid base64 must fail"),
PrivacyError::Base64Error(_)
));
assert_eq!(
PrivacyError::InvalidKeyLength.to_string(),
"RULLST_ENCRYPTION_KEY must be exactly 32 bytes long"
);
assert_eq!(
PrivacyError::PayloadTooShort.to_string(),
"Invalid encrypted payload (too short)"
);
assert!(
PrivacyError::EncryptionFailed("err".to_string())
.to_string()
.contains("Encryption failed")
);
assert!(
PrivacyError::DecryptionFailed("err".to_string())
.to_string()
.contains("Decryption failed")
);
assert!(
PrivacyError::Utf8Error("err".to_string())
.to_string()
.contains("UTF-8")
);
assert!(
PrivacyError::EnvError("err".to_string())
.to_string()
.contains("Environment variable")
);
}
#[test]
fn legacy_ciphertext_decrypts_and_invalid_utf8_is_typed() {
let key = generate_test_key_32();
let cipher = Aes256Gcm::new_from_slice(key.as_bytes()).unwrap();
let nonce_bytes = [7_u8; NONCE_LENGTH];
let nonce = Nonce::<Aes256Gcm>::try_from(nonce_bytes.as_slice()).unwrap();
let ciphertext = cipher.encrypt(&nonce, b"legacy secret".as_slice()).unwrap();
let mut payload = nonce_bytes.to_vec();
payload.extend_from_slice(&ciphertext);
assert_eq!(
decrypt_aes_gcm(&STANDARD.encode(payload), &key).unwrap(),
"legacy secret"
);
let invalid_utf8 = cipher.encrypt(&nonce, [0xff].as_slice()).unwrap();
let mut invalid_payload = nonce_bytes.to_vec();
invalid_payload.extend_from_slice(&invalid_utf8);
assert!(matches!(
decrypt_aes_gcm(&STANDARD.encode(invalid_payload), &key),
Err(PrivacyError::Utf8Error(_))
));
}
#[test]
fn environment_key_selection_rotation_and_context_fail_closed() {
let _serial = environment_lock();
let mut environment = EnvironmentGuard::new();
for key in [KEY_ENV, KEY_ID_ENV, KEYRING_ENV] {
environment.clear(key);
}
assert_eq!(current_key_id().unwrap(), DEFAULT_KEY_ID);
assert!(matches!(current_key(), Err(PrivacyError::EnvError(_))));
environment.set(KEY_ID_ENV, "bad/id");
assert_eq!(current_key_id().unwrap_err(), PrivacyError::InvalidKeyId);
let primary = "0123456789abcdef0123456789abcdef";
let rotated = "abcdef0123456789abcdef0123456789";
environment.set(KEY_ID_ENV, "primary-2026");
environment.set(KEY_ENV, primary);
assert_eq!(current_key().unwrap(), primary.as_bytes());
let encrypted = encrypt_model_field("secret", "accounts", "token").unwrap();
assert_eq!(
decrypt_model_field(&encrypted, "accounts", "token").unwrap(),
"secret"
);
assert!(decrypt_model_field(&encrypted, "accounts", "other").is_err());
environment.set(KEY_ID_ENV, "rotated-2027");
environment.set(KEY_ENV, rotated);
environment.clear(KEYRING_ENV);
assert!(matches!(
decrypt_model_field(&encrypted, "accounts", "token"),
Err(PrivacyError::EnvError(_))
));
environment.set(KEYRING_ENV, "not-json");
assert!(matches!(
decrypt_model_field(&encrypted, "accounts", "token"),
Err(PrivacyError::EnvError(_))
));
environment.set(KEYRING_ENV, "{}");
assert_eq!(
decrypt_model_field(&encrypted, "accounts", "token").unwrap_err(),
PrivacyError::KeyNotFound("primary-2026".to_string())
);
environment.set(KEYRING_ENV, &format!(r#"{{"primary-2026":"{primary}"}}"#));
assert_eq!(
decrypt_model_field(&encrypted, "accounts", "token").unwrap(),
"secret"
);
let configured = encrypt_configured_secret("configured").unwrap();
assert_eq!(
decrypt_configured_secret(&configured).unwrap(),
"configured"
);
let secret = SecretString::new("123.456.789-00");
let serialized = serde_json::to_string(&secret).unwrap();
assert!(serialized.starts_with("\"RULLST:v2:rotated-2027:"));
assert!(!serialized.contains("123.456.789-00"));
let restored: SecretString = serde_json::from_str(&serialized).unwrap();
assert_eq!(restored.reveal_audited(), "123.456.789-00");
let sql_column_envelope = serde_json::to_string(&configured).unwrap();
assert!(
serde_json::from_str::<SecretString>(&sql_column_envelope).is_err(),
"a SQL-column envelope must not decrypt through the serde context"
);
environment.set(KEY_ID_ENV, "primary-2026");
environment.set(KEY_ENV, primary);
environment.clear(KEYRING_ENV);
assert!(
serde_json::from_str::<SecretString>(&serialized).is_err(),
"an envelope for a key missing from the keyring must fail closed"
);
environment.clear(KEY_ENV);
assert!(
serde_json::to_string(&secret).is_err(),
"serializing without a key must fail instead of emitting plaintext"
);
}
#[test]
fn secret_string_serde_redacts_projections_and_accepts_plaintext_input() {
let secret = SecretString::new("cpf-123");
let redacted = with_redacted_secrets(|| {
with_redacted_secrets(|| serde_json::to_string(&vec![secret.clone()]))
})
.unwrap();
assert_eq!(redacted, r#"["***"]"#);
let parsed: SecretString = serde_json::from_str("\"plain input\"").unwrap();
assert_eq!(parsed.reveal_audited(), "plain input");
for malformed in [
"\"RULLST:v2:k:!!:!!\"",
"\"RULLST:v9:k:a:b\"",
"\"RULLST:\"",
] {
assert!(serde_json::from_str::<SecretString>(malformed).is_err());
}
}
fn legacy_ciphertext(key: &str, plaintext: &str) -> String {
let cipher = Aes256Gcm::new_from_slice(key.as_bytes()).unwrap();
let nonce_bytes = [9_u8; NONCE_LENGTH];
let nonce = Nonce::<Aes256Gcm>::try_from(nonce_bytes.as_slice()).unwrap();
let mut payload = nonce_bytes.to_vec();
payload.extend_from_slice(&cipher.encrypt(&nonce, plaintext.as_bytes()).unwrap());
STANDARD.encode(payload)
}
#[test]
fn legacy_ciphertext_stays_readable_through_the_keyring_after_rotation() {
let _serial = environment_lock();
let mut environment = EnvironmentGuard::new();
let retired = "0123456789abcdef0123456789abcdef";
let unrelated = "fedcba9876543210fedcba9876543210";
let current = "abcdef0123456789abcdef0123456789";
let legacy = legacy_ciphertext(retired, "pre-v12 secret");
environment.set(KEY_ENV, current);
environment.set(KEY_ID_ENV, "rotated-2027");
environment.clear(KEYRING_ENV);
assert!(matches!(
decrypt_configured_secret(&legacy),
Err(PrivacyError::DecryptionFailed(_))
));
environment.set(KEYRING_ENV, &format!(r#"{{"unrelated":"{unrelated}"}}"#));
assert!(matches!(
decrypt_configured_secret(&legacy),
Err(PrivacyError::DecryptionFailed(_))
));
for keyring in [
format!(r#"{{"unrelated":"{unrelated}","default":"{retired}"}}"#),
format!(r#"{{"primary-2026":"{retired}"}}"#),
] {
environment.set(KEYRING_ENV, &keyring);
assert_eq!(
decrypt_configured_secret(&legacy).unwrap(),
"pre-v12 secret"
);
}
environment.set(KEY_ENV, retired);
environment.set(KEYRING_ENV, "not-json");
assert_eq!(
decrypt_configured_secret(&legacy).unwrap(),
"pre-v12 secret",
"the current key is tried before the keyring"
);
environment.set(KEY_ENV, current);
assert!(matches!(
decrypt_configured_secret(&legacy),
Err(PrivacyError::EnvError(_))
));
environment.set(KEYRING_ENV, r#"{"default":"short"}"#);
assert_eq!(
decrypt_configured_secret(&legacy).unwrap_err(),
PrivacyError::InvalidKeyLength
);
}
#[derive(serde::Deserialize)]
struct ClientSettings {
#[serde(deserialize_with = "deserialize_plaintext_secret")]
webhook_secret: SecretString,
#[serde(default, deserialize_with = "deserialize_optional_plaintext_secret")]
backup_secret: Option<SecretString>,
}
#[test]
fn client_input_deserializers_reject_envelopes() {
let _serial = environment_lock();
let mut environment = EnvironmentGuard::new();
environment.set(KEY_ENV, "0123456789abcdef0123456789abcdef");
environment.set(KEY_ID_ENV, "client-input-2026");
environment.clear(KEYRING_ENV);
let exposed = serde_json::to_string(&SecretString::new("victim secret")).unwrap();
let restored: SecretString = serde_json::from_str(&exposed).unwrap();
assert_eq!(restored.reveal_audited(), "victim secret");
let submitted = format!(r#"{{"webhook_secret":{exposed}}}"#);
let error = serde_json::from_str::<ClientSettings>(&submitted)
.err()
.expect("an exposed envelope must be rejected as client input");
assert!(error.to_string().contains("not accepted as secret input"));
let optional = format!(r#"{{"webhook_secret":"plain","backup_secret":{exposed}}}"#);
assert!(serde_json::from_str::<ClientSettings>(&optional).is_err());
for malformed in [r#""RULLST:v2:k:AA:AA""#, r#""RULLST:""#] {
let body = format!(r#"{{"webhook_secret":{malformed}}}"#);
assert!(serde_json::from_str::<ClientSettings>(&body).is_err());
}
let accepted: ClientSettings =
serde_json::from_str(r#"{"webhook_secret":"new secret","backup_secret":null}"#).unwrap();
assert_eq!(accepted.webhook_secret.reveal_audited(), "new secret");
assert!(accepted.backup_secret.is_none());
let accepted: ClientSettings =
serde_json::from_str(r#"{"webhook_secret":"RULLSTish","backup_secret":"spare secret"}"#)
.unwrap();
assert_eq!(accepted.webhook_secret.reveal_audited(), "RULLSTish");
assert_eq!(
accepted
.backup_secret
.as_ref()
.map(SecretString::reveal_audited),
Some("spare secret")
);
}