Skip to main content

shipper_encrypt/
lib.rs

1//! State file encryption using AES-256-GCM with PBKDF2 key derivation.
2//!
3//! This crate provides transparent encryption and decryption of sensitive data using
4//! AES-256-GCM with PBKDF2 key derivation from user passphrases.
5//!
6//! ## Usage
7//!
8//! ```
9//! use shipper_encrypt::{encrypt, decrypt};
10//!
11//! let plaintext = b"Secret data";
12//! let passphrase = "my-secret-passphrase";
13//!
14//! let encrypted = encrypt(plaintext, passphrase).expect("encryption failed");
15//! let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
16//! let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption failed");
17//!
18//! assert_eq!(plaintext.to_vec(), decrypted);
19//! ```
20//!
21//! ## Security
22//!
23//! - Uses AES-256-GCM for authenticated encryption
24//! - PBKDF2 with 100,000 iterations for key derivation
25//! - Random salt and nonce for each encryption operation
26//! - Encrypted data format: base64(salt || nonce || ciphertext || auth_tag)
27
28use std::fmt;
29use std::path::Path;
30
31use aes_gcm::{
32    Aes256Gcm, Nonce,
33    aead::{Aead, KeyInit, OsRng, rand_core::RngCore},
34};
35use anyhow::{Context, Result, bail};
36use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
37use pbkdf2::pbkdf2_hmac_array;
38use serde::{Deserialize, Serialize};
39use sha2::Sha256;
40
41/// Size of the salt for key derivation (16 bytes)
42const SALT_SIZE: usize = 16;
43/// Size of the nonce for AES-GCM (12 bytes)
44const NONCE_SIZE: usize = 12;
45/// Number of PBKDF2 iterations
46const PBKDF2_ITERATIONS: u32 = 100_000;
47/// Size of the derived key (256 bits for AES-256)
48const KEY_SIZE: usize = 32;
49
50/// Encryption configuration
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct EncryptionConfig {
53    /// Whether encryption is enabled
54    #[serde(default)]
55    pub enabled: bool,
56    /// Passphrase for encryption/decryption (if enabled)
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub passphrase: Option<String>,
59    /// Environment variable name to read passphrase from
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub env_var: Option<String>,
62}
63
64impl EncryptionConfig {
65    /// Create a new encryption config with the given passphrase
66    pub fn new(passphrase: String) -> Self {
67        Self {
68            enabled: true,
69            passphrase: Some(passphrase),
70            env_var: None,
71        }
72    }
73
74    /// Create a new encryption config that reads passphrase from environment variable
75    pub fn from_env(env_var: String) -> Self {
76        Self {
77            enabled: true,
78            passphrase: None,
79            env_var: Some(env_var),
80        }
81    }
82
83    /// Get the passphrase, either directly or from environment
84    pub fn get_passphrase(&self) -> Result<Option<String>> {
85        if let Some(passphrase) = &self.passphrase {
86            return Ok(Some(passphrase.clone()));
87        }
88
89        if let Some(ref env_var) = self.env_var {
90            return Ok(std::env::var(env_var).ok());
91        }
92
93        Ok(None)
94    }
95}
96
97impl fmt::Display for EncryptionConfig {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        if !self.enabled {
100            return write!(f, "encryption: disabled");
101        }
102        match (&self.passphrase, &self.env_var) {
103            (Some(p), _) => write!(
104                f,
105                "encryption: enabled (passphrase: {})",
106                mask_passphrase(p)
107            ),
108            (None, Some(var)) => write!(f, "encryption: enabled (env: {var})"),
109            (None, None) => write!(f, "encryption: enabled (no passphrase configured)"),
110        }
111    }
112}
113
114/// Mask a passphrase for safe display, showing only the first and last
115/// characters with asterisks in between. Passphrases with fewer than 3
116/// characters are fully masked.
117pub fn mask_passphrase(passphrase: &str) -> String {
118    let chars: Vec<char> = passphrase.chars().collect();
119    if chars.len() < 3 {
120        return "*".repeat(chars.len().max(1));
121    }
122    let first = chars[0];
123    let last = chars[chars.len() - 1];
124    format!("{first}{}{last}", "*".repeat(chars.len() - 2))
125}
126
127impl fmt::Display for StateEncryption {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(f, "{}", self.config)
130    }
131}
132
133/// Encrypt data using AES-256-GCM with PBKDF2 key derivation
134///
135/// # Arguments
136/// * `data` - The plaintext data to encrypt
137/// * `passphrase` - The passphrase to derive the encryption key from
138///
139/// # Returns
140/// Base64-encoded encrypted data with format: salt || nonce || ciphertext
141///
142/// # Example
143///
144/// ```
145/// use shipper_encrypt::encrypt;
146///
147/// let data = b"Secret message";
148/// let passphrase = "my-passphrase";
149///
150/// let encrypted = encrypt(data, passphrase).expect("encryption failed");
151/// // encrypted is base64-encoded and can be safely stored as text
152/// ```
153pub fn encrypt(data: &[u8], passphrase: &str) -> Result<Vec<u8>> {
154    // Generate random salt and nonce
155    let mut salt = [0u8; SALT_SIZE];
156    let mut nonce_bytes = [0u8; NONCE_SIZE];
157    OsRng.fill_bytes(&mut salt);
158    OsRng.fill_bytes(&mut nonce_bytes);
159
160    // Derive key from passphrase using PBKDF2
161    let key = derive_key(passphrase, &salt);
162
163    // Create cipher and encrypt
164    let cipher = Aes256Gcm::new_from_slice(&key).context("failed to create AES-256-GCM cipher")?;
165    let nonce = Nonce::from_slice(&nonce_bytes);
166    let ciphertext = cipher
167        .encrypt(nonce, data)
168        .map_err(|e| anyhow::anyhow!("encryption failed: {:?}", e))?;
169
170    // Format: salt || nonce || ciphertext
171    let mut result = Vec::with_capacity(SALT_SIZE + NONCE_SIZE + ciphertext.len());
172    result.extend_from_slice(&salt);
173    result.extend_from_slice(&nonce_bytes);
174    result.extend_from_slice(&ciphertext);
175
176    // Return base64-encoded result
177    Ok(BASE64.encode(&result).into_bytes())
178}
179
180/// Decrypt data using AES-256-GCM with PBKDF2 key derivation
181///
182/// # Arguments
183/// * `encrypted_data` - Base64-encoded encrypted data (as string or bytes)
184/// * `passphrase` - The passphrase to derive the decryption key from
185///
186/// # Returns
187/// The decrypted plaintext data
188///
189/// # Example
190///
191/// ```
192/// use shipper_encrypt::{encrypt, decrypt};
193///
194/// let data = b"Secret message";
195/// let passphrase = "my-passphrase";
196///
197/// let encrypted = encrypt(data, passphrase).expect("encryption failed");
198/// let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
199/// let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption failed");
200///
201/// assert_eq!(data.to_vec(), decrypted);
202/// ```
203pub fn decrypt(encrypted_data: impl AsRef<str>, passphrase: &str) -> Result<Vec<u8>> {
204    let encrypted_str = encrypted_data.as_ref();
205    // Decode base64
206    let data = BASE64
207        .decode(encrypted_str)
208        .context("invalid base64 encoding")?;
209
210    // Check minimum length
211    if data.len() < SALT_SIZE + NONCE_SIZE + 16 {
212        bail!("encrypted data too short");
213    }
214
215    // Extract salt, nonce, and ciphertext
216    let salt = &data[..SALT_SIZE];
217    let nonce_bytes = &data[SALT_SIZE..SALT_SIZE + NONCE_SIZE];
218    let ciphertext = &data[SALT_SIZE + NONCE_SIZE..];
219
220    // Derive key from passphrase using PBKDF2
221    let key = derive_key(passphrase, salt);
222
223    // Create cipher and decrypt
224    let cipher = Aes256Gcm::new_from_slice(&key).context("failed to create AES-256-GCM cipher")?;
225    let nonce = Nonce::from_slice(nonce_bytes);
226    let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| {
227        anyhow::anyhow!(
228            "decryption failed - wrong passphrase or corrupted data: {:?}",
229            e
230        )
231    })?;
232
233    Ok(plaintext)
234}
235
236/// Derive a 256-bit key from passphrase using PBKDF2-SHA256
237fn derive_key(passphrase: &str, salt: &[u8]) -> [u8; KEY_SIZE] {
238    pbkdf2_hmac_array::<Sha256, KEY_SIZE>(passphrase.as_bytes(), salt, PBKDF2_ITERATIONS)
239}
240
241/// Check if data appears to be encrypted (starts with base64-encoded salt)
242/// This is a heuristic check - it may give false negatives for very short
243/// or specially crafted plaintexts, but should work for normal JSON state files.
244pub fn is_encrypted(content: &str) -> bool {
245    // Try to decode as base64
246    let Ok(data) = BASE64.decode(content) else {
247        return false;
248    };
249
250    // Check minimum length for encrypted data
251    if data.len() < SALT_SIZE + NONCE_SIZE + 16 {
252        return false;
253    }
254
255    // Additional heuristic: encrypted data should have high entropy
256    // and not be valid UTF-8 JSON (encrypted data is not valid JSON)
257    // This is a simple check - we rely on the decryption to confirm
258
259    true
260}
261
262/// Read and decrypt a file
263///
264/// # Arguments
265/// * `path` - Path to the encrypted file
266/// * `passphrase` - The passphrase to decrypt with
267///
268/// # Returns
269/// The decrypted file contents as a string
270pub fn read_decrypted(path: &Path, passphrase: &str) -> Result<String> {
271    let encrypted = std::fs::read_to_string(path)
272        .with_context(|| format!("failed to read encrypted file: {}", path.display()))?;
273
274    // Try to decrypt
275    let decrypted = decrypt(&encrypted, passphrase)?;
276    String::from_utf8(decrypted).context("decrypted data is not valid UTF-8")
277}
278
279/// Write and encrypt data to a file
280///
281/// # Arguments
282/// * `path` - Path to the file
283/// * `data` - The plaintext data to encrypt and write
284/// * `passphrase` - The passphrase to encrypt with
285pub fn write_encrypted(path: &Path, data: &[u8], passphrase: &str) -> Result<()> {
286    let encrypted = encrypt(data, passphrase)?;
287
288    // Write as base64 string
289    let encrypted_str =
290        String::from_utf8(encrypted).context("encrypted data is not valid UTF-8")?;
291
292    std::fs::write(path, encrypted_str)
293        .with_context(|| format!("failed to write encrypted file: {}", path.display()))?;
294
295    Ok(())
296}
297
298/// Transparent encryption wrapper for file operations.
299///
300/// This provides a simple interface for encrypting/decrypting files
301/// transparently without changing the rest of the codebase.
302pub struct StateEncryption {
303    config: EncryptionConfig,
304}
305
306impl StateEncryption {
307    /// Create a new state encryption handler
308    pub fn new(config: EncryptionConfig) -> Result<Self> {
309        Ok(Self { config })
310    }
311
312    /// Get the passphrase, trying environment variable first if configured
313    fn get_passphrase(&self) -> Result<Option<String>> {
314        if !self.config.enabled {
315            return Ok(None);
316        }
317
318        // Try env var first if configured
319        if let Some(ref env_var) = self.config.env_var
320            && let Ok(passphrase) = std::env::var(env_var)
321        {
322            return Ok(Some(passphrase));
323        }
324
325        // Fall back to direct passphrase
326        self.config.get_passphrase()
327    }
328
329    /// Check if encryption is enabled and we have a passphrase
330    pub fn is_enabled(&self) -> bool {
331        self.config.enabled && self.get_passphrase().ok().flatten().is_some()
332    }
333
334    /// Encrypt data if encryption is enabled
335    pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
336        let passphrase = self.get_passphrase()?.context(
337            "encryption is enabled but no passphrase available. Set SHIPPER_ENCRYPT_KEY environment variable or provide passphrase in config.",
338        )?;
339
340        encrypt(data, &passphrase)
341    }
342
343    /// Decrypt data if encryption is enabled
344    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
345        // First, try to decrypt assuming it's encrypted
346        if let Some(passphrase) = self.get_passphrase()? {
347            // Try decryption first
348            if let Ok(decrypted) = decrypt(String::from_utf8_lossy(data), &passphrase) {
349                return Ok(decrypted);
350            }
351        }
352
353        // If decryption didn't work or encryption not enabled, return original data
354        // This allows for transparent fallback to unencrypted data
355        Ok(data.to_vec())
356    }
357
358    /// Read and decrypt a file if encrypted
359    pub fn read_file(&self, path: &Path) -> Result<String> {
360        if !self.is_enabled() {
361            // Read as plain text
362            return std::fs::read_to_string(path)
363                .with_context(|| format!("failed to read file: {}", path.display()));
364        }
365
366        let passphrase = self
367            .get_passphrase()?
368            .context("encryption is enabled but no passphrase available")?;
369
370        let content = std::fs::read_to_string(path)
371            .with_context(|| format!("failed to read file: {}", path.display()))?;
372
373        // Try to decrypt - if it fails, assume it's not encrypted
374        match decrypt(&content, &passphrase) {
375            Ok(decrypted) => {
376                String::from_utf8(decrypted).context("decrypted data is not valid UTF-8")
377            }
378            Err(_) => {
379                // File might not be encrypted yet - try reading as plain
380                Ok(content)
381            }
382        }
383    }
384
385    /// Write and encrypt a file if encryption is enabled
386    pub fn write_file(&self, path: &Path, data: &[u8]) -> Result<()> {
387        if !self.is_enabled() {
388            // Write as plain text
389            return std::fs::write(path, data)
390                .with_context(|| format!("failed to write file: {}", path.display()));
391        }
392
393        let passphrase = self
394            .get_passphrase()?
395            .context("encryption is enabled but no passphrase available")?;
396
397        let encrypted = encrypt(data, &passphrase)?;
398        let encrypted_str =
399            String::from_utf8(encrypted).context("encrypted data is not valid UTF-8")?;
400
401        std::fs::write(path, encrypted_str)
402            .with_context(|| format!("failed to write encrypted file: {}", path.display()))
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use tempfile::tempdir;
410
411    // ── Core encrypt/decrypt roundtrip ──────────────────────────────────
412
413    #[test]
414    fn encrypt_decrypt_roundtrip() {
415        let plaintext = b"Hello, World! This is a test message.";
416        let passphrase = "test-passphrase-123";
417
418        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
419        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
420        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
421
422        assert_eq!(plaintext.to_vec(), decrypted);
423    }
424
425    #[test]
426    fn encrypt_produces_different_output_for_same_plaintext() {
427        let plaintext = b"Hello, World!";
428        let passphrase = "test-passphrase";
429
430        let encrypted1 = encrypt(plaintext, passphrase).expect("encryption should succeed");
431        let encrypted2 = encrypt(plaintext, passphrase).expect("encryption should succeed");
432
433        // Should be different due to random salt/nonce
434        assert_ne!(encrypted1, encrypted2);
435
436        // But both should decrypt to the same plaintext
437        let decrypted1 = decrypt(
438            String::from_utf8(encrypted1).expect("valid UTF-8"),
439            passphrase,
440        )
441        .expect("decryption should succeed");
442        let decrypted2 = decrypt(
443            String::from_utf8(encrypted2).expect("valid UTF-8"),
444            passphrase,
445        )
446        .expect("decryption should succeed");
447
448        assert_eq!(decrypted1, decrypted2);
449    }
450
451    #[test]
452    fn decrypt_wrong_passphrase_fails() {
453        let plaintext = b"Secret data";
454        let passphrase = "correct-passphrase";
455        let wrong_passphrase = "wrong-passphrase";
456
457        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
458        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
459
460        let result = decrypt(&encrypted_str, wrong_passphrase);
461        assert!(result.is_err());
462    }
463
464    // ── Empty input ─────────────────────────────────────────────────────
465
466    #[test]
467    fn encrypt_decrypt_empty_input() {
468        let plaintext = b"";
469        let passphrase = "test-passphrase";
470
471        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
472        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
473        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
474
475        assert_eq!(plaintext.to_vec(), decrypted);
476    }
477
478    #[test]
479    fn encrypt_empty_with_empty_passphrase() {
480        let plaintext = b"";
481        let passphrase = "";
482
483        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
484        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
485        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
486
487        assert_eq!(plaintext.to_vec(), decrypted);
488    }
489
490    // ── Large input ─────────────────────────────────────────────────────
491
492    #[test]
493    fn encrypt_decrypt_large_input() {
494        // 1 MiB of data
495        let plaintext: Vec<u8> = (0..1_048_576).map(|i| (i % 256) as u8).collect();
496        let passphrase = "large-data-passphrase";
497
498        let encrypted = encrypt(&plaintext, passphrase).expect("encryption should succeed");
499        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
500        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
501
502        assert_eq!(plaintext, decrypted);
503    }
504
505    #[test]
506    fn encrypt_decrypt_single_byte() {
507        let plaintext = b"\x42";
508        let passphrase = "single-byte";
509
510        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
511        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
512        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
513
514        assert_eq!(plaintext.to_vec(), decrypted);
515    }
516
517    // ── Decrypt error cases ─────────────────────────────────────────────
518
519    #[test]
520    fn decrypt_invalid_base64_fails() {
521        let result = decrypt("not-valid-base64!!!", "passphrase");
522        assert!(result.is_err());
523        let err = result.unwrap_err().to_string();
524        assert!(
525            err.contains("base64"),
526            "error should mention base64, got: {err}"
527        );
528    }
529
530    #[test]
531    fn decrypt_too_short_data_fails() {
532        // Encode data that is too short (less than salt + nonce + 16-byte tag)
533        let short_data = vec![0u8; SALT_SIZE + NONCE_SIZE + 15];
534        let encoded = BASE64.encode(&short_data);
535
536        let result = decrypt(&encoded, "passphrase");
537        assert!(result.is_err());
538        let err = result.unwrap_err().to_string();
539        assert!(
540            err.contains("too short"),
541            "error should mention 'too short', got: {err}"
542        );
543    }
544
545    #[test]
546    fn decrypt_corrupted_ciphertext_fails() {
547        let plaintext = b"Some data to encrypt";
548        let passphrase = "test-pass";
549
550        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
551        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
552
553        // Decode, corrupt a byte in the ciphertext region, re-encode
554        let mut raw = BASE64.decode(&encrypted_str).expect("valid base64");
555        let idx = SALT_SIZE + NONCE_SIZE + 1;
556        raw[idx] ^= 0xFF;
557        let corrupted = BASE64.encode(&raw);
558
559        let result = decrypt(&corrupted, passphrase);
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn decrypt_corrupted_salt_fails() {
565        let plaintext = b"Some data";
566        let passphrase = "test-pass";
567
568        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
569        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
570
571        // Flip a bit in the salt region
572        let mut raw = BASE64.decode(&encrypted_str).expect("valid base64");
573        raw[0] ^= 0xFF;
574        let corrupted = BASE64.encode(&raw);
575
576        let result = decrypt(&corrupted, passphrase);
577        assert!(result.is_err());
578    }
579
580    #[test]
581    fn decrypt_corrupted_nonce_fails() {
582        let plaintext = b"Some data";
583        let passphrase = "test-pass";
584
585        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
586        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
587
588        // Flip a bit in the nonce region
589        let mut raw = BASE64.decode(&encrypted_str).expect("valid base64");
590        raw[SALT_SIZE] ^= 0xFF;
591        let corrupted = BASE64.encode(&raw);
592
593        let result = decrypt(&corrupted, passphrase);
594        assert!(result.is_err());
595    }
596
597    #[test]
598    fn decrypt_empty_string_fails() {
599        let result = decrypt("", "passphrase");
600        assert!(result.is_err());
601    }
602
603    #[test]
604    fn decrypt_exactly_minimum_length_minus_one_fails() {
605        // Exactly salt + nonce + 15 bytes (one less than the 16-byte auth tag)
606        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 15];
607        let encoded = BASE64.encode(&data);
608        assert!(decrypt(&encoded, "pass").is_err());
609    }
610
611    #[test]
612    fn decrypt_exactly_minimum_length_fails_with_wrong_key() {
613        // salt + nonce + 16 bytes of garbage "ciphertext"
614        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 16];
615        let encoded = BASE64.encode(&data);
616        // Passes the length check but fails decryption
617        assert!(decrypt(&encoded, "pass").is_err());
618    }
619
620    // ── is_encrypted heuristic ──────────────────────────────────────────
621
622    #[test]
623    fn is_encrypted_detects_encrypted_data() {
624        let plaintext = b"Hello, World!";
625        let passphrase = "test-passphrase";
626
627        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
628        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
629
630        assert!(is_encrypted(&encrypted_str));
631    }
632
633    #[test]
634    fn is_encrypted_rejects_plaintext() {
635        let plaintext = r#"{"key": "value"}"#;
636        assert!(!is_encrypted(plaintext));
637    }
638
639    #[test]
640    fn is_encrypted_rejects_empty_string() {
641        assert!(!is_encrypted(""));
642    }
643
644    #[test]
645    fn is_encrypted_rejects_short_base64() {
646        // Valid base64 but too short to be encrypted data
647        let short = BASE64.encode(vec![0u8; SALT_SIZE + NONCE_SIZE + 10]);
648        assert!(!is_encrypted(&short));
649    }
650
651    #[test]
652    fn is_encrypted_rejects_non_base64() {
653        assert!(!is_encrypted("definitely not base64 $$$ !!!"));
654    }
655
656    // ── Passphrase edge cases ───────────────────────────────────────────
657
658    #[test]
659    fn roundtrip_with_unicode_passphrase() {
660        let plaintext = b"Unicode passphrase test";
661        let passphrase = "pässwörd-密码-🔑";
662
663        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
664        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
665        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
666
667        assert_eq!(plaintext.to_vec(), decrypted);
668    }
669
670    #[test]
671    fn roundtrip_with_very_long_passphrase() {
672        let plaintext = b"Long passphrase test";
673        let passphrase: String = "a".repeat(10_000);
674
675        let encrypted = encrypt(plaintext, &passphrase).expect("encryption should succeed");
676        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
677        let decrypted = decrypt(&encrypted_str, &passphrase).expect("decryption should succeed");
678
679        assert_eq!(plaintext.to_vec(), decrypted);
680    }
681
682    #[test]
683    fn different_passphrases_produce_different_ciphertexts_when_decoded() {
684        let plaintext = b"Same plaintext";
685        let pass1 = "passphrase-one";
686        let pass2 = "passphrase-two";
687
688        let enc1 = encrypt(plaintext, pass1).expect("encrypt");
689        let enc2 = encrypt(plaintext, pass2).expect("encrypt");
690
691        // Different passphrases → different raw bytes (even ignoring salt/nonce randomness)
692        let raw1 = BASE64
693            .decode(String::from_utf8(enc1).expect("utf8"))
694            .expect("base64");
695        let raw2 = BASE64
696            .decode(String::from_utf8(enc2).expect("utf8"))
697            .expect("base64");
698
699        // Ciphertext portions must differ
700        let ct1 = &raw1[SALT_SIZE + NONCE_SIZE..];
701        let ct2 = &raw2[SALT_SIZE + NONCE_SIZE..];
702        assert_ne!(ct1, ct2);
703    }
704
705    // ── Binary / non-UTF8 plaintext ─────────────────────────────────────
706
707    #[test]
708    fn roundtrip_binary_data() {
709        let plaintext: Vec<u8> = (0..=255).collect();
710        let passphrase = "binary-data-test";
711
712        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
713        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
714        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
715
716        assert_eq!(plaintext, decrypted);
717    }
718
719    #[test]
720    fn roundtrip_all_zero_bytes() {
721        let plaintext = vec![0u8; 1024];
722        let passphrase = "zeroes";
723
724        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
725        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
726        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
727
728        assert_eq!(plaintext, decrypted);
729    }
730
731    // ── derive_key ──────────────────────────────────────────────────────
732
733    #[test]
734    fn derive_key_produces_consistent_output() {
735        let passphrase = "test-passphrase";
736        let salt = [0u8; SALT_SIZE];
737
738        let key1 = derive_key(passphrase, &salt);
739        let key2 = derive_key(passphrase, &salt);
740
741        assert_eq!(key1, key2);
742    }
743
744    #[test]
745    fn derive_key_different_salts_produce_different_keys() {
746        let passphrase = "test-passphrase";
747        let salt1 = [0u8; SALT_SIZE];
748        let mut salt2 = [0u8; SALT_SIZE];
749        salt2[0] = 1;
750
751        let key1 = derive_key(passphrase, &salt1);
752        let key2 = derive_key(passphrase, &salt2);
753
754        assert_ne!(key1, key2);
755    }
756
757    #[test]
758    fn derive_key_different_passphrases_produce_different_keys() {
759        let salt = [42u8; SALT_SIZE];
760
761        let key1 = derive_key("passphrase-a", &salt);
762        let key2 = derive_key("passphrase-b", &salt);
763
764        assert_ne!(key1, key2);
765    }
766
767    #[test]
768    fn derive_key_empty_passphrase() {
769        let salt = [0u8; SALT_SIZE];
770        // Should not panic – just produces a deterministic key
771        let key1 = derive_key("", &salt);
772        let key2 = derive_key("", &salt);
773        assert_eq!(key1, key2);
774    }
775
776    // ── EncryptionConfig ────────────────────────────────────────────────
777
778    #[test]
779    fn encryption_config_default_is_disabled() {
780        let cfg = EncryptionConfig::default();
781        assert!(!cfg.enabled);
782        assert!(cfg.passphrase.is_none());
783        assert!(cfg.env_var.is_none());
784    }
785
786    #[test]
787    fn encryption_config_new_is_enabled() {
788        let cfg = EncryptionConfig::new("secret".to_string());
789        assert!(cfg.enabled);
790        assert_eq!(cfg.passphrase.as_deref(), Some("secret"));
791        assert!(cfg.env_var.is_none());
792    }
793
794    #[test]
795    fn encryption_config_from_env_is_enabled() {
796        let cfg = EncryptionConfig::from_env("MY_VAR".to_string());
797        assert!(cfg.enabled);
798        assert!(cfg.passphrase.is_none());
799        assert_eq!(cfg.env_var.as_deref(), Some("MY_VAR"));
800    }
801
802    #[test]
803    fn encryption_config_get_passphrase_direct() {
804        let cfg = EncryptionConfig::new("hello".to_string());
805        assert_eq!(cfg.get_passphrase().unwrap(), Some("hello".to_string()));
806    }
807
808    #[test]
809    fn encryption_config_get_passphrase_none_when_disabled() {
810        let cfg = EncryptionConfig::default();
811        assert_eq!(cfg.get_passphrase().unwrap(), None);
812    }
813
814    #[test]
815    fn encryption_config_serde_roundtrip() {
816        let cfg = EncryptionConfig::new("test".to_string());
817        let json = serde_json::to_string(&cfg).expect("serialize");
818        let deserialized: EncryptionConfig = serde_json::from_str(&json).expect("deserialize");
819        assert_eq!(deserialized.enabled, cfg.enabled);
820        assert_eq!(deserialized.passphrase, cfg.passphrase);
821    }
822
823    #[test]
824    fn encryption_config_serde_skips_none_fields() {
825        let cfg = EncryptionConfig::default();
826        let json = serde_json::to_string(&cfg).expect("serialize");
827        assert!(!json.contains("passphrase"));
828        assert!(!json.contains("env_var"));
829    }
830
831    // ── StateEncryption ─────────────────────────────────────────────────
832
833    #[test]
834    fn state_encryption_enabled_disabled() {
835        let config = EncryptionConfig::default();
836        let encryption = StateEncryption::new(config.clone()).expect("should create");
837        assert!(!encryption.is_enabled());
838
839        let config = EncryptionConfig::new("test-passphrase".to_string());
840        let encryption = StateEncryption::new(config).expect("should create");
841        assert!(encryption.is_enabled());
842    }
843
844    #[test]
845    fn state_encryption_roundtrip() {
846        let config = EncryptionConfig::new("my-secret-passphrase".to_string());
847        let encryption = StateEncryption::new(config).expect("should create");
848
849        let data = b"Test state data";
850
851        let encrypted = encryption.encrypt(data).expect("encryption should succeed");
852        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
853        let decrypted =
854            decrypt(&encrypted_str, "my-secret-passphrase").expect("decryption should succeed");
855
856        assert_eq!(data.to_vec(), decrypted);
857    }
858
859    #[test]
860    fn state_encryption_decrypt_roundtrip() {
861        let config = EncryptionConfig::new("my-pass".to_string());
862        let encryption = StateEncryption::new(config).expect("should create");
863
864        let data = b"state data to encrypt";
865        let encrypted = encryption.encrypt(data).expect("encrypt");
866        let decrypted = encryption.decrypt(&encrypted).expect("decrypt");
867
868        assert_eq!(data.to_vec(), decrypted);
869    }
870
871    #[test]
872    fn state_encryption_disabled_passthrough() {
873        let config = EncryptionConfig::default();
874        let encryption = StateEncryption::new(config).expect("should create");
875
876        let data = b"Plain text data";
877
878        let result = encryption.decrypt(data).expect("should succeed");
879        assert_eq!(data.to_vec(), result);
880    }
881
882    #[test]
883    fn state_encryption_disabled_encrypt_passthrough_on_decrypt() {
884        // When disabled, decrypt returns data as-is even if it looks like garbage
885        let config = EncryptionConfig::default();
886        let encryption = StateEncryption::new(config).expect("should create");
887
888        let garbage = b"\x00\x01\x02\x03";
889        let result = encryption.decrypt(garbage).expect("should succeed");
890        assert_eq!(garbage.to_vec(), result);
891    }
892
893    // ── encrypt output format ───────────────────────────────────────────
894
895    #[test]
896    fn encrypt_produces_valid_base64() {
897        let plaintext = b"Test data";
898        let passphrase = "test";
899
900        let encrypted = encrypt(plaintext, passphrase).expect("should encrypt");
901        let encrypted_str = String::from_utf8(encrypted.clone()).expect("valid UTF-8");
902
903        let decoded = BASE64.decode(&encrypted_str).expect("valid base64");
904        assert!(decoded.len() > plaintext.len());
905    }
906
907    #[test]
908    fn encrypted_output_has_expected_structure() {
909        let plaintext = b"Hello";
910        let passphrase = "test";
911
912        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
913        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
914        let raw = BASE64.decode(&encrypted_str).expect("base64");
915
916        // raw = salt(16) + nonce(12) + ciphertext(len(plaintext) + 16 for GCM tag)
917        let expected_len = SALT_SIZE + NONCE_SIZE + plaintext.len() + 16;
918        assert_eq!(raw.len(), expected_len);
919    }
920
921    // ── File I/O ────────────────────────────────────────────────────────
922
923    #[test]
924    fn read_write_encrypted_file() {
925        let td = tempdir().expect("tempdir");
926        let path = td.path().join("test.enc");
927
928        let plaintext = b"Secret file content";
929        let passphrase = "file-passphrase";
930
931        write_encrypted(&path, plaintext, passphrase).expect("write encrypted");
932        let decrypted = read_decrypted(&path, passphrase).expect("read decrypted");
933
934        assert_eq!(plaintext.to_vec(), decrypted.into_bytes());
935    }
936
937    #[test]
938    fn read_decrypted_wrong_passphrase_fails() {
939        let td = tempdir().expect("tempdir");
940        let path = td.path().join("test.enc");
941
942        write_encrypted(&path, b"data", "correct").expect("write");
943        let result = read_decrypted(&path, "wrong");
944        assert!(result.is_err());
945    }
946
947    #[test]
948    fn read_decrypted_nonexistent_file_fails() {
949        let td = tempdir().expect("tempdir");
950        let path = td.path().join("does-not-exist.enc");
951
952        let result = read_decrypted(&path, "pass");
953        assert!(result.is_err());
954    }
955
956    #[test]
957    fn write_encrypted_file_is_base64_on_disk() {
958        let td = tempdir().expect("tempdir");
959        let path = td.path().join("test.enc");
960
961        write_encrypted(&path, b"data", "pass").expect("write");
962        let on_disk = std::fs::read_to_string(&path).expect("read");
963
964        // Should be valid base64
965        assert!(BASE64.decode(&on_disk).is_ok());
966        // Should NOT be the plaintext
967        assert_ne!(on_disk, "data");
968    }
969
970    #[test]
971    fn state_encryption_file_roundtrip() {
972        let td = tempdir().expect("tempdir");
973        let path = td.path().join("state.json");
974
975        let config = EncryptionConfig::new("test-pass".to_string());
976        let encryption = StateEncryption::new(config).expect("should create");
977
978        let data = br#"{"key": "value"}"#;
979
980        encryption.write_file(&path, data).expect("write file");
981        let content = encryption.read_file(&path).expect("read file");
982
983        assert_eq!(String::from_utf8_lossy(data), content);
984    }
985
986    #[test]
987    fn state_encryption_unencrypted_fallback() {
988        let td = tempdir().expect("tempdir");
989        let path = td.path().join("plain.json");
990
991        let config = EncryptionConfig::new("test-pass".to_string());
992        let encryption = StateEncryption::new(config).expect("should create");
993
994        // Write unencrypted file directly
995        let data = r#"{"plain": "data"}"#;
996        std::fs::write(&path, data).expect("write plain");
997
998        // Should be able to read it back
999        let content = encryption.read_file(&path).expect("read file");
1000        assert_eq!(data, content);
1001    }
1002
1003    #[test]
1004    fn state_encryption_disabled_writes_plaintext() {
1005        let td = tempdir().expect("tempdir");
1006        let path = td.path().join("plain.json");
1007
1008        let config = EncryptionConfig::default();
1009        let encryption = StateEncryption::new(config).expect("create");
1010
1011        let data = b"plain text content";
1012        encryption.write_file(&path, data).expect("write");
1013
1014        let on_disk = std::fs::read(&path).expect("read");
1015        assert_eq!(data.to_vec(), on_disk);
1016    }
1017
1018    #[test]
1019    fn state_encryption_disabled_reads_plaintext() {
1020        let td = tempdir().expect("tempdir");
1021        let path = td.path().join("plain.txt");
1022        std::fs::write(&path, "hello").expect("write");
1023
1024        let config = EncryptionConfig::default();
1025        let encryption = StateEncryption::new(config).expect("create");
1026
1027        let content = encryption.read_file(&path).expect("read");
1028        assert_eq!(content, "hello");
1029    }
1030
1031    #[test]
1032    fn state_encryption_read_nonexistent_file_fails() {
1033        let td = tempdir().expect("tempdir");
1034        let path = td.path().join("nope.json");
1035
1036        let config = EncryptionConfig::new("pass".to_string());
1037        let encryption = StateEncryption::new(config).expect("create");
1038
1039        assert!(encryption.read_file(&path).is_err());
1040    }
1041
1042    // ── Edge-case: large data >1 MB ─────────────────────────────────────
1043
1044    #[test]
1045    fn encrypt_decrypt_data_over_1mb() {
1046        // 2 MiB of pseudo-random data
1047        let plaintext: Vec<u8> = (0u64..2_097_152)
1048            .map(|i| (i.wrapping_mul(7) % 256) as u8)
1049            .collect();
1050        let passphrase = "large-2mb-passphrase";
1051
1052        let encrypted = encrypt(&plaintext, passphrase).expect("encryption should succeed");
1053        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1054        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
1055
1056        assert_eq!(plaintext, decrypted);
1057    }
1058
1059    // ── Edge-case: key boundary values ──────────────────────────────────
1060
1061    #[test]
1062    fn roundtrip_single_char_passphrase() {
1063        let plaintext = b"single char key";
1064        let passphrase = "x";
1065
1066        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1067        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1068        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1069
1070        assert_eq!(plaintext.to_vec(), decrypted);
1071    }
1072
1073    #[test]
1074    fn roundtrip_whitespace_only_passphrase() {
1075        let plaintext = b"whitespace key test";
1076        let passphrase = "   \t\n  ";
1077
1078        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1079        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1080        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1081
1082        assert_eq!(plaintext.to_vec(), decrypted);
1083    }
1084
1085    #[test]
1086    fn roundtrip_max_reasonable_passphrase() {
1087        let plaintext = b"max key test";
1088        // 100 KB passphrase
1089        let passphrase: String = "Z".repeat(100_000);
1090
1091        let encrypted = encrypt(plaintext, &passphrase).expect("encrypt");
1092        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1093        let decrypted = decrypt(&encrypted_str, &passphrase).expect("decrypt");
1094
1095        assert_eq!(plaintext.to_vec(), decrypted);
1096    }
1097
1098    // ── Edge-case: nonce uniqueness at raw byte level ───────────────────
1099
1100    #[test]
1101    fn nonce_uniqueness_raw_salt_and_nonce_differ() {
1102        let plaintext = b"nonce uniqueness check";
1103        let passphrase = "same-passphrase";
1104
1105        let enc1 = encrypt(plaintext, passphrase).expect("encrypt");
1106        let enc2 = encrypt(plaintext, passphrase).expect("encrypt");
1107
1108        let raw1 = BASE64
1109            .decode(String::from_utf8(enc1).expect("utf8"))
1110            .expect("base64");
1111        let raw2 = BASE64
1112            .decode(String::from_utf8(enc2).expect("utf8"))
1113            .expect("base64");
1114
1115        let salt_nonce_1 = &raw1[..SALT_SIZE + NONCE_SIZE];
1116        let salt_nonce_2 = &raw2[..SALT_SIZE + NONCE_SIZE];
1117
1118        // Random salt+nonce must differ between encryptions
1119        assert_ne!(salt_nonce_1, salt_nonce_2);
1120    }
1121
1122    // ── Edge-case: tampered auth tag detection ──────────────────────────
1123
1124    #[test]
1125    fn tampered_auth_tag_detected() {
1126        let plaintext = b"auth tag tamper test";
1127        let passphrase = "test-pass";
1128
1129        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1130        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1131
1132        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
1133        // Flip the last byte (inside the GCM auth tag)
1134        let last = raw.len() - 1;
1135        raw[last] ^= 0xFF;
1136        let corrupted = BASE64.encode(&raw);
1137
1138        assert!(decrypt(&corrupted, passphrase).is_err());
1139    }
1140
1141    #[test]
1142    fn tampered_single_bit_flip_detected() {
1143        let plaintext = b"bit flip detection";
1144        let passphrase = "bit-flip-pass";
1145
1146        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1147        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1148
1149        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
1150        // Flip a single bit in the middle of the ciphertext
1151        let mid = raw.len() / 2;
1152        raw[mid] ^= 0x01;
1153        let corrupted = BASE64.encode(&raw);
1154
1155        assert!(decrypt(&corrupted, passphrase).is_err());
1156    }
1157
1158    // ── Edge-case: wrong key returns error, not garbage ─────────────────
1159
1160    #[test]
1161    fn wrong_key_returns_error_not_garbage_data() {
1162        let plaintext = b"This must not leak through wrong key";
1163        let correct = "correct-key";
1164        let wrong = "wrong-key";
1165
1166        let encrypted = encrypt(plaintext, correct).expect("encrypt");
1167        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1168
1169        let result = decrypt(&encrypted_str, wrong);
1170        // Must be Err — AES-GCM authenticated encryption must reject wrong keys
1171        assert!(
1172            result.is_err(),
1173            "wrong key must return Err, not Ok with garbage"
1174        );
1175
1176        let err_msg = result.unwrap_err().to_string();
1177        assert!(
1178            err_msg.contains("wrong passphrase or corrupted data"),
1179            "error message should indicate wrong passphrase, got: {err_msg}"
1180        );
1181    }
1182
1183    #[test]
1184    fn wrong_key_similar_passphrase_returns_error() {
1185        let plaintext = b"subtle key difference";
1186        let correct = "my-passphrase-abc";
1187        let wrong = "my-passphrase-abd"; // off by one char
1188
1189        let encrypted = encrypt(plaintext, correct).expect("encrypt");
1190        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1191
1192        assert!(
1193            decrypt(&encrypted_str, wrong).is_err(),
1194            "even a single-char difference must cause decryption failure"
1195        );
1196    }
1197
1198    // ── Realistic data roundtrips ───────────────────────────────────────
1199
1200    #[test]
1201    fn roundtrip_realistic_json_state() {
1202        let state_json = br#"{
1203            "plan_id": "abc123",
1204            "workspace": "/home/user/project",
1205            "crates": [
1206                {"name": "core", "version": "0.1.0", "status": "published"},
1207                {"name": "cli", "version": "0.2.0", "status": "pending"}
1208            ],
1209            "started_at": "2024-01-15T10:30:00Z",
1210            "token": "cio_supersecrettoken1234567890"
1211        }"#;
1212        let passphrase = "ci-pipeline-key-2024";
1213
1214        let encrypted = encrypt(state_json, passphrase).expect("encrypt");
1215        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1216        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1217
1218        assert_eq!(state_json.to_vec(), decrypted);
1219    }
1220
1221    #[test]
1222    fn roundtrip_event_log_jsonl() {
1223        let events = b"{\"event\":\"publish_start\",\"crate\":\"core\",\"ts\":1700000000}\n\
1224                       {\"event\":\"publish_ok\",\"crate\":\"core\",\"ts\":1700000005}\n\
1225                       {\"event\":\"publish_start\",\"crate\":\"cli\",\"ts\":1700000010}\n";
1226        let passphrase = "event-log-key";
1227
1228        let encrypted = encrypt(events, passphrase).expect("encrypt");
1229        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1230        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1231
1232        assert_eq!(events.to_vec(), decrypted);
1233    }
1234
1235    // ── Key derivation edge cases ───────────────────────────────────────
1236
1237    #[test]
1238    fn derive_key_always_produces_32_bytes() {
1239        for passphrase in ["", "a", "short", &"x".repeat(10_000)] {
1240            for salt in [&[0u8; 0][..], &[0u8; 1], &[0u8; SALT_SIZE], &[0xFF; 64]] {
1241                let key = derive_key(passphrase, salt);
1242                assert_eq!(
1243                    key.len(),
1244                    KEY_SIZE,
1245                    "key must be {KEY_SIZE} bytes for passphrase len={}, salt len={}",
1246                    passphrase.len(),
1247                    salt.len()
1248                );
1249            }
1250        }
1251    }
1252
1253    #[test]
1254    fn derive_key_with_empty_salt() {
1255        let key1 = derive_key("passphrase", &[]);
1256        let key2 = derive_key("passphrase", &[]);
1257        assert_eq!(key1, key2, "empty salt should still be deterministic");
1258        assert_eq!(key1.len(), KEY_SIZE);
1259    }
1260
1261    // ── AES block boundary tests ────────────────────────────────────────
1262
1263    #[test]
1264    fn encrypt_decrypt_exactly_aes_block_size() {
1265        // AES block size is 16 bytes; GCM is a stream mode, but block boundary is interesting
1266        let plaintext = [0xABu8; 16];
1267        let passphrase = "block-boundary";
1268
1269        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1270        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1271        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1272
1273        assert_eq!(plaintext.to_vec(), decrypted);
1274    }
1275
1276    #[test]
1277    fn encrypt_decrypt_multi_block_boundaries() {
1278        let passphrase = "multi-block";
1279        for size in [15, 16, 17, 31, 32, 33, 48, 64, 128, 255, 256, 257] {
1280            let plaintext: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
1281            let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1282            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1283            let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1284            assert_eq!(plaintext, decrypted, "roundtrip failed for size {size}");
1285        }
1286    }
1287
1288    // ── StateEncryption error paths ─────────────────────────────────────
1289
1290    #[test]
1291    fn state_encryption_encrypt_enabled_no_passphrase_errors() {
1292        let config = EncryptionConfig {
1293            enabled: true,
1294            passphrase: None,
1295            env_var: None,
1296        };
1297        let encryption = StateEncryption::new(config).expect("create");
1298        assert!(!encryption.is_enabled());
1299
1300        let result = encryption.encrypt(b"data");
1301        assert!(result.is_err(), "encrypt with no passphrase should fail");
1302        let err = result.unwrap_err().to_string();
1303        assert!(
1304            err.contains("no passphrase"),
1305            "error should mention missing passphrase, got: {err}"
1306        );
1307    }
1308
1309    #[test]
1310    fn state_encryption_cross_config_decrypt_fails() {
1311        let config_a = EncryptionConfig::new("key-alpha".to_string());
1312        let config_b = EncryptionConfig::new("key-beta".to_string());
1313        let enc_a = StateEncryption::new(config_a).expect("create");
1314        let enc_b = StateEncryption::new(config_b).expect("create");
1315
1316        let data = b"cross-config secret";
1317        let encrypted = enc_a.encrypt(data).expect("encrypt with A");
1318
1319        // B's decrypt should fall back to returning raw data (not the plaintext)
1320        let result = enc_b.decrypt(&encrypted).expect("decrypt returns fallback");
1321        assert_ne!(
1322            result,
1323            data.to_vec(),
1324            "wrong config must not produce original plaintext"
1325        );
1326    }
1327
1328    // ── Truncation / malformed ciphertext ───────────────────────────────
1329
1330    #[test]
1331    fn decrypt_truncated_after_header_fails() {
1332        let plaintext = b"data to truncate";
1333        let passphrase = "trunc-pass";
1334
1335        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1336        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1337        let raw = BASE64.decode(&encrypted_str).expect("base64");
1338
1339        // Truncate to just salt + nonce + 16 bytes (minimum that passes length check)
1340        let truncated = &raw[..SALT_SIZE + NONCE_SIZE + 16];
1341        let encoded = BASE64.encode(truncated);
1342
1343        assert!(
1344            decrypt(&encoded, passphrase).is_err(),
1345            "truncated ciphertext must fail decryption"
1346        );
1347    }
1348
1349    // ── is_encrypted edge cases ─────────────────────────────────────────
1350
1351    #[test]
1352    fn is_encrypted_accepts_exact_minimum_length() {
1353        // Exactly salt(16) + nonce(12) + 16 bytes = 44 bytes of raw data
1354        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 16];
1355        let encoded = BASE64.encode(&data);
1356        assert!(
1357            is_encrypted(&encoded),
1358            "minimum-length valid base64 should pass heuristic"
1359        );
1360    }
1361
1362    // ── Repeated operations ─────────────────────────────────────────────
1363
1364    #[test]
1365    fn multiple_sequential_encrypt_decrypt_cycles() {
1366        let passphrase = "cycle-test";
1367        let mut data = b"initial plaintext".to_vec();
1368
1369        for i in 0..50 {
1370            let encrypted = encrypt(&data, passphrase)
1371                .unwrap_or_else(|e| panic!("encrypt failed on cycle {i}: {e}"));
1372            let encrypted_str = String::from_utf8(encrypted)
1373                .unwrap_or_else(|e| panic!("utf8 failed on cycle {i}: {e}"));
1374            let decrypted = decrypt(&encrypted_str, passphrase)
1375                .unwrap_or_else(|e| panic!("decrypt failed on cycle {i}: {e}"));
1376            assert_eq!(data, decrypted, "mismatch on cycle {i}");
1377            // Mutate data slightly for next cycle
1378            data.push((i % 256) as u8);
1379        }
1380    }
1381
1382    // ── Null bytes and high-entropy data ────────────────────────────────
1383
1384    #[test]
1385    fn roundtrip_null_bytes_in_plaintext() {
1386        let plaintext = b"before\x00middle\x00\x00after\x00";
1387        let passphrase = "null-byte-pass";
1388
1389        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1390        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1391        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1392
1393        assert_eq!(plaintext.to_vec(), decrypted);
1394    }
1395
1396    #[test]
1397    fn roundtrip_all_0xff_bytes() {
1398        let plaintext = vec![0xFFu8; 512];
1399        let passphrase = "high-entropy";
1400
1401        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1402        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1403        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1404
1405        assert_eq!(plaintext, decrypted);
1406    }
1407
1408    // ── Env-var passphrase resolution (temp_env) ────────────────────────
1409
1410    #[test]
1411    fn env_var_passphrase_resolution() {
1412        let cfg = EncryptionConfig::from_env("SHIPPER_TEST_PASS_1".to_string());
1413        temp_env::with_var("SHIPPER_TEST_PASS_1", Some("env-secret"), || {
1414            let passphrase = cfg.get_passphrase().unwrap();
1415            assert_eq!(passphrase, Some("env-secret".to_string()));
1416        });
1417    }
1418
1419    #[test]
1420    fn env_var_passphrase_missing_returns_none() {
1421        let cfg = EncryptionConfig::from_env("SHIPPER_TEST_MISSING_VAR".to_string());
1422        temp_env::with_var("SHIPPER_TEST_MISSING_VAR", None::<&str>, || {
1423            let passphrase = cfg.get_passphrase().unwrap();
1424            assert_eq!(passphrase, None);
1425        });
1426    }
1427
1428    #[test]
1429    fn state_encryption_from_env_var_roundtrip() {
1430        let config = EncryptionConfig::from_env("SHIPPER_TEST_ENC_PASS".to_string());
1431        let encryption = StateEncryption::new(config).expect("create");
1432
1433        temp_env::with_var("SHIPPER_TEST_ENC_PASS", Some("my-env-key"), || {
1434            assert!(encryption.is_enabled());
1435
1436            let data = b"env-var encrypted data";
1437            let encrypted = encryption.encrypt(data).expect("encrypt");
1438            let decrypted = encryption.decrypt(&encrypted).expect("decrypt");
1439            assert_eq!(data.to_vec(), decrypted);
1440        });
1441    }
1442
1443    #[test]
1444    fn state_encryption_env_var_takes_precedence() {
1445        let config = EncryptionConfig {
1446            enabled: true,
1447            passphrase: Some("inline-pass".to_string()),
1448            env_var: Some("SHIPPER_TEST_PRIO_PASS".to_string()),
1449        };
1450        let encryption = StateEncryption::new(config).expect("create");
1451
1452        temp_env::with_var("SHIPPER_TEST_PRIO_PASS", Some("env-pass"), || {
1453            // StateEncryption.get_passphrase tries env var first
1454            let data = b"priority test";
1455            let encrypted = encryption.encrypt(data).expect("encrypt");
1456
1457            // Must decrypt with "env-pass" (env takes priority in StateEncryption)
1458            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1459            assert!(
1460                decrypt(&encrypted_str, "env-pass").is_ok(),
1461                "env var passphrase should take priority"
1462            );
1463        });
1464    }
1465
1466    #[test]
1467    fn state_encryption_file_roundtrip_with_env_var() {
1468        let td = tempdir().expect("tempdir");
1469        let path = td.path().join("env_enc.json");
1470
1471        let config = EncryptionConfig::from_env("SHIPPER_TEST_FILE_PASS".to_string());
1472        let encryption = StateEncryption::new(config).expect("create");
1473
1474        temp_env::with_var("SHIPPER_TEST_FILE_PASS", Some("file-env-key"), || {
1475            let data = br#"{"encrypted_via": "env_var"}"#;
1476            encryption.write_file(&path, data).expect("write");
1477            let content = encryption.read_file(&path).expect("read");
1478            assert_eq!(String::from_utf8_lossy(data), content);
1479        });
1480    }
1481
1482    // ── Salt uniqueness across many encryptions ─────────────────────────
1483
1484    #[test]
1485    fn salt_uniqueness_across_10_encryptions() {
1486        let plaintext = b"salt uniqueness test";
1487        let passphrase = "salt-test";
1488
1489        let mut salts = Vec::new();
1490        for _ in 0..10 {
1491            let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1492            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1493            let raw = BASE64.decode(&encrypted_str).expect("base64");
1494            let salt = raw[..SALT_SIZE].to_vec();
1495            salts.push(salt);
1496        }
1497
1498        // All salts must be unique
1499        for i in 0..salts.len() {
1500            for j in (i + 1)..salts.len() {
1501                assert_ne!(salts[i], salts[j], "salt collision at indices {i} and {j}");
1502            }
1503        }
1504    }
1505
1506    // ── Key derivation with special passphrases ─────────────────────────
1507
1508    #[test]
1509    fn derive_key_unicode_passphrase_is_deterministic() {
1510        let passphrase = "пароль-密码-🔑";
1511        let salt = [0x42u8; SALT_SIZE];
1512        let key1 = derive_key(passphrase, &salt);
1513        let key2 = derive_key(passphrase, &salt);
1514        assert_eq!(key1, key2);
1515    }
1516
1517    #[test]
1518    fn derive_key_newline_passphrase_differs_from_stripped() {
1519        let salt = [0u8; SALT_SIZE];
1520        let key_with_newlines = derive_key("pass\nphrase\n", &salt);
1521        let key_stripped = derive_key("passphrase", &salt);
1522        assert_ne!(key_with_newlines, key_stripped);
1523    }
1524
1525    // ── Double encryption ───────────────────────────────────────────────
1526
1527    #[test]
1528    fn double_encrypt_roundtrip() {
1529        let plaintext = b"double layer secret";
1530        let pass1 = "outer-key";
1531        let pass2 = "inner-key";
1532
1533        let inner = encrypt(plaintext, pass1).expect("encrypt inner");
1534        let outer = encrypt(&inner, pass2).expect("encrypt outer");
1535
1536        let outer_str = String::from_utf8(outer).expect("utf8");
1537        let decrypted_outer = decrypt(&outer_str, pass2).expect("decrypt outer");
1538        let inner_str = String::from_utf8(decrypted_outer).expect("utf8");
1539        let decrypted_inner = decrypt(&inner_str, pass1).expect("decrypt inner");
1540
1541        assert_eq!(plaintext.to_vec(), decrypted_inner);
1542    }
1543
1544    // ── is_encrypted edge cases ─────────────────────────────────────────
1545
1546    #[test]
1547    fn is_encrypted_rejects_whitespace_around_base64() {
1548        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 16];
1549        let encoded = format!("  {}  ", BASE64.encode(&data));
1550        // Leading/trailing whitespace makes it invalid base64
1551        assert!(!is_encrypted(&encoded));
1552    }
1553
1554    #[test]
1555    fn is_encrypted_rejects_json_object() {
1556        assert!(!is_encrypted(r#"{"plan_id":"abc","crates":[]}"#));
1557    }
1558
1559    // ── StateEncryption fallback on malformed data ──────────────────────
1560
1561    #[test]
1562    fn state_encryption_decrypt_returns_original_on_bad_encrypted_data() {
1563        let config = EncryptionConfig::new("test-pass".to_string());
1564        let encryption = StateEncryption::new(config).expect("create");
1565
1566        // Data that isn't valid encrypted content
1567        let raw_json = b"plain JSON content";
1568        let result = encryption.decrypt(raw_json).expect("should fall back");
1569        assert_eq!(raw_json.to_vec(), result);
1570    }
1571
1572    // ── File I/O with unicode content ───────────────────────────────────
1573
1574    #[test]
1575    fn file_roundtrip_unicode_content() {
1576        let td = tempdir().expect("tempdir");
1577        let path = td.path().join("unicode.enc");
1578
1579        let plaintext = "Ünïcödé cöntënt: 日本語テスト 🎉";
1580        write_encrypted(&path, plaintext.as_bytes(), "unicode-pass").expect("write");
1581        let decrypted = read_decrypted(&path, "unicode-pass").expect("read");
1582        assert_eq!(plaintext, decrypted);
1583    }
1584
1585    // ── Encrypt/decrypt with GCM tag-sized plaintext ────────────────────
1586
1587    #[test]
1588    fn encrypt_decrypt_exactly_gcm_tag_size() {
1589        // 16 bytes, same as the GCM authentication tag size
1590        let plaintext = [0xCD; 16];
1591        let passphrase = "tag-size-test";
1592
1593        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1594        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1595        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1596        assert_eq!(plaintext.to_vec(), decrypted);
1597    }
1598
1599    // ── EncryptionConfig Display with both sources ──────────────────────
1600
1601    #[test]
1602    fn display_config_passphrase_takes_precedence_in_display() {
1603        let cfg = EncryptionConfig {
1604            enabled: true,
1605            passphrase: Some("my-pass".to_string()),
1606            env_var: Some("MY_ENV".to_string()),
1607        };
1608        let display = cfg.to_string();
1609        // Display shows passphrase arm (first match) when both are present
1610        assert!(
1611            display.contains("passphrase:"),
1612            "should show passphrase branch, got: {display}"
1613        );
1614    }
1615
1616    // ── mask_passphrase additional cases ─────────────────────────────────
1617
1618    #[test]
1619    fn mask_passphrase_four_chars() {
1620        let masked = mask_passphrase("abcd");
1621        assert_eq!(masked, "a**d");
1622    }
1623
1624    #[test]
1625    fn mask_passphrase_five_chars() {
1626        let masked = mask_passphrase("hello");
1627        assert_eq!(masked, "h***o");
1628    }
1629
1630    #[test]
1631    fn mask_passphrase_with_spaces() {
1632        let masked = mask_passphrase("a b c");
1633        assert_eq!(masked, "a***c");
1634    }
1635
1636    // ── StateEncryption disabled ignores env var ────────────────────────
1637
1638    #[test]
1639    fn state_encryption_disabled_ignores_env_var() {
1640        let config = EncryptionConfig {
1641            enabled: false,
1642            passphrase: None,
1643            env_var: Some("SHIPPER_TEST_IGNORED_VAR".to_string()),
1644        };
1645        let encryption = StateEncryption::new(config).expect("create");
1646
1647        temp_env::with_var("SHIPPER_TEST_IGNORED_VAR", Some("secret"), || {
1648            assert!(!encryption.is_enabled());
1649            // decrypt should pass through raw data
1650            let data = b"not encrypted";
1651            let result = encryption.decrypt(data).expect("passthrough");
1652            assert_eq!(data.to_vec(), result);
1653        });
1654    }
1655}
1656
1657// ── Property-based tests ────────────────────────────────────────────────
1658
1659#[cfg(test)]
1660mod proptests {
1661    use super::*;
1662    use proptest::prelude::*;
1663
1664    proptest! {
1665        #[test]
1666        fn roundtrip_arbitrary_data(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
1667            let passphrase = "prop-test-pass";
1668            let encrypted = encrypt(&data, passphrase).expect("encrypt");
1669            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1670            let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1671            prop_assert_eq!(data, decrypted);
1672        }
1673
1674        #[test]
1675        fn roundtrip_arbitrary_passphrase(passphrase in "\\PC{1,200}") {
1676            let plaintext = b"fixed plaintext for passphrase fuzz";
1677            let encrypted = encrypt(plaintext, &passphrase).expect("encrypt");
1678            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1679            let decrypted = decrypt(&encrypted_str, &passphrase).expect("decrypt");
1680            prop_assert_eq!(plaintext.to_vec(), decrypted);
1681        }
1682
1683        #[test]
1684        fn roundtrip_arbitrary_data_and_passphrase(
1685            data in proptest::collection::vec(any::<u8>(), 0..1024),
1686            passphrase in "\\PC{1,100}",
1687        ) {
1688            let encrypted = encrypt(&data, &passphrase).expect("encrypt");
1689            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1690            let decrypted = decrypt(&encrypted_str, &passphrase).expect("decrypt");
1691            prop_assert_eq!(data, decrypted);
1692        }
1693
1694        #[test]
1695        fn encrypted_output_is_valid_base64(data in proptest::collection::vec(any::<u8>(), 0..512)) {
1696            let encrypted = encrypt(&data, "test").expect("encrypt");
1697            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1698            prop_assert!(BASE64.decode(&encrypted_str).is_ok());
1699        }
1700
1701        #[test]
1702        fn wrong_passphrase_always_fails(
1703            data in proptest::collection::vec(any::<u8>(), 1..512),
1704            correct in "[a-z]{8,16}",
1705            wrong in "[A-Z]{8,16}",
1706        ) {
1707            // Ensure passphrases actually differ
1708            prop_assume!(correct != wrong);
1709            let encrypted = encrypt(&data, &correct).expect("encrypt");
1710            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1711            prop_assert!(decrypt(&encrypted_str, &wrong).is_err());
1712        }
1713
1714        #[test]
1715        fn encrypted_size_is_deterministic(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
1716            let encrypted = encrypt(&data, "pass").expect("encrypt");
1717            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1718            let raw = BASE64.decode(&encrypted_str).expect("base64");
1719            // salt(16) + nonce(12) + plaintext_len + gcm_tag(16)
1720            let expected = SALT_SIZE + NONCE_SIZE + data.len() + 16;
1721            prop_assert_eq!(raw.len(), expected);
1722        }
1723
1724        #[test]
1725        fn is_encrypted_true_for_encrypt_output(data in proptest::collection::vec(any::<u8>(), 0..512)) {
1726            let encrypted = encrypt(&data, "test-pass").expect("encrypt");
1727            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1728            prop_assert!(is_encrypted(&encrypted_str));
1729        }
1730
1731        #[test]
1732        fn is_encrypted_never_panics(s in "\\PC{0,500}") {
1733            let _ = is_encrypted(&s);
1734        }
1735
1736        #[test]
1737        fn decrypt_arbitrary_string_never_panics(s in "\\PC{0,500}") {
1738            let _ = decrypt(&s, "passphrase");
1739        }
1740
1741        #[test]
1742        fn encrypt_output_is_always_utf8(
1743            data in proptest::collection::vec(any::<u8>(), 0..1024),
1744            passphrase in "\\PC{1,50}",
1745        ) {
1746            let encrypted = encrypt(&data, &passphrase).expect("encrypt");
1747            prop_assert!(String::from_utf8(encrypted).is_ok());
1748        }
1749
1750        #[test]
1751        fn each_encrypt_produces_unique_ciphertext(data in proptest::collection::vec(any::<u8>(), 0..256)) {
1752            let a = encrypt(&data, "same-pass").expect("encrypt");
1753            let b = encrypt(&data, "same-pass").expect("encrypt");
1754            prop_assert_ne!(a, b);
1755        }
1756
1757        #[test]
1758        fn encryption_config_serde_roundtrip_arbitrary(passphrase in "\\PC{1,100}") {
1759            let cfg = EncryptionConfig::new(passphrase.clone());
1760            let json = serde_json::to_string(&cfg).expect("serialize");
1761            let de: EncryptionConfig = serde_json::from_str(&json).expect("deserialize");
1762            prop_assert_eq!(de.enabled, true);
1763            prop_assert_eq!(de.passphrase.as_deref(), Some(passphrase.as_str()));
1764        }
1765
1766        #[test]
1767        fn state_encryption_roundtrip_arbitrary(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
1768            let config = EncryptionConfig::new("state-prop-pass".to_string());
1769            let se = StateEncryption::new(config).expect("create");
1770            let encrypted = se.encrypt(&data).expect("encrypt");
1771            let decrypted = se.decrypt(&encrypted).expect("decrypt");
1772            prop_assert_eq!(data, decrypted);
1773        }
1774
1775        #[test]
1776        fn encryption_output_always_longer_than_input(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
1777            let encrypted = encrypt(&data, "length-test").expect("encrypt");
1778            // Encrypted output (base64 of salt+nonce+ciphertext+tag) is always longer than plaintext
1779            prop_assert!(encrypted.len() > data.len());
1780        }
1781
1782        #[test]
1783        fn tampered_ciphertext_always_fails(data in proptest::collection::vec(any::<u8>(), 1..512)) {
1784            let passphrase = "tamper-prop-test";
1785            let encrypted = encrypt(&data, passphrase).expect("encrypt");
1786            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1787
1788            let mut raw = BASE64.decode(&encrypted_str).expect("base64");
1789            // Flip a byte in the ciphertext region (after salt+nonce)
1790            let idx = SALT_SIZE + NONCE_SIZE + (raw.len() - SALT_SIZE - NONCE_SIZE) / 2;
1791            raw[idx] ^= 0xFF;
1792            let corrupted = BASE64.encode(&raw);
1793
1794            prop_assert!(decrypt(&corrupted, passphrase).is_err());
1795        }
1796
1797        #[test]
1798        fn derive_key_always_produces_32_bytes_prop(
1799            passphrase in "\\PC{0,200}",
1800            salt in proptest::collection::vec(any::<u8>(), 0..64),
1801        ) {
1802            let key = derive_key(&passphrase, &salt);
1803            prop_assert_eq!(key.len(), KEY_SIZE);
1804        }
1805
1806        #[test]
1807        fn decrypt_truncated_ciphertext_always_fails_prop(
1808            data in proptest::collection::vec(any::<u8>(), 1..512),
1809            trim in 1usize..17,
1810        ) {
1811            let passphrase = "truncation-prop";
1812            let encrypted = encrypt(&data, passphrase).expect("encrypt");
1813            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1814            let raw = BASE64.decode(&encrypted_str).expect("base64");
1815
1816            // Trim `trim` bytes off the end (corrupts auth tag or ciphertext)
1817            if raw.len() > SALT_SIZE + NONCE_SIZE + 16 {
1818                let truncated = &raw[..raw.len() - trim];
1819                if truncated.len() >= SALT_SIZE + NONCE_SIZE + 16 {
1820                    let encoded = BASE64.encode(truncated);
1821                    prop_assert!(decrypt(&encoded, passphrase).is_err());
1822                }
1823            }
1824        }
1825
1826        #[test]
1827        fn derive_key_deterministic_prop(
1828            passphrase in "\\PC{0,100}",
1829            salt in proptest::collection::vec(any::<u8>(), 0..32),
1830        ) {
1831            let key1 = derive_key(&passphrase, &salt);
1832            let key2 = derive_key(&passphrase, &salt);
1833            prop_assert_eq!(key1, key2, "derive_key must be deterministic");
1834        }
1835
1836        #[test]
1837        fn salt_differs_across_encryptions_prop(data in proptest::collection::vec(any::<u8>(), 0..256)) {
1838            let a = encrypt(&data, "same-pass").expect("encrypt");
1839            let b = encrypt(&data, "same-pass").expect("encrypt");
1840            let raw_a = BASE64.decode(String::from_utf8(a).expect("utf8")).expect("base64");
1841            let raw_b = BASE64.decode(String::from_utf8(b).expect("utf8")).expect("base64");
1842            let salt_a = &raw_a[..SALT_SIZE];
1843            let salt_b = &raw_b[..SALT_SIZE];
1844            prop_assert_ne!(salt_a.to_vec(), salt_b.to_vec(), "salts must differ");
1845        }
1846
1847        #[test]
1848        fn double_encrypt_roundtrip_prop(data in proptest::collection::vec(any::<u8>(), 0..256)) {
1849            let pass1 = "layer-one";
1850            let pass2 = "layer-two";
1851            let enc1 = encrypt(&data, pass1).expect("encrypt 1");
1852            let enc2 = encrypt(&enc1, pass2).expect("encrypt 2");
1853            let enc2_str = String::from_utf8(enc2).expect("utf8");
1854            let dec2 = decrypt(&enc2_str, pass2).expect("decrypt 2");
1855            let dec2_str = String::from_utf8(dec2).expect("utf8");
1856            let dec1 = decrypt(&dec2_str, pass1).expect("decrypt 1");
1857            prop_assert_eq!(data, dec1);
1858        }
1859    }
1860}
1861
1862// ── Snapshot tests ──────────────────────────────────────────────────────
1863
1864#[cfg(test)]
1865mod snapshot_tests {
1866    use super::*;
1867    use insta::{assert_debug_snapshot, assert_snapshot};
1868
1869    // ── EncryptionConfig serialization ──────────────────────────────────
1870
1871    #[test]
1872    fn config_default_json() {
1873        let cfg = EncryptionConfig::default();
1874        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
1875        assert_snapshot!(json);
1876    }
1877
1878    #[test]
1879    fn config_with_passphrase_json() {
1880        let cfg = EncryptionConfig::new("my-secret".to_string());
1881        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
1882        assert_snapshot!(json);
1883    }
1884
1885    #[test]
1886    fn config_with_env_var_json() {
1887        let cfg = EncryptionConfig::from_env("SHIPPER_ENCRYPT_KEY".to_string());
1888        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
1889        assert_snapshot!(json);
1890    }
1891
1892    #[test]
1893    fn config_enabled_no_passphrase_json() {
1894        let cfg = EncryptionConfig {
1895            enabled: true,
1896            passphrase: None,
1897            env_var: None,
1898        };
1899        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
1900        assert_snapshot!(json);
1901    }
1902
1903    #[test]
1904    fn config_with_both_passphrase_and_env_json() {
1905        let cfg = EncryptionConfig {
1906            enabled: true,
1907            passphrase: Some("inline-pass".to_string()),
1908            env_var: Some("SHIPPER_ENCRYPT_KEY".to_string()),
1909        };
1910        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
1911        assert_snapshot!(json);
1912    }
1913
1914    // ── Masked token format ─────────────────────────────────────────────
1915
1916    #[test]
1917    fn mask_passphrase_normal() {
1918        assert_snapshot!(mask_passphrase("my-secret-passphrase"));
1919    }
1920
1921    #[test]
1922    fn mask_passphrase_short_three_chars() {
1923        assert_snapshot!(mask_passphrase("abc"));
1924    }
1925
1926    #[test]
1927    fn mask_passphrase_two_chars() {
1928        assert_snapshot!(mask_passphrase("ab"));
1929    }
1930
1931    #[test]
1932    fn mask_passphrase_single_char() {
1933        assert_snapshot!(mask_passphrase("x"));
1934    }
1935
1936    #[test]
1937    fn mask_passphrase_empty() {
1938        assert_snapshot!(mask_passphrase(""));
1939    }
1940
1941    #[test]
1942    fn mask_passphrase_unicode() {
1943        assert_snapshot!(mask_passphrase("🔑secret🔒"));
1944    }
1945
1946    // ── StateEncryption config display ──────────────────────────────────
1947
1948    #[test]
1949    fn display_config_disabled() {
1950        let cfg = EncryptionConfig::default();
1951        assert_snapshot!(cfg.to_string());
1952    }
1953
1954    #[test]
1955    fn display_config_with_passphrase() {
1956        let cfg = EncryptionConfig::new("super-secret-key".to_string());
1957        assert_snapshot!(cfg.to_string());
1958    }
1959
1960    #[test]
1961    fn display_config_with_env_var() {
1962        let cfg = EncryptionConfig::from_env("SHIPPER_ENCRYPT_KEY".to_string());
1963        assert_snapshot!(cfg.to_string());
1964    }
1965
1966    #[test]
1967    fn display_config_enabled_no_source() {
1968        let cfg = EncryptionConfig {
1969            enabled: true,
1970            passphrase: None,
1971            env_var: None,
1972        };
1973        assert_snapshot!(cfg.to_string());
1974    }
1975
1976    #[test]
1977    fn display_state_encryption_wrapper() {
1978        let cfg = EncryptionConfig::new("my-passphrase".to_string());
1979        let se = StateEncryption::new(cfg).expect("create");
1980        assert_snapshot!(se.to_string());
1981    }
1982
1983    // ── Decryption failure error messages ───────────────────────────────
1984
1985    #[test]
1986    fn error_invalid_base64() {
1987        let err = decrypt("not-valid-base64!!!", "pass").unwrap_err();
1988        assert_snapshot!(err.to_string());
1989    }
1990
1991    #[test]
1992    fn error_data_too_short() {
1993        let short = BASE64.encode(vec![0u8; SALT_SIZE + NONCE_SIZE + 15]);
1994        let err = decrypt(&short, "pass").unwrap_err();
1995        assert_snapshot!(err.to_string());
1996    }
1997
1998    #[test]
1999    fn error_wrong_passphrase() {
2000        let encrypted = encrypt(b"secret data", "correct-pass").expect("encrypt");
2001        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2002        let err = decrypt(&encrypted_str, "wrong-pass").unwrap_err();
2003        assert_snapshot!(err.to_string());
2004    }
2005
2006    #[test]
2007    fn error_corrupted_ciphertext() {
2008        let encrypted = encrypt(b"data", "pass").expect("encrypt");
2009        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2010        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2011        raw[SALT_SIZE + NONCE_SIZE + 1] ^= 0xFF;
2012        let corrupted = BASE64.encode(&raw);
2013        let err = decrypt(&corrupted, "pass").unwrap_err();
2014        assert_snapshot!(err.to_string());
2015    }
2016
2017    #[test]
2018    fn error_empty_input() {
2019        let err = decrypt("", "pass").unwrap_err();
2020        assert_snapshot!(err.to_string());
2021    }
2022
2023    // ── Snapshot: error types for tampered regions ──────────────────────
2024
2025    #[test]
2026    fn error_corrupted_salt_message() {
2027        let encrypted = encrypt(b"snapshot salt", "pass").expect("encrypt");
2028        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2029        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2030        raw[0] ^= 0xFF;
2031        let corrupted = BASE64.encode(&raw);
2032        let err = decrypt(&corrupted, "pass").unwrap_err();
2033        assert_snapshot!(err.to_string());
2034    }
2035
2036    #[test]
2037    fn error_corrupted_nonce_message() {
2038        let encrypted = encrypt(b"snapshot nonce", "pass").expect("encrypt");
2039        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2040        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2041        raw[SALT_SIZE] ^= 0xFF;
2042        let corrupted = BASE64.encode(&raw);
2043        let err = decrypt(&corrupted, "pass").unwrap_err();
2044        assert_snapshot!(err.to_string());
2045    }
2046
2047    #[test]
2048    fn error_corrupted_auth_tag_message() {
2049        let encrypted = encrypt(b"snapshot tag", "pass").expect("encrypt");
2050        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2051        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2052        let last = raw.len() - 1;
2053        raw[last] ^= 0xFF;
2054        let corrupted = BASE64.encode(&raw);
2055        let err = decrypt(&corrupted, "pass").unwrap_err();
2056        assert_snapshot!(err.to_string());
2057    }
2058
2059    // ── Snapshot: key generation output format ──────────────────────────
2060
2061    #[test]
2062    fn snapshot_derive_key_output_format() {
2063        let key = derive_key("test-passphrase", &[0u8; SALT_SIZE]);
2064        // Snapshot the hex-encoded key to verify deterministic output format
2065        let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
2066        assert_snapshot!(hex);
2067    }
2068
2069    #[test]
2070    fn snapshot_derive_key_length() {
2071        let key = derive_key("any-passphrase", &[42u8; SALT_SIZE]);
2072        assert_debug_snapshot!(key.len());
2073    }
2074
2075    // ── Snapshot: EncryptionConfig Debug output ─────────────────────────
2076
2077    #[test]
2078    fn snapshot_encryption_config_debug_default() {
2079        let cfg = EncryptionConfig::default();
2080        assert_debug_snapshot!(cfg);
2081    }
2082
2083    #[test]
2084    fn snapshot_encryption_config_debug_with_passphrase() {
2085        let cfg = EncryptionConfig::new("debug-pass".to_string());
2086        assert_debug_snapshot!(cfg);
2087    }
2088
2089    #[test]
2090    fn snapshot_encryption_config_debug_from_env() {
2091        let cfg = EncryptionConfig::from_env("MY_SECRET_VAR".to_string());
2092        assert_debug_snapshot!(cfg);
2093    }
2094
2095    // ── Snapshot: encrypted output structure ─────────────────────────────
2096
2097    #[test]
2098    fn snapshot_encrypted_data_component_sizes() {
2099        let plaintext = b"snapshot-structure-test";
2100        let encrypted = encrypt(plaintext, "snap-pass").expect("encrypt");
2101        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2102        let raw = BASE64.decode(&encrypted_str).expect("base64");
2103
2104        let info = format!(
2105            "salt_bytes={}, nonce_bytes={}, ciphertext_plus_tag_bytes={}, plaintext_len={}, overhead={}",
2106            SALT_SIZE,
2107            NONCE_SIZE,
2108            raw.len() - SALT_SIZE - NONCE_SIZE,
2109            plaintext.len(),
2110            raw.len() - plaintext.len(),
2111        );
2112        assert_snapshot!(info);
2113    }
2114
2115    #[test]
2116    fn snapshot_derive_key_alternate_passphrase() {
2117        let key = derive_key("alternate-passphrase-for-snapshot", &[0xAB; SALT_SIZE]);
2118        let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
2119        assert_snapshot!(hex);
2120    }
2121
2122    #[test]
2123    fn snapshot_is_encrypted_results() {
2124        let results = format!(
2125            "empty={}, json={}, short_b64={}, garbage={}",
2126            is_encrypted(""),
2127            is_encrypted(r#"{"key":"value"}"#),
2128            is_encrypted(&BASE64.encode(vec![0u8; 10])),
2129            is_encrypted("!!!not-base64!!!"),
2130        );
2131        assert_snapshot!(results);
2132    }
2133
2134    // ── Snapshot: StateEncryption no-passphrase error ────────────────────
2135
2136    #[test]
2137    fn snapshot_state_encryption_no_passphrase_error() {
2138        let config = EncryptionConfig {
2139            enabled: true,
2140            passphrase: None,
2141            env_var: None,
2142        };
2143        let encryption = StateEncryption::new(config).expect("create");
2144        let err = encryption.encrypt(b"data").unwrap_err();
2145        assert_snapshot!(err.to_string());
2146    }
2147
2148    // ── Snapshot: Display with both passphrase and env_var ───────────────
2149
2150    #[test]
2151    fn snapshot_display_config_with_both_sources() {
2152        let cfg = EncryptionConfig {
2153            enabled: true,
2154            passphrase: Some("inline-secret".to_string()),
2155            env_var: Some("SHIPPER_KEY".to_string()),
2156        };
2157        assert_snapshot!(cfg.to_string());
2158    }
2159
2160    // ── Snapshot: mask_passphrase additional lengths ─────────────────────
2161
2162    #[test]
2163    fn snapshot_mask_passphrase_four_chars() {
2164        assert_snapshot!(mask_passphrase("abcd"));
2165    }
2166
2167    #[test]
2168    fn snapshot_mask_passphrase_with_spaces() {
2169        assert_snapshot!(mask_passphrase("a b c d"));
2170    }
2171
2172    #[test]
2173    fn snapshot_mask_passphrase_with_newline() {
2174        assert_snapshot!(mask_passphrase("pass\nword"));
2175    }
2176
2177    // ── Snapshot: encrypted output overhead for empty plaintext ──────────
2178
2179    #[test]
2180    fn snapshot_encrypted_empty_plaintext_structure() {
2181        let encrypted = encrypt(b"", "snap-pass").expect("encrypt");
2182        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2183        let raw = BASE64.decode(&encrypted_str).expect("base64");
2184
2185        let info = format!(
2186            "raw_len={}, salt={}, nonce={}, ciphertext_plus_tag={}, plaintext_len=0",
2187            raw.len(),
2188            SALT_SIZE,
2189            NONCE_SIZE,
2190            raw.len() - SALT_SIZE - NONCE_SIZE,
2191        );
2192        assert_snapshot!(info);
2193    }
2194}