rullst-orm 12.0.0

Fast, un-opinionated, database-agnostic ORM and query builder for Rust
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used)]

use super::*;
use std::collections::BTreeMap;

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 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"
    );
}