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 serial_test::serial;
410    use tempfile::tempdir;
411
412    // ── Core encrypt/decrypt roundtrip ──────────────────────────────────
413
414    #[test]
415    fn encrypt_decrypt_roundtrip() {
416        let plaintext = b"Hello, World! This is a test message.";
417        let passphrase = "test-passphrase-123";
418
419        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
420        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
421        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
422
423        assert_eq!(plaintext.to_vec(), decrypted);
424    }
425
426    #[test]
427    fn encrypt_produces_different_output_for_same_plaintext() {
428        let plaintext = b"Hello, World!";
429        let passphrase = "test-passphrase";
430
431        let encrypted1 = encrypt(plaintext, passphrase).expect("encryption should succeed");
432        let encrypted2 = encrypt(plaintext, passphrase).expect("encryption should succeed");
433
434        // Should be different due to random salt/nonce
435        assert_ne!(encrypted1, encrypted2);
436
437        // But both should decrypt to the same plaintext
438        let decrypted1 = decrypt(
439            String::from_utf8(encrypted1).expect("valid UTF-8"),
440            passphrase,
441        )
442        .expect("decryption should succeed");
443        let decrypted2 = decrypt(
444            String::from_utf8(encrypted2).expect("valid UTF-8"),
445            passphrase,
446        )
447        .expect("decryption should succeed");
448
449        assert_eq!(decrypted1, decrypted2);
450    }
451
452    #[test]
453    fn decrypt_wrong_passphrase_fails() {
454        let plaintext = b"Secret data";
455        let passphrase = "correct-passphrase";
456        let wrong_passphrase = "wrong-passphrase";
457
458        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
459        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
460
461        let result = decrypt(&encrypted_str, wrong_passphrase);
462        assert!(result.is_err());
463    }
464
465    // ── Empty input ─────────────────────────────────────────────────────
466
467    #[test]
468    fn encrypt_decrypt_empty_input() {
469        let plaintext = b"";
470        let passphrase = "test-passphrase";
471
472        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
473        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
474        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
475
476        assert_eq!(plaintext.to_vec(), decrypted);
477    }
478
479    #[test]
480    fn encrypt_empty_with_empty_passphrase() {
481        let plaintext = b"";
482        let passphrase = "";
483
484        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
485        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
486        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
487
488        assert_eq!(plaintext.to_vec(), decrypted);
489    }
490
491    // ── Large input ─────────────────────────────────────────────────────
492
493    #[test]
494    fn encrypt_decrypt_large_input() {
495        // 1 MiB of data
496        let plaintext: Vec<u8> = (0..1_048_576).map(|i| (i % 256) as u8).collect();
497        let passphrase = "large-data-passphrase";
498
499        let encrypted = encrypt(&plaintext, passphrase).expect("encryption should succeed");
500        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
501        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
502
503        assert_eq!(plaintext, decrypted);
504    }
505
506    #[test]
507    fn encrypt_decrypt_single_byte() {
508        let plaintext = b"\x42";
509        let passphrase = "single-byte";
510
511        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
512        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
513        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
514
515        assert_eq!(plaintext.to_vec(), decrypted);
516    }
517
518    // ── Decrypt error cases ─────────────────────────────────────────────
519
520    #[test]
521    fn decrypt_invalid_base64_fails() {
522        let result = decrypt("not-valid-base64!!!", "passphrase");
523        assert!(result.is_err());
524        let err = result.unwrap_err().to_string();
525        assert!(
526            err.contains("base64"),
527            "error should mention base64, got: {err}"
528        );
529    }
530
531    #[test]
532    fn decrypt_too_short_data_fails() {
533        // Encode data that is too short (less than salt + nonce + 16-byte tag)
534        let short_data = vec![0u8; SALT_SIZE + NONCE_SIZE + 15];
535        let encoded = BASE64.encode(&short_data);
536
537        let result = decrypt(&encoded, "passphrase");
538        assert!(result.is_err());
539        let err = result.unwrap_err().to_string();
540        assert!(
541            err.contains("too short"),
542            "error should mention 'too short', got: {err}"
543        );
544    }
545
546    #[test]
547    fn decrypt_corrupted_ciphertext_fails() {
548        let plaintext = b"Some data to encrypt";
549        let passphrase = "test-pass";
550
551        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
552        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
553
554        // Decode, corrupt a byte in the ciphertext region, re-encode
555        let mut raw = BASE64.decode(&encrypted_str).expect("valid base64");
556        let idx = SALT_SIZE + NONCE_SIZE + 1;
557        raw[idx] ^= 0xFF;
558        let corrupted = BASE64.encode(&raw);
559
560        let result = decrypt(&corrupted, passphrase);
561        assert!(result.is_err());
562    }
563
564    #[test]
565    fn decrypt_corrupted_salt_fails() {
566        let plaintext = b"Some data";
567        let passphrase = "test-pass";
568
569        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
570        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
571
572        // Flip a bit in the salt region
573        let mut raw = BASE64.decode(&encrypted_str).expect("valid base64");
574        raw[0] ^= 0xFF;
575        let corrupted = BASE64.encode(&raw);
576
577        let result = decrypt(&corrupted, passphrase);
578        assert!(result.is_err());
579    }
580
581    #[test]
582    fn decrypt_corrupted_nonce_fails() {
583        let plaintext = b"Some data";
584        let passphrase = "test-pass";
585
586        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
587        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
588
589        // Flip a bit in the nonce region
590        let mut raw = BASE64.decode(&encrypted_str).expect("valid base64");
591        raw[SALT_SIZE] ^= 0xFF;
592        let corrupted = BASE64.encode(&raw);
593
594        let result = decrypt(&corrupted, passphrase);
595        assert!(result.is_err());
596    }
597
598    #[test]
599    fn decrypt_empty_string_fails() {
600        let result = decrypt("", "passphrase");
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn decrypt_exactly_minimum_length_minus_one_fails() {
606        // Exactly salt + nonce + 15 bytes (one less than the 16-byte auth tag)
607        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 15];
608        let encoded = BASE64.encode(&data);
609        assert!(decrypt(&encoded, "pass").is_err());
610    }
611
612    #[test]
613    fn decrypt_exactly_minimum_length_fails_with_wrong_key() {
614        // salt + nonce + 16 bytes of garbage "ciphertext"
615        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 16];
616        let encoded = BASE64.encode(&data);
617        // Passes the length check but fails decryption
618        assert!(decrypt(&encoded, "pass").is_err());
619    }
620
621    // ── is_encrypted heuristic ──────────────────────────────────────────
622
623    #[test]
624    fn is_encrypted_detects_encrypted_data() {
625        let plaintext = b"Hello, World!";
626        let passphrase = "test-passphrase";
627
628        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
629        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
630
631        assert!(is_encrypted(&encrypted_str));
632    }
633
634    #[test]
635    fn is_encrypted_rejects_plaintext() {
636        let plaintext = r#"{"key": "value"}"#;
637        assert!(!is_encrypted(plaintext));
638    }
639
640    #[test]
641    fn is_encrypted_rejects_empty_string() {
642        assert!(!is_encrypted(""));
643    }
644
645    #[test]
646    fn is_encrypted_rejects_short_base64() {
647        // Valid base64 but too short to be encrypted data
648        let short = BASE64.encode(vec![0u8; SALT_SIZE + NONCE_SIZE + 10]);
649        assert!(!is_encrypted(&short));
650    }
651
652    #[test]
653    fn is_encrypted_rejects_non_base64() {
654        assert!(!is_encrypted("definitely not base64 $$$ !!!"));
655    }
656
657    // ── Passphrase edge cases ───────────────────────────────────────────
658
659    #[test]
660    fn roundtrip_with_unicode_passphrase() {
661        let plaintext = b"Unicode passphrase test";
662        let passphrase = "pässwörd-密码-🔑";
663
664        let encrypted = encrypt(plaintext, passphrase).expect("encryption should succeed");
665        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
666        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
667
668        assert_eq!(plaintext.to_vec(), decrypted);
669    }
670
671    #[test]
672    fn roundtrip_with_very_long_passphrase() {
673        let plaintext = b"Long passphrase test";
674        let passphrase: String = "a".repeat(10_000);
675
676        let encrypted = encrypt(plaintext, &passphrase).expect("encryption should succeed");
677        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
678        let decrypted = decrypt(&encrypted_str, &passphrase).expect("decryption should succeed");
679
680        assert_eq!(plaintext.to_vec(), decrypted);
681    }
682
683    #[test]
684    fn different_passphrases_produce_different_ciphertexts_when_decoded() {
685        let plaintext = b"Same plaintext";
686        let pass1 = "passphrase-one";
687        let pass2 = "passphrase-two";
688
689        let enc1 = encrypt(plaintext, pass1).expect("encrypt");
690        let enc2 = encrypt(plaintext, pass2).expect("encrypt");
691
692        // Different passphrases → different raw bytes (even ignoring salt/nonce randomness)
693        let raw1 = BASE64
694            .decode(String::from_utf8(enc1).expect("utf8"))
695            .expect("base64");
696        let raw2 = BASE64
697            .decode(String::from_utf8(enc2).expect("utf8"))
698            .expect("base64");
699
700        // Ciphertext portions must differ
701        let ct1 = &raw1[SALT_SIZE + NONCE_SIZE..];
702        let ct2 = &raw2[SALT_SIZE + NONCE_SIZE..];
703        assert_ne!(ct1, ct2);
704    }
705
706    // ── Binary / non-UTF8 plaintext ─────────────────────────────────────
707
708    #[test]
709    fn roundtrip_binary_data() {
710        let plaintext: Vec<u8> = (0..=255).collect();
711        let passphrase = "binary-data-test";
712
713        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
714        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
715        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
716
717        assert_eq!(plaintext, decrypted);
718    }
719
720    #[test]
721    fn roundtrip_all_zero_bytes() {
722        let plaintext = vec![0u8; 1024];
723        let passphrase = "zeroes";
724
725        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
726        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
727        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
728
729        assert_eq!(plaintext, decrypted);
730    }
731
732    // ── derive_key ──────────────────────────────────────────────────────
733
734    #[test]
735    fn derive_key_produces_consistent_output() {
736        let passphrase = "test-passphrase";
737        let salt = [0u8; SALT_SIZE];
738
739        let key1 = derive_key(passphrase, &salt);
740        let key2 = derive_key(passphrase, &salt);
741
742        assert_eq!(key1, key2);
743    }
744
745    #[test]
746    fn derive_key_different_salts_produce_different_keys() {
747        let passphrase = "test-passphrase";
748        let salt1 = [0u8; SALT_SIZE];
749        let mut salt2 = [0u8; SALT_SIZE];
750        salt2[0] = 1;
751
752        let key1 = derive_key(passphrase, &salt1);
753        let key2 = derive_key(passphrase, &salt2);
754
755        assert_ne!(key1, key2);
756    }
757
758    #[test]
759    fn derive_key_different_passphrases_produce_different_keys() {
760        let salt = [42u8; SALT_SIZE];
761
762        let key1 = derive_key("passphrase-a", &salt);
763        let key2 = derive_key("passphrase-b", &salt);
764
765        assert_ne!(key1, key2);
766    }
767
768    #[test]
769    fn derive_key_empty_passphrase() {
770        let salt = [0u8; SALT_SIZE];
771        // Should not panic – just produces a deterministic key
772        let key1 = derive_key("", &salt);
773        let key2 = derive_key("", &salt);
774        assert_eq!(key1, key2);
775    }
776
777    // ── EncryptionConfig ────────────────────────────────────────────────
778
779    #[test]
780    fn encryption_config_default_is_disabled() {
781        let cfg = EncryptionConfig::default();
782        assert!(!cfg.enabled);
783        assert!(cfg.passphrase.is_none());
784        assert!(cfg.env_var.is_none());
785    }
786
787    #[test]
788    fn encryption_config_new_is_enabled() {
789        let cfg = EncryptionConfig::new("secret".to_string());
790        assert!(cfg.enabled);
791        assert_eq!(cfg.passphrase.as_deref(), Some("secret"));
792        assert!(cfg.env_var.is_none());
793    }
794
795    #[test]
796    fn encryption_config_from_env_is_enabled() {
797        let cfg = EncryptionConfig::from_env("MY_VAR".to_string());
798        assert!(cfg.enabled);
799        assert!(cfg.passphrase.is_none());
800        assert_eq!(cfg.env_var.as_deref(), Some("MY_VAR"));
801    }
802
803    #[test]
804    fn encryption_config_get_passphrase_direct() {
805        let cfg = EncryptionConfig::new("hello".to_string());
806        assert_eq!(cfg.get_passphrase().unwrap(), Some("hello".to_string()));
807    }
808
809    #[test]
810    fn encryption_config_get_passphrase_none_when_disabled() {
811        let cfg = EncryptionConfig::default();
812        assert_eq!(cfg.get_passphrase().unwrap(), None);
813    }
814
815    #[test]
816    fn encryption_config_serde_roundtrip() {
817        let cfg = EncryptionConfig::new("test".to_string());
818        let json = serde_json::to_string(&cfg).expect("serialize");
819        let deserialized: EncryptionConfig = serde_json::from_str(&json).expect("deserialize");
820        assert_eq!(deserialized.enabled, cfg.enabled);
821        assert_eq!(deserialized.passphrase, cfg.passphrase);
822    }
823
824    #[test]
825    fn encryption_config_serde_skips_none_fields() {
826        let cfg = EncryptionConfig::default();
827        let json = serde_json::to_string(&cfg).expect("serialize");
828        assert!(!json.contains("passphrase"));
829        assert!(!json.contains("env_var"));
830    }
831
832    // ── StateEncryption ─────────────────────────────────────────────────
833
834    #[test]
835    fn state_encryption_enabled_disabled() {
836        let config = EncryptionConfig::default();
837        let encryption = StateEncryption::new(config.clone()).expect("should create");
838        assert!(!encryption.is_enabled());
839
840        let config = EncryptionConfig::new("test-passphrase".to_string());
841        let encryption = StateEncryption::new(config).expect("should create");
842        assert!(encryption.is_enabled());
843    }
844
845    #[test]
846    fn state_encryption_roundtrip() {
847        let config = EncryptionConfig::new("my-secret-passphrase".to_string());
848        let encryption = StateEncryption::new(config).expect("should create");
849
850        let data = b"Test state data";
851
852        let encrypted = encryption.encrypt(data).expect("encryption should succeed");
853        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
854        let decrypted =
855            decrypt(&encrypted_str, "my-secret-passphrase").expect("decryption should succeed");
856
857        assert_eq!(data.to_vec(), decrypted);
858    }
859
860    #[test]
861    fn state_encryption_decrypt_roundtrip() {
862        let config = EncryptionConfig::new("my-pass".to_string());
863        let encryption = StateEncryption::new(config).expect("should create");
864
865        let data = b"state data to encrypt";
866        let encrypted = encryption.encrypt(data).expect("encrypt");
867        let decrypted = encryption.decrypt(&encrypted).expect("decrypt");
868
869        assert_eq!(data.to_vec(), decrypted);
870    }
871
872    #[test]
873    fn state_encryption_disabled_passthrough() {
874        let config = EncryptionConfig::default();
875        let encryption = StateEncryption::new(config).expect("should create");
876
877        let data = b"Plain text data";
878
879        let result = encryption.decrypt(data).expect("should succeed");
880        assert_eq!(data.to_vec(), result);
881    }
882
883    #[test]
884    fn state_encryption_disabled_encrypt_passthrough_on_decrypt() {
885        // When disabled, decrypt returns data as-is even if it looks like garbage
886        let config = EncryptionConfig::default();
887        let encryption = StateEncryption::new(config).expect("should create");
888
889        let garbage = b"\x00\x01\x02\x03";
890        let result = encryption.decrypt(garbage).expect("should succeed");
891        assert_eq!(garbage.to_vec(), result);
892    }
893
894    // ── encrypt output format ───────────────────────────────────────────
895
896    #[test]
897    fn encrypt_produces_valid_base64() {
898        let plaintext = b"Test data";
899        let passphrase = "test";
900
901        let encrypted = encrypt(plaintext, passphrase).expect("should encrypt");
902        let encrypted_str = String::from_utf8(encrypted.clone()).expect("valid UTF-8");
903
904        let decoded = BASE64.decode(&encrypted_str).expect("valid base64");
905        assert!(decoded.len() > plaintext.len());
906    }
907
908    #[test]
909    fn encrypted_output_has_expected_structure() {
910        let plaintext = b"Hello";
911        let passphrase = "test";
912
913        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
914        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
915        let raw = BASE64.decode(&encrypted_str).expect("base64");
916
917        // raw = salt(16) + nonce(12) + ciphertext(len(plaintext) + 16 for GCM tag)
918        let expected_len = SALT_SIZE + NONCE_SIZE + plaintext.len() + 16;
919        assert_eq!(raw.len(), expected_len);
920    }
921
922    // ── File I/O ────────────────────────────────────────────────────────
923
924    #[test]
925    fn read_write_encrypted_file() {
926        let td = tempdir().expect("tempdir");
927        let path = td.path().join("test.enc");
928
929        let plaintext = b"Secret file content";
930        let passphrase = "file-passphrase";
931
932        write_encrypted(&path, plaintext, passphrase).expect("write encrypted");
933        let decrypted = read_decrypted(&path, passphrase).expect("read decrypted");
934
935        assert_eq!(plaintext.to_vec(), decrypted.into_bytes());
936    }
937
938    #[test]
939    fn read_decrypted_wrong_passphrase_fails() {
940        let td = tempdir().expect("tempdir");
941        let path = td.path().join("test.enc");
942
943        write_encrypted(&path, b"data", "correct").expect("write");
944        let result = read_decrypted(&path, "wrong");
945        assert!(result.is_err());
946    }
947
948    #[test]
949    fn read_decrypted_nonexistent_file_fails() {
950        let td = tempdir().expect("tempdir");
951        let path = td.path().join("does-not-exist.enc");
952
953        let result = read_decrypted(&path, "pass");
954        assert!(result.is_err());
955    }
956
957    #[test]
958    fn write_encrypted_file_is_base64_on_disk() {
959        let td = tempdir().expect("tempdir");
960        let path = td.path().join("test.enc");
961
962        write_encrypted(&path, b"data", "pass").expect("write");
963        let on_disk = std::fs::read_to_string(&path).expect("read");
964
965        // Should be valid base64
966        assert!(BASE64.decode(&on_disk).is_ok());
967        // Should NOT be the plaintext
968        assert_ne!(on_disk, "data");
969    }
970
971    #[test]
972    fn state_encryption_file_roundtrip() {
973        let td = tempdir().expect("tempdir");
974        let path = td.path().join("state.json");
975
976        let config = EncryptionConfig::new("test-pass".to_string());
977        let encryption = StateEncryption::new(config).expect("should create");
978
979        let data = br#"{"key": "value"}"#;
980
981        encryption.write_file(&path, data).expect("write file");
982        let content = encryption.read_file(&path).expect("read file");
983
984        assert_eq!(String::from_utf8_lossy(data), content);
985    }
986
987    #[test]
988    fn state_encryption_unencrypted_fallback() {
989        let td = tempdir().expect("tempdir");
990        let path = td.path().join("plain.json");
991
992        let config = EncryptionConfig::new("test-pass".to_string());
993        let encryption = StateEncryption::new(config).expect("should create");
994
995        // Write unencrypted file directly
996        let data = r#"{"plain": "data"}"#;
997        std::fs::write(&path, data).expect("write plain");
998
999        // Should be able to read it back
1000        let content = encryption.read_file(&path).expect("read file");
1001        assert_eq!(data, content);
1002    }
1003
1004    #[test]
1005    fn state_encryption_disabled_writes_plaintext() {
1006        let td = tempdir().expect("tempdir");
1007        let path = td.path().join("plain.json");
1008
1009        let config = EncryptionConfig::default();
1010        let encryption = StateEncryption::new(config).expect("create");
1011
1012        let data = b"plain text content";
1013        encryption.write_file(&path, data).expect("write");
1014
1015        let on_disk = std::fs::read(&path).expect("read");
1016        assert_eq!(data.to_vec(), on_disk);
1017    }
1018
1019    #[test]
1020    fn state_encryption_disabled_reads_plaintext() {
1021        let td = tempdir().expect("tempdir");
1022        let path = td.path().join("plain.txt");
1023        std::fs::write(&path, "hello").expect("write");
1024
1025        let config = EncryptionConfig::default();
1026        let encryption = StateEncryption::new(config).expect("create");
1027
1028        let content = encryption.read_file(&path).expect("read");
1029        assert_eq!(content, "hello");
1030    }
1031
1032    #[test]
1033    fn state_encryption_read_nonexistent_file_fails() {
1034        let td = tempdir().expect("tempdir");
1035        let path = td.path().join("nope.json");
1036
1037        let config = EncryptionConfig::new("pass".to_string());
1038        let encryption = StateEncryption::new(config).expect("create");
1039
1040        assert!(encryption.read_file(&path).is_err());
1041    }
1042
1043    // ── Edge-case: large data >1 MB ─────────────────────────────────────
1044
1045    #[test]
1046    fn encrypt_decrypt_data_over_1mb() {
1047        // 2 MiB of pseudo-random data
1048        let plaintext: Vec<u8> = (0u64..2_097_152)
1049            .map(|i| (i.wrapping_mul(7) % 256) as u8)
1050            .collect();
1051        let passphrase = "large-2mb-passphrase";
1052
1053        let encrypted = encrypt(&plaintext, passphrase).expect("encryption should succeed");
1054        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1055        let decrypted = decrypt(&encrypted_str, passphrase).expect("decryption should succeed");
1056
1057        assert_eq!(plaintext, decrypted);
1058    }
1059
1060    // ── Edge-case: key boundary values ──────────────────────────────────
1061
1062    #[test]
1063    fn roundtrip_single_char_passphrase() {
1064        let plaintext = b"single char key";
1065        let passphrase = "x";
1066
1067        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1068        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1069        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1070
1071        assert_eq!(plaintext.to_vec(), decrypted);
1072    }
1073
1074    #[test]
1075    fn roundtrip_whitespace_only_passphrase() {
1076        let plaintext = b"whitespace key test";
1077        let passphrase = "   \t\n  ";
1078
1079        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1080        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1081        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1082
1083        assert_eq!(plaintext.to_vec(), decrypted);
1084    }
1085
1086    #[test]
1087    fn roundtrip_max_reasonable_passphrase() {
1088        let plaintext = b"max key test";
1089        // 100 KB passphrase
1090        let passphrase: String = "Z".repeat(100_000);
1091
1092        let encrypted = encrypt(plaintext, &passphrase).expect("encrypt");
1093        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1094        let decrypted = decrypt(&encrypted_str, &passphrase).expect("decrypt");
1095
1096        assert_eq!(plaintext.to_vec(), decrypted);
1097    }
1098
1099    // ── Edge-case: nonce uniqueness at raw byte level ───────────────────
1100
1101    #[test]
1102    fn nonce_uniqueness_raw_salt_and_nonce_differ() {
1103        let plaintext = b"nonce uniqueness check";
1104        let passphrase = "same-passphrase";
1105
1106        let enc1 = encrypt(plaintext, passphrase).expect("encrypt");
1107        let enc2 = encrypt(plaintext, passphrase).expect("encrypt");
1108
1109        let raw1 = BASE64
1110            .decode(String::from_utf8(enc1).expect("utf8"))
1111            .expect("base64");
1112        let raw2 = BASE64
1113            .decode(String::from_utf8(enc2).expect("utf8"))
1114            .expect("base64");
1115
1116        let salt_nonce_1 = &raw1[..SALT_SIZE + NONCE_SIZE];
1117        let salt_nonce_2 = &raw2[..SALT_SIZE + NONCE_SIZE];
1118
1119        // Random salt+nonce must differ between encryptions
1120        assert_ne!(salt_nonce_1, salt_nonce_2);
1121    }
1122
1123    // ── Edge-case: tampered auth tag detection ──────────────────────────
1124
1125    #[test]
1126    fn tampered_auth_tag_detected() {
1127        let plaintext = b"auth tag tamper test";
1128        let passphrase = "test-pass";
1129
1130        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1131        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1132
1133        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
1134        // Flip the last byte (inside the GCM auth tag)
1135        let last = raw.len() - 1;
1136        raw[last] ^= 0xFF;
1137        let corrupted = BASE64.encode(&raw);
1138
1139        assert!(decrypt(&corrupted, passphrase).is_err());
1140    }
1141
1142    #[test]
1143    fn tampered_single_bit_flip_detected() {
1144        let plaintext = b"bit flip detection";
1145        let passphrase = "bit-flip-pass";
1146
1147        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1148        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1149
1150        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
1151        // Flip a single bit in the middle of the ciphertext
1152        let mid = raw.len() / 2;
1153        raw[mid] ^= 0x01;
1154        let corrupted = BASE64.encode(&raw);
1155
1156        assert!(decrypt(&corrupted, passphrase).is_err());
1157    }
1158
1159    // ── Edge-case: wrong key returns error, not garbage ─────────────────
1160
1161    #[test]
1162    fn wrong_key_returns_error_not_garbage_data() {
1163        let plaintext = b"This must not leak through wrong key";
1164        let correct = "correct-key";
1165        let wrong = "wrong-key";
1166
1167        let encrypted = encrypt(plaintext, correct).expect("encrypt");
1168        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1169
1170        let result = decrypt(&encrypted_str, wrong);
1171        // Must be Err — AES-GCM authenticated encryption must reject wrong keys
1172        assert!(
1173            result.is_err(),
1174            "wrong key must return Err, not Ok with garbage"
1175        );
1176
1177        let err_msg = result.unwrap_err().to_string();
1178        assert!(
1179            err_msg.contains("wrong passphrase or corrupted data"),
1180            "error message should indicate wrong passphrase, got: {err_msg}"
1181        );
1182    }
1183
1184    #[test]
1185    fn wrong_key_similar_passphrase_returns_error() {
1186        let plaintext = b"subtle key difference";
1187        let correct = "my-passphrase-abc";
1188        let wrong = "my-passphrase-abd"; // off by one char
1189
1190        let encrypted = encrypt(plaintext, correct).expect("encrypt");
1191        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1192
1193        assert!(
1194            decrypt(&encrypted_str, wrong).is_err(),
1195            "even a single-char difference must cause decryption failure"
1196        );
1197    }
1198
1199    // ── Realistic data roundtrips ───────────────────────────────────────
1200
1201    #[test]
1202    fn roundtrip_realistic_json_state() {
1203        let state_json = br#"{
1204            "plan_id": "abc123",
1205            "workspace": "/home/user/project",
1206            "crates": [
1207                {"name": "core", "version": "0.1.0", "status": "published"},
1208                {"name": "cli", "version": "0.2.0", "status": "pending"}
1209            ],
1210            "started_at": "2024-01-15T10:30:00Z",
1211            "token": "cio_supersecrettoken1234567890"
1212        }"#;
1213        let passphrase = "ci-pipeline-key-2024";
1214
1215        let encrypted = encrypt(state_json, passphrase).expect("encrypt");
1216        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1217        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1218
1219        assert_eq!(state_json.to_vec(), decrypted);
1220    }
1221
1222    #[test]
1223    fn roundtrip_event_log_jsonl() {
1224        let events = b"{\"event\":\"publish_start\",\"crate\":\"core\",\"ts\":1700000000}\n\
1225                       {\"event\":\"publish_ok\",\"crate\":\"core\",\"ts\":1700000005}\n\
1226                       {\"event\":\"publish_start\",\"crate\":\"cli\",\"ts\":1700000010}\n";
1227        let passphrase = "event-log-key";
1228
1229        let encrypted = encrypt(events, passphrase).expect("encrypt");
1230        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1231        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1232
1233        assert_eq!(events.to_vec(), decrypted);
1234    }
1235
1236    // ── Key derivation edge cases ───────────────────────────────────────
1237
1238    #[test]
1239    fn derive_key_always_produces_32_bytes() {
1240        for passphrase in ["", "a", "short", &"x".repeat(10_000)] {
1241            for salt in [&[0u8; 0][..], &[0u8; 1], &[0u8; SALT_SIZE], &[0xFF; 64]] {
1242                let key = derive_key(passphrase, salt);
1243                assert_eq!(
1244                    key.len(),
1245                    KEY_SIZE,
1246                    "key must be {KEY_SIZE} bytes for passphrase len={}, salt len={}",
1247                    passphrase.len(),
1248                    salt.len()
1249                );
1250            }
1251        }
1252    }
1253
1254    #[test]
1255    fn derive_key_with_empty_salt() {
1256        let key1 = derive_key("passphrase", &[]);
1257        let key2 = derive_key("passphrase", &[]);
1258        assert_eq!(key1, key2, "empty salt should still be deterministic");
1259        assert_eq!(key1.len(), KEY_SIZE);
1260    }
1261
1262    // ── AES block boundary tests ────────────────────────────────────────
1263
1264    #[test]
1265    fn encrypt_decrypt_exactly_aes_block_size() {
1266        // AES block size is 16 bytes; GCM is a stream mode, but block boundary is interesting
1267        let plaintext = [0xABu8; 16];
1268        let passphrase = "block-boundary";
1269
1270        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1271        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1272        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1273
1274        assert_eq!(plaintext.to_vec(), decrypted);
1275    }
1276
1277    #[test]
1278    fn encrypt_decrypt_multi_block_boundaries() {
1279        let passphrase = "multi-block";
1280        for size in [15, 16, 17, 31, 32, 33, 48, 64, 128, 255, 256, 257] {
1281            let plaintext: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
1282            let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1283            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1284            let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1285            assert_eq!(plaintext, decrypted, "roundtrip failed for size {size}");
1286        }
1287    }
1288
1289    // ── StateEncryption error paths ─────────────────────────────────────
1290
1291    #[test]
1292    fn state_encryption_encrypt_enabled_no_passphrase_errors() {
1293        let config = EncryptionConfig {
1294            enabled: true,
1295            passphrase: None,
1296            env_var: None,
1297        };
1298        let encryption = StateEncryption::new(config).expect("create");
1299        assert!(!encryption.is_enabled());
1300
1301        let result = encryption.encrypt(b"data");
1302        assert!(result.is_err(), "encrypt with no passphrase should fail");
1303        let err = result.unwrap_err().to_string();
1304        assert!(
1305            err.contains("no passphrase"),
1306            "error should mention missing passphrase, got: {err}"
1307        );
1308    }
1309
1310    #[test]
1311    fn state_encryption_cross_config_decrypt_fails() {
1312        let config_a = EncryptionConfig::new("key-alpha".to_string());
1313        let config_b = EncryptionConfig::new("key-beta".to_string());
1314        let enc_a = StateEncryption::new(config_a).expect("create");
1315        let enc_b = StateEncryption::new(config_b).expect("create");
1316
1317        let data = b"cross-config secret";
1318        let encrypted = enc_a.encrypt(data).expect("encrypt with A");
1319
1320        // B's decrypt should fall back to returning raw data (not the plaintext)
1321        let result = enc_b.decrypt(&encrypted).expect("decrypt returns fallback");
1322        assert_ne!(
1323            result,
1324            data.to_vec(),
1325            "wrong config must not produce original plaintext"
1326        );
1327    }
1328
1329    // ── Truncation / malformed ciphertext ───────────────────────────────
1330
1331    #[test]
1332    fn decrypt_truncated_after_header_fails() {
1333        let plaintext = b"data to truncate";
1334        let passphrase = "trunc-pass";
1335
1336        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1337        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1338        let raw = BASE64.decode(&encrypted_str).expect("base64");
1339
1340        // Truncate to just salt + nonce + 16 bytes (minimum that passes length check)
1341        let truncated = &raw[..SALT_SIZE + NONCE_SIZE + 16];
1342        let encoded = BASE64.encode(truncated);
1343
1344        assert!(
1345            decrypt(&encoded, passphrase).is_err(),
1346            "truncated ciphertext must fail decryption"
1347        );
1348    }
1349
1350    // ── is_encrypted edge cases ─────────────────────────────────────────
1351
1352    #[test]
1353    fn is_encrypted_accepts_exact_minimum_length() {
1354        // Exactly salt(16) + nonce(12) + 16 bytes = 44 bytes of raw data
1355        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 16];
1356        let encoded = BASE64.encode(&data);
1357        assert!(
1358            is_encrypted(&encoded),
1359            "minimum-length valid base64 should pass heuristic"
1360        );
1361    }
1362
1363    // ── Repeated operations ─────────────────────────────────────────────
1364
1365    #[test]
1366    fn multiple_sequential_encrypt_decrypt_cycles() {
1367        let passphrase = "cycle-test";
1368        let mut data = b"initial plaintext".to_vec();
1369
1370        for i in 0..50 {
1371            let encrypted = encrypt(&data, passphrase)
1372                .unwrap_or_else(|e| panic!("encrypt failed on cycle {i}: {e}"));
1373            let encrypted_str = String::from_utf8(encrypted)
1374                .unwrap_or_else(|e| panic!("utf8 failed on cycle {i}: {e}"));
1375            let decrypted = decrypt(&encrypted_str, passphrase)
1376                .unwrap_or_else(|e| panic!("decrypt failed on cycle {i}: {e}"));
1377            assert_eq!(data, decrypted, "mismatch on cycle {i}");
1378            // Mutate data slightly for next cycle
1379            data.push((i % 256) as u8);
1380        }
1381    }
1382
1383    // ── Null bytes and high-entropy data ────────────────────────────────
1384
1385    #[test]
1386    fn roundtrip_null_bytes_in_plaintext() {
1387        let plaintext = b"before\x00middle\x00\x00after\x00";
1388        let passphrase = "null-byte-pass";
1389
1390        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1391        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1392        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1393
1394        assert_eq!(plaintext.to_vec(), decrypted);
1395    }
1396
1397    #[test]
1398    fn roundtrip_all_0xff_bytes() {
1399        let plaintext = vec![0xFFu8; 512];
1400        let passphrase = "high-entropy";
1401
1402        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1403        let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
1404        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1405
1406        assert_eq!(plaintext, decrypted);
1407    }
1408
1409    // ── Env-var passphrase resolution (temp_env) ────────────────────────
1410
1411    #[test]
1412    #[serial]
1413    fn env_var_passphrase_resolution() {
1414        let cfg = EncryptionConfig::from_env("SHIPPER_TEST_PASS_1".to_string());
1415        temp_env::with_var("SHIPPER_TEST_PASS_1", Some("env-secret"), || {
1416            let passphrase = cfg.get_passphrase().unwrap();
1417            assert_eq!(passphrase, Some("env-secret".to_string()));
1418        });
1419    }
1420
1421    #[test]
1422    #[serial]
1423    fn env_var_passphrase_missing_returns_none() {
1424        let cfg = EncryptionConfig::from_env("SHIPPER_TEST_MISSING_VAR".to_string());
1425        temp_env::with_var("SHIPPER_TEST_MISSING_VAR", None::<&str>, || {
1426            let passphrase = cfg.get_passphrase().unwrap();
1427            assert_eq!(passphrase, None);
1428        });
1429    }
1430
1431    #[test]
1432    #[serial]
1433    fn state_encryption_from_env_var_roundtrip() {
1434        let config = EncryptionConfig::from_env("SHIPPER_TEST_ENC_PASS".to_string());
1435        let encryption = StateEncryption::new(config).expect("create");
1436
1437        temp_env::with_var("SHIPPER_TEST_ENC_PASS", Some("my-env-key"), || {
1438            assert!(encryption.is_enabled());
1439
1440            let data = b"env-var encrypted data";
1441            let encrypted = encryption.encrypt(data).expect("encrypt");
1442            let decrypted = encryption.decrypt(&encrypted).expect("decrypt");
1443            assert_eq!(data.to_vec(), decrypted);
1444        });
1445    }
1446
1447    #[test]
1448    #[serial]
1449    fn state_encryption_env_var_takes_precedence() {
1450        let config = EncryptionConfig {
1451            enabled: true,
1452            passphrase: Some("inline-pass".to_string()),
1453            env_var: Some("SHIPPER_TEST_PRIO_PASS".to_string()),
1454        };
1455        let encryption = StateEncryption::new(config).expect("create");
1456
1457        temp_env::with_var("SHIPPER_TEST_PRIO_PASS", Some("env-pass"), || {
1458            // StateEncryption.get_passphrase tries env var first
1459            let data = b"priority test";
1460            let encrypted = encryption.encrypt(data).expect("encrypt");
1461
1462            // Must decrypt with "env-pass" (env takes priority in StateEncryption)
1463            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1464            assert!(
1465                decrypt(&encrypted_str, "env-pass").is_ok(),
1466                "env var passphrase should take priority"
1467            );
1468        });
1469    }
1470
1471    #[test]
1472    #[serial]
1473    fn state_encryption_file_roundtrip_with_env_var() {
1474        let td = tempdir().expect("tempdir");
1475        let path = td.path().join("env_enc.json");
1476
1477        let config = EncryptionConfig::from_env("SHIPPER_TEST_FILE_PASS".to_string());
1478        let encryption = StateEncryption::new(config).expect("create");
1479
1480        temp_env::with_var("SHIPPER_TEST_FILE_PASS", Some("file-env-key"), || {
1481            let data = br#"{"encrypted_via": "env_var"}"#;
1482            encryption.write_file(&path, data).expect("write");
1483            let content = encryption.read_file(&path).expect("read");
1484            assert_eq!(String::from_utf8_lossy(data), content);
1485        });
1486    }
1487
1488    // ── Salt uniqueness across many encryptions ─────────────────────────
1489
1490    #[test]
1491    fn salt_uniqueness_across_10_encryptions() {
1492        let plaintext = b"salt uniqueness test";
1493        let passphrase = "salt-test";
1494
1495        let mut salts = Vec::new();
1496        for _ in 0..10 {
1497            let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
1498            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1499            let raw = BASE64.decode(&encrypted_str).expect("base64");
1500            let salt = raw[..SALT_SIZE].to_vec();
1501            salts.push(salt);
1502        }
1503
1504        // All salts must be unique
1505        for i in 0..salts.len() {
1506            for j in (i + 1)..salts.len() {
1507                assert_ne!(salts[i], salts[j], "salt collision at indices {i} and {j}");
1508            }
1509        }
1510    }
1511
1512    // ── Key derivation with special passphrases ─────────────────────────
1513
1514    #[test]
1515    fn derive_key_unicode_passphrase_is_deterministic() {
1516        let passphrase = "пароль-密码-🔑";
1517        let salt = [0x42u8; SALT_SIZE];
1518        let key1 = derive_key(passphrase, &salt);
1519        let key2 = derive_key(passphrase, &salt);
1520        assert_eq!(key1, key2);
1521    }
1522
1523    #[test]
1524    fn derive_key_newline_passphrase_differs_from_stripped() {
1525        let salt = [0u8; SALT_SIZE];
1526        let key_with_newlines = derive_key("pass\nphrase\n", &salt);
1527        let key_stripped = derive_key("passphrase", &salt);
1528        assert_ne!(key_with_newlines, key_stripped);
1529    }
1530
1531    // ── Double encryption ───────────────────────────────────────────────
1532
1533    #[test]
1534    fn double_encrypt_roundtrip() {
1535        let plaintext = b"double layer secret";
1536        let pass1 = "outer-key";
1537        let pass2 = "inner-key";
1538
1539        let inner = encrypt(plaintext, pass1).expect("encrypt inner");
1540        let outer = encrypt(&inner, pass2).expect("encrypt outer");
1541
1542        let outer_str = String::from_utf8(outer).expect("utf8");
1543        let decrypted_outer = decrypt(&outer_str, pass2).expect("decrypt outer");
1544        let inner_str = String::from_utf8(decrypted_outer).expect("utf8");
1545        let decrypted_inner = decrypt(&inner_str, pass1).expect("decrypt inner");
1546
1547        assert_eq!(plaintext.to_vec(), decrypted_inner);
1548    }
1549
1550    // ── is_encrypted edge cases ─────────────────────────────────────────
1551
1552    #[test]
1553    fn is_encrypted_rejects_whitespace_around_base64() {
1554        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 16];
1555        let encoded = format!("  {}  ", BASE64.encode(&data));
1556        // Leading/trailing whitespace makes it invalid base64
1557        assert!(!is_encrypted(&encoded));
1558    }
1559
1560    #[test]
1561    fn is_encrypted_rejects_json_object() {
1562        assert!(!is_encrypted(r#"{"plan_id":"abc","crates":[]}"#));
1563    }
1564
1565    // ── StateEncryption fallback on malformed data ──────────────────────
1566
1567    #[test]
1568    fn state_encryption_decrypt_returns_original_on_bad_encrypted_data() {
1569        let config = EncryptionConfig::new("test-pass".to_string());
1570        let encryption = StateEncryption::new(config).expect("create");
1571
1572        // Data that isn't valid encrypted content
1573        let raw_json = b"plain JSON content";
1574        let result = encryption.decrypt(raw_json).expect("should fall back");
1575        assert_eq!(raw_json.to_vec(), result);
1576    }
1577
1578    // ── File I/O with unicode content ───────────────────────────────────
1579
1580    #[test]
1581    fn file_roundtrip_unicode_content() {
1582        let td = tempdir().expect("tempdir");
1583        let path = td.path().join("unicode.enc");
1584
1585        let plaintext = "Ünïcödé cöntënt: 日本語テスト 🎉";
1586        write_encrypted(&path, plaintext.as_bytes(), "unicode-pass").expect("write");
1587        let decrypted = read_decrypted(&path, "unicode-pass").expect("read");
1588        assert_eq!(plaintext, decrypted);
1589    }
1590
1591    // ── Encrypt/decrypt with GCM tag-sized plaintext ────────────────────
1592
1593    #[test]
1594    fn encrypt_decrypt_exactly_gcm_tag_size() {
1595        // 16 bytes, same as the GCM authentication tag size
1596        let plaintext = [0xCD; 16];
1597        let passphrase = "tag-size-test";
1598
1599        let encrypted = encrypt(&plaintext, passphrase).expect("encrypt");
1600        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1601        let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
1602        assert_eq!(plaintext.to_vec(), decrypted);
1603    }
1604
1605    // ── EncryptionConfig Display with both sources ──────────────────────
1606
1607    #[test]
1608    fn display_config_passphrase_takes_precedence_in_display() {
1609        let cfg = EncryptionConfig {
1610            enabled: true,
1611            passphrase: Some("my-pass".to_string()),
1612            env_var: Some("MY_ENV".to_string()),
1613        };
1614        let display = cfg.to_string();
1615        // Display shows passphrase arm (first match) when both are present
1616        assert!(
1617            display.contains("passphrase:"),
1618            "should show passphrase branch, got: {display}"
1619        );
1620    }
1621
1622    // ── mask_passphrase additional cases ─────────────────────────────────
1623
1624    #[test]
1625    fn mask_passphrase_four_chars() {
1626        let masked = mask_passphrase("abcd");
1627        assert_eq!(masked, "a**d");
1628    }
1629
1630    #[test]
1631    fn mask_passphrase_five_chars() {
1632        let masked = mask_passphrase("hello");
1633        assert_eq!(masked, "h***o");
1634    }
1635
1636    #[test]
1637    fn mask_passphrase_with_spaces() {
1638        let masked = mask_passphrase("a b c");
1639        assert_eq!(masked, "a***c");
1640    }
1641
1642    // ── StateEncryption disabled ignores env var ────────────────────────
1643
1644    #[test]
1645    #[serial]
1646    fn state_encryption_disabled_ignores_env_var() {
1647        let config = EncryptionConfig {
1648            enabled: false,
1649            passphrase: None,
1650            env_var: Some("SHIPPER_TEST_IGNORED_VAR".to_string()),
1651        };
1652        let encryption = StateEncryption::new(config).expect("create");
1653
1654        temp_env::with_var("SHIPPER_TEST_IGNORED_VAR", Some("secret"), || {
1655            assert!(!encryption.is_enabled());
1656            // decrypt should pass through raw data
1657            let data = b"not encrypted";
1658            let result = encryption.decrypt(data).expect("passthrough");
1659            assert_eq!(data.to_vec(), result);
1660        });
1661    }
1662
1663    // ── Coverage gap: read_decrypted with non-UTF-8 plaintext ───────────
1664
1665    /// Invariant: `read_decrypted` returns a UTF-8 error (not garbage) when the
1666    /// decrypted bytes are not valid UTF-8. Callers must not silently receive
1667    /// `String::from_utf8_lossy`-style data: a binary state file would be a
1668    /// bug, and surfacing the error is the safe behavior.
1669    #[test]
1670    fn read_decrypted_non_utf8_plaintext_errors() {
1671        let td = tempdir().expect("tempdir");
1672        let path = td.path().join("binary.enc");
1673
1674        // Bytes that are valid AES-GCM plaintext but invalid UTF-8 (lone 0x80)
1675        let binary = vec![0x80u8, 0xFE, 0xFF, 0xC0, 0x80];
1676        write_encrypted(&path, &binary, "binary-pass").expect("write");
1677
1678        let result = read_decrypted(&path, "binary-pass");
1679        assert!(
1680            result.is_err(),
1681            "non-UTF-8 decrypted data must surface as an error"
1682        );
1683        let err = result.unwrap_err().to_string();
1684        assert!(
1685            err.contains("not valid UTF-8"),
1686            "error should mention UTF-8 validation, got: {err}"
1687        );
1688    }
1689
1690    // ── Coverage gap: write_encrypted file IO failure ───────────────────
1691
1692    /// Invariant: `write_encrypted` propagates the underlying fs::write error
1693    /// instead of silently dropping the encrypted payload. Writing into a
1694    /// non-existent parent directory must fail.
1695    #[test]
1696    fn write_encrypted_to_invalid_path_errors() {
1697        let td = tempdir().expect("tempdir");
1698        // Parent directory does not exist
1699        let path = td.path().join("does_not_exist_dir").join("file.enc");
1700
1701        let result = write_encrypted(&path, b"data", "pass");
1702        assert!(result.is_err(), "writing into a missing dir must fail");
1703        let err = result.unwrap_err().to_string();
1704        assert!(
1705            err.contains("failed to write encrypted file"),
1706            "error should mention failing write context, got: {err}"
1707        );
1708    }
1709
1710    // ── Coverage gap: StateEncryption::write_file IO failure ────────────
1711
1712    /// Same invariant as above but via the `StateEncryption` wrapper when
1713    /// encryption is enabled: failing fs::write must surface as Err.
1714    #[test]
1715    fn state_encryption_write_file_encrypted_path_io_error() {
1716        let td = tempdir().expect("tempdir");
1717        let path = td.path().join("missing_subdir").join("state.enc");
1718
1719        let config = EncryptionConfig::new("io-err-pass".to_string());
1720        let encryption = StateEncryption::new(config).expect("create");
1721
1722        let result = encryption.write_file(&path, b"payload");
1723        assert!(result.is_err(), "write to missing parent dir must fail");
1724        let err = result.unwrap_err().to_string();
1725        assert!(
1726            err.contains("failed to write encrypted file"),
1727            "error should mention failing write context, got: {err}"
1728        );
1729    }
1730
1731    /// Invariant: when encryption is disabled, `write_file` still surfaces IO
1732    /// failures (it must not silently swallow the error).
1733    #[test]
1734    fn state_encryption_write_file_plaintext_path_io_error() {
1735        let td = tempdir().expect("tempdir");
1736        let path = td.path().join("missing_subdir").join("plain.txt");
1737
1738        let config = EncryptionConfig::default();
1739        let encryption = StateEncryption::new(config).expect("create");
1740
1741        let result = encryption.write_file(&path, b"plain payload");
1742        assert!(result.is_err(), "plain write to missing dir must fail");
1743        let err = result.unwrap_err().to_string();
1744        assert!(
1745            err.contains("failed to write file"),
1746            "error should mention failing write context, got: {err}"
1747        );
1748    }
1749
1750    // ── Coverage gap: StateEncryption::read_file non-UTF-8 decrypted ─────
1751
1752    /// Invariant: `read_file` returns an error rather than mangling non-UTF-8
1753    /// decrypted bytes. This protects callers that expect a `String` back.
1754    #[test]
1755    fn state_encryption_read_file_non_utf8_decrypted_errors() {
1756        let td = tempdir().expect("tempdir");
1757        let path = td.path().join("binary.enc");
1758
1759        let config = EncryptionConfig::new("rf-pass".to_string());
1760        let encryption = StateEncryption::new(config).expect("create");
1761
1762        // Encrypt binary (non-UTF-8) data using the StateEncryption wrapper so
1763        // the file on disk is a valid ciphertext under the configured passphrase.
1764        let binary = vec![0xFFu8, 0xFE, 0xFD, 0x80, 0xC0];
1765        encryption
1766            .write_file(&path, &binary)
1767            .expect("write encrypted");
1768
1769        let result = encryption.read_file(&path);
1770        assert!(
1771            result.is_err(),
1772            "non-UTF-8 decrypted state should surface as Err"
1773        );
1774        let err = result.unwrap_err().to_string();
1775        assert!(
1776            err.contains("not valid UTF-8"),
1777            "error should mention UTF-8 validation, got: {err}"
1778        );
1779    }
1780
1781    // ── Coverage gap: read_file when enabled and file is not encrypted ──
1782
1783    /// Invariant: even when encryption is configured, `read_file` falls back
1784    /// to returning the raw file contents if decryption fails (so existing
1785    /// plaintext state isn't lost during a key rotation). The returned string
1786    /// must equal the on-disk bytes exactly.
1787    #[test]
1788    fn state_encryption_read_file_enabled_returns_plain_when_not_encrypted() {
1789        let td = tempdir().expect("tempdir");
1790        let path = td.path().join("plain.json");
1791
1792        // Make sure the file content is short enough that it is not even
1793        // a valid base64 encoding of a full salt+nonce+tag payload.
1794        let plain = r#"{"unencrypted": "legacy state"}"#;
1795        std::fs::write(&path, plain).expect("write plain");
1796
1797        let config = EncryptionConfig::new("any-pass".to_string());
1798        let encryption = StateEncryption::new(config).expect("create");
1799
1800        let content = encryption.read_file(&path).expect("read");
1801        assert_eq!(content, plain);
1802    }
1803
1804    // ── Coverage gap: read_file enabled but read_to_string fails ────────
1805
1806    /// Invariant: when encryption is enabled and the file cannot be read as a
1807    /// UTF-8 string at all (e.g., contains lone high bytes), the wrapper must
1808    /// surface the IO/decoding error rather than silently returning empty data.
1809    #[test]
1810    fn state_encryption_read_file_enabled_non_utf8_file_errors() {
1811        let td = tempdir().expect("tempdir");
1812        let path = td.path().join("garbage.bin");
1813
1814        // Write raw non-UTF-8 bytes directly. The file is NOT a valid base64
1815        // ciphertext under our wrapper, and `read_to_string` will fail.
1816        std::fs::write(&path, [0xFFu8, 0xFE, 0xFD, 0x80, 0xC0]).expect("write");
1817
1818        let config = EncryptionConfig::new("any-pass".to_string());
1819        let encryption = StateEncryption::new(config).expect("create");
1820
1821        let result = encryption.read_file(&path);
1822        assert!(
1823            result.is_err(),
1824            "non-UTF-8 file contents must surface as Err when encryption enabled"
1825        );
1826        let err = result.unwrap_err().to_string();
1827        assert!(
1828            err.contains("failed to read file"),
1829            "error should mention failing read context, got: {err}"
1830        );
1831    }
1832
1833    // ── Coverage gap: enabled config with no passphrase source ──────────
1834
1835    /// Invariant: `EncryptionConfig::get_passphrase` returns `Ok(None)` when
1836    /// the config has neither an inline passphrase nor an env var. The
1837    /// surrounding code uses this to decide whether to require a passphrase
1838    /// before encrypting; surfacing `None` (not `Err`, not a default key) is
1839    /// the security-critical contract.
1840    #[test]
1841    fn encryption_config_enabled_no_source_returns_none_passphrase() {
1842        let cfg = EncryptionConfig {
1843            enabled: true,
1844            passphrase: None,
1845            env_var: None,
1846        };
1847        assert_eq!(cfg.get_passphrase().unwrap(), None);
1848    }
1849
1850    /// Invariant: `EncryptionConfig::get_passphrase` returns `Ok(None)` (not
1851    /// `Err`) when an env_var is configured but unset. This lets callers
1852    /// distinguish "missing passphrase" from "lookup failure".
1853    #[test]
1854    #[serial]
1855    fn encryption_config_env_var_unset_returns_none_not_err() {
1856        let cfg = EncryptionConfig::from_env("SHIPPER_TEST_UNSET_PASS_X1".to_string());
1857        temp_env::with_var("SHIPPER_TEST_UNSET_PASS_X1", None::<&str>, || {
1858            let result = cfg.get_passphrase();
1859            assert!(result.is_ok());
1860            assert_eq!(result.unwrap(), None);
1861        });
1862    }
1863
1864    // ── StateEncryption::get_passphrase fall-through semantics ───────────
1865
1866    /// Invariant: when both an inline passphrase and an env_var are configured
1867    /// but the env_var is unset, `StateEncryption` falls back to the inline
1868    /// passphrase. This is required for roll-out scenarios where the env-var
1869    /// is the preferred source but not yet provisioned everywhere.
1870    #[test]
1871    #[serial]
1872    fn state_encryption_falls_back_to_inline_when_env_unset() {
1873        let config = EncryptionConfig {
1874            enabled: true,
1875            passphrase: Some("inline-fallback".to_string()),
1876            env_var: Some("SHIPPER_TEST_FALLBACK_VAR".to_string()),
1877        };
1878        let encryption = StateEncryption::new(config).expect("create");
1879
1880        temp_env::with_var("SHIPPER_TEST_FALLBACK_VAR", None::<&str>, || {
1881            assert!(encryption.is_enabled());
1882            let data = b"fallback test data";
1883            let encrypted = encryption.encrypt(data).expect("encrypt");
1884            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
1885
1886            // Must decrypt with the inline passphrase (env wasn't set)
1887            assert!(
1888                decrypt(&encrypted_str, "inline-fallback").is_ok(),
1889                "must encrypt with the inline passphrase when env is unset"
1890            );
1891        });
1892    }
1893
1894    /// Invariant: with `enabled: true` but no passphrase source whatsoever,
1895    /// `is_enabled` must return false so that callers don't think encryption
1896    /// is active when it actually has no key.
1897    #[test]
1898    fn state_encryption_is_enabled_false_when_enabled_but_no_passphrase() {
1899        let config = EncryptionConfig {
1900            enabled: true,
1901            passphrase: None,
1902            env_var: None,
1903        };
1904        let encryption = StateEncryption::new(config).expect("create");
1905        assert!(
1906            !encryption.is_enabled(),
1907            "enabled=true with no passphrase must report not-enabled"
1908        );
1909    }
1910
1911    /// Invariant: with `enabled: true` and an env_var that is currently unset,
1912    /// `is_enabled` must return false (no usable passphrase available).
1913    #[test]
1914    #[serial]
1915    fn state_encryption_is_enabled_false_when_env_var_unset_and_no_inline() {
1916        let config = EncryptionConfig {
1917            enabled: true,
1918            passphrase: None,
1919            env_var: Some("SHIPPER_TEST_NOT_SET_AT_ALL_VAR".to_string()),
1920        };
1921        let encryption = StateEncryption::new(config).expect("create");
1922        temp_env::with_var("SHIPPER_TEST_NOT_SET_AT_ALL_VAR", None::<&str>, || {
1923            assert!(!encryption.is_enabled());
1924        });
1925    }
1926
1927    /// Invariant: when `is_enabled` is false (no passphrase available), the
1928    /// `write_file` plaintext branch is taken and the on-disk bytes equal the
1929    /// input — encryption must not silently fail-open with a default key.
1930    #[test]
1931    fn state_encryption_write_file_no_passphrase_writes_plaintext() {
1932        let td = tempdir().expect("tempdir");
1933        let path = td.path().join("nopass.txt");
1934
1935        let config = EncryptionConfig {
1936            enabled: true,
1937            passphrase: None,
1938            env_var: None,
1939        };
1940        let encryption = StateEncryption::new(config).expect("create");
1941
1942        let data = b"would-be-encrypted but no passphrase";
1943        encryption.write_file(&path, data).expect("write plaintext");
1944        let on_disk = std::fs::read(&path).expect("read");
1945        assert_eq!(
1946            on_disk,
1947            data.to_vec(),
1948            "with no passphrase, write must NOT encrypt and must NOT use a default key"
1949        );
1950    }
1951
1952    // ── Coverage gap: EncryptionConfig env_var serde roundtrip ──────────
1953
1954    /// Invariant: `EncryptionConfig` deserialized from JSON containing only
1955    /// `env_var` (no inline passphrase) preserves the env_var.
1956    #[test]
1957    fn encryption_config_serde_roundtrip_env_var() {
1958        let cfg = EncryptionConfig::from_env("SHIPPER_TEST_SERDE_VAR".to_string());
1959        let json = serde_json::to_string(&cfg).expect("serialize");
1960        assert!(!json.contains("passphrase"));
1961        let de: EncryptionConfig = serde_json::from_str(&json).expect("deserialize");
1962        assert!(de.enabled);
1963        assert!(de.passphrase.is_none());
1964        assert_eq!(de.env_var.as_deref(), Some("SHIPPER_TEST_SERDE_VAR"));
1965    }
1966
1967    /// Invariant: `enabled: false` is the serde default — `EncryptionConfig`
1968    /// deserialized from `{}` is the safe (disabled) default.
1969    #[test]
1970    fn encryption_config_deserialize_empty_object_is_disabled() {
1971        let cfg: EncryptionConfig = serde_json::from_str("{}").expect("deserialize empty");
1972        assert!(!cfg.enabled);
1973        assert!(cfg.passphrase.is_none());
1974        assert!(cfg.env_var.is_none());
1975    }
1976
1977    // ── PBKDF2 constants invariant (security-critical pins) ─────────────
1978
1979    /// Invariant pin: PBKDF2 iterations is 100,000. Reducing this lowers the
1980    /// cost of an offline brute-force attack; any change should be a conscious,
1981    /// reviewed action — this test is the trip-wire.
1982    #[test]
1983    fn pbkdf2_iteration_count_pinned_at_100k() {
1984        assert_eq!(
1985            PBKDF2_ITERATIONS, 100_000,
1986            "PBKDF2 iteration count is a security parameter; \
1987             do not change without explicit security review"
1988        );
1989    }
1990
1991    /// Invariant pin: derived key size is 256 bits (32 bytes), as required by
1992    /// AES-256-GCM. Any other value would break the cipher construction.
1993    #[test]
1994    fn key_size_pinned_at_32_bytes() {
1995        assert_eq!(KEY_SIZE, 32, "AES-256 requires a 256-bit (32-byte) key");
1996    }
1997
1998    /// Invariant pin: AES-GCM nonce is exactly 12 bytes (96 bits), per the
1999    /// recommended GCM nonce size. Anything else would either break interop
2000    /// or weaken the construction.
2001    #[test]
2002    fn nonce_size_pinned_at_12_bytes() {
2003        assert_eq!(NONCE_SIZE, 12, "AES-GCM standard nonce size is 96 bits");
2004    }
2005
2006    /// Invariant pin: salt is at least 128 bits (16 bytes). This is the
2007    /// minimum size that prevents practical precomputation attacks against
2008    /// PBKDF2.
2009    #[test]
2010    fn salt_size_pinned_at_16_bytes() {
2011        assert_eq!(SALT_SIZE, 16, "PBKDF2 salt should be at least 128 bits");
2012    }
2013
2014    // ── Format compatibility invariant ──────────────────────────────────
2015
2016    /// Invariant: the encrypted payload layout is exactly
2017    /// `salt(16) || nonce(12) || ciphertext(N) || tag(16)`. This test pins
2018    /// the on-disk format so a future refactor that reorders the components
2019    /// fails fast. (The existing `encrypted_output_has_expected_structure`
2020    /// test pins the total length; this one pins each component's position
2021    /// by re-assembling the payload manually and decrypting with our cipher.)
2022    #[test]
2023    fn ciphertext_layout_is_salt_nonce_ciphertext_tag() {
2024        let plaintext = b"layout pin";
2025        let passphrase = "layout-pass";
2026        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
2027        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2028        let raw = BASE64.decode(&encrypted_str).expect("base64");
2029
2030        // Layout: salt(16) | nonce(12) | ciphertext + tag(plaintext.len()+16)
2031        assert!(raw.len() >= SALT_SIZE + NONCE_SIZE + 16);
2032        let salt = &raw[..SALT_SIZE];
2033        let nonce_bytes = &raw[SALT_SIZE..SALT_SIZE + NONCE_SIZE];
2034        let ct_tag = &raw[SALT_SIZE + NONCE_SIZE..];
2035
2036        // Manually derive the key from the extracted salt and decrypt.
2037        let key = derive_key(passphrase, salt);
2038        let cipher = Aes256Gcm::new_from_slice(&key).expect("cipher");
2039        let nonce = Nonce::from_slice(nonce_bytes);
2040        let decrypted = cipher
2041            .decrypt(nonce, ct_tag)
2042            .expect("manual decrypt should succeed if layout matches");
2043        assert_eq!(decrypted, plaintext);
2044
2045        // ciphertext-plus-tag must be plaintext_len + 16 (GCM tag)
2046        assert_eq!(ct_tag.len(), plaintext.len() + 16);
2047    }
2048
2049    /// Invariant: a ciphertext produced by the public `encrypt` API today
2050    /// remains decryptable by the public `decrypt` API even if the
2051    /// intermediate representation is parsed and re-encoded byte-for-byte.
2052    /// This guards against silent base64 alphabet/padding regressions.
2053    #[test]
2054    fn ciphertext_survives_base64_decode_reencode_cycle() {
2055        let plaintext = b"base64 stability pin";
2056        let passphrase = "b64-stability";
2057        let encrypted = encrypt(plaintext, passphrase).expect("encrypt");
2058        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2059
2060        // Decode and re-encode using the same engine — must round-trip and
2061        // still decrypt successfully.
2062        let raw = BASE64.decode(&encrypted_str).expect("base64 decode");
2063        let reencoded = BASE64.encode(&raw);
2064        assert_eq!(reencoded, encrypted_str, "base64 round-trip must be stable");
2065
2066        let decrypted = decrypt(&reencoded, passphrase).expect("decrypt");
2067        assert_eq!(decrypted, plaintext);
2068    }
2069
2070    // ── is_encrypted byte-boundary checks ───────────────────────────────
2071
2072    /// Invariant: `is_encrypted` returns false for base64 whose decoded length
2073    /// is exactly one byte below the minimum (salt + nonce + 16). This is the
2074    /// boundary that separates "could be a valid GCM payload" from "definitely
2075    /// can't be".
2076    #[test]
2077    fn is_encrypted_rejects_one_byte_below_minimum() {
2078        let data = vec![0u8; SALT_SIZE + NONCE_SIZE + 15];
2079        let encoded = BASE64.encode(&data);
2080        assert!(!is_encrypted(&encoded));
2081    }
2082
2083    // ── StateEncryption::decrypt over actual encrypted bytes ────────────
2084
2085    /// Invariant: `StateEncryption::decrypt` round-trips an encrypted Vec<u8>
2086    /// produced by the same wrapper via env-var, including when the env-var
2087    /// is used to source the key.
2088    #[test]
2089    #[serial]
2090    fn state_encryption_env_var_decrypt_handles_encrypted_bytes() {
2091        let config = EncryptionConfig::from_env("SHIPPER_TEST_DEC_VAR".to_string());
2092        let encryption = StateEncryption::new(config).expect("create");
2093
2094        temp_env::with_var("SHIPPER_TEST_DEC_VAR", Some("dec-env-pass"), || {
2095            let data = b"env decrypt cycle";
2096            let encrypted = encryption.encrypt(data).expect("encrypt");
2097            let decrypted = encryption.decrypt(&encrypted).expect("decrypt");
2098            assert_eq!(decrypted, data);
2099        });
2100    }
2101
2102    // ── mask_passphrase grapheme behavior ───────────────────────────────
2103
2104    /// Invariant: `mask_passphrase` masks by Unicode scalar count, not byte
2105    /// count. A 3-char ASCII passphrase produces `*` (1 char) between the
2106    /// two anchor chars; a multi-byte 3-char passphrase must produce the
2107    /// same single-`*` shape.
2108    #[test]
2109    fn mask_passphrase_uses_char_count_not_byte_count() {
2110        // Three Unicode scalars, but multiple bytes each.
2111        let masked = mask_passphrase("αβγ");
2112        // Format: first + repeat(chars.len()-2) of '*' + last = "α" + "*" + "γ"
2113        assert_eq!(masked.chars().count(), 3);
2114        assert!(masked.starts_with('α'));
2115        assert!(masked.ends_with('γ'));
2116        assert!(masked.contains('*'));
2117    }
2118
2119    /// Invariant: empty passphrases mask to a single `*` (never empty), so
2120    /// they can't be confused with an absent passphrase in log output.
2121    #[test]
2122    fn mask_passphrase_empty_produces_one_asterisk() {
2123        assert_eq!(mask_passphrase(""), "*");
2124    }
2125
2126    // ── Negative test: never leak plaintext in masked output ────────────
2127
2128    /// Invariant: masked passphrases must not contain any middle characters
2129    /// from the original passphrase (only the first and last). This is the
2130    /// "don't leak secrets in logs" contract.
2131    #[test]
2132    fn mask_passphrase_does_not_leak_middle_characters() {
2133        let secret = "DO-NOT-LEAK-MIDDLE";
2134        let masked = mask_passphrase(secret);
2135        let secret_chars: Vec<char> = secret.chars().collect();
2136        let masked_chars: Vec<char> = masked.chars().collect();
2137
2138        assert_eq!(masked_chars.len(), secret_chars.len());
2139        assert_eq!(masked_chars.first(), secret_chars.first());
2140        assert_eq!(masked_chars.last(), secret_chars.last());
2141        assert!(
2142            masked_chars[1..masked_chars.len() - 1]
2143                .iter()
2144                .all(|ch| *ch == '*'),
2145            "masked output {masked:?} leaked middle characters"
2146        );
2147    }
2148}
2149
2150// ── Property-based tests ────────────────────────────────────────────────
2151
2152#[cfg(test)]
2153mod proptests {
2154    use super::*;
2155    use proptest::prelude::*;
2156
2157    proptest! {
2158        #[test]
2159        fn roundtrip_arbitrary_data(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
2160            let passphrase = "prop-test-pass";
2161            let encrypted = encrypt(&data, passphrase).expect("encrypt");
2162            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2163            let decrypted = decrypt(&encrypted_str, passphrase).expect("decrypt");
2164            prop_assert_eq!(data, decrypted);
2165        }
2166
2167        #[test]
2168        fn roundtrip_arbitrary_passphrase(passphrase in "\\PC{1,200}") {
2169            let plaintext = b"fixed plaintext for passphrase fuzz";
2170            let encrypted = encrypt(plaintext, &passphrase).expect("encrypt");
2171            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2172            let decrypted = decrypt(&encrypted_str, &passphrase).expect("decrypt");
2173            prop_assert_eq!(plaintext.to_vec(), decrypted);
2174        }
2175
2176        #[test]
2177        fn roundtrip_arbitrary_data_and_passphrase(
2178            data in proptest::collection::vec(any::<u8>(), 0..1024),
2179            passphrase in "\\PC{1,100}",
2180        ) {
2181            let encrypted = encrypt(&data, &passphrase).expect("encrypt");
2182            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2183            let decrypted = decrypt(&encrypted_str, &passphrase).expect("decrypt");
2184            prop_assert_eq!(data, decrypted);
2185        }
2186
2187        #[test]
2188        fn encrypted_output_is_valid_base64(data in proptest::collection::vec(any::<u8>(), 0..512)) {
2189            let encrypted = encrypt(&data, "test").expect("encrypt");
2190            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2191            prop_assert!(BASE64.decode(&encrypted_str).is_ok());
2192        }
2193
2194        #[test]
2195        fn wrong_passphrase_always_fails(
2196            data in proptest::collection::vec(any::<u8>(), 1..512),
2197            correct in "[a-z]{8,16}",
2198            wrong in "[A-Z]{8,16}",
2199        ) {
2200            // Ensure passphrases actually differ
2201            prop_assume!(correct != wrong);
2202            let encrypted = encrypt(&data, &correct).expect("encrypt");
2203            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2204            prop_assert!(decrypt(&encrypted_str, &wrong).is_err());
2205        }
2206
2207        #[test]
2208        fn encrypted_size_is_deterministic(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
2209            let encrypted = encrypt(&data, "pass").expect("encrypt");
2210            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2211            let raw = BASE64.decode(&encrypted_str).expect("base64");
2212            // salt(16) + nonce(12) + plaintext_len + gcm_tag(16)
2213            let expected = SALT_SIZE + NONCE_SIZE + data.len() + 16;
2214            prop_assert_eq!(raw.len(), expected);
2215        }
2216
2217        #[test]
2218        fn is_encrypted_true_for_encrypt_output(data in proptest::collection::vec(any::<u8>(), 0..512)) {
2219            let encrypted = encrypt(&data, "test-pass").expect("encrypt");
2220            let encrypted_str = String::from_utf8(encrypted).expect("valid UTF-8");
2221            prop_assert!(is_encrypted(&encrypted_str));
2222        }
2223
2224        #[test]
2225        fn is_encrypted_never_panics(s in "\\PC{0,500}") {
2226            let _ = is_encrypted(&s);
2227        }
2228
2229        #[test]
2230        fn decrypt_arbitrary_string_never_panics(s in "\\PC{0,500}") {
2231            let _ = decrypt(&s, "passphrase");
2232        }
2233
2234        #[test]
2235        fn encrypt_output_is_always_utf8(
2236            data in proptest::collection::vec(any::<u8>(), 0..1024),
2237            passphrase in "\\PC{1,50}",
2238        ) {
2239            let encrypted = encrypt(&data, &passphrase).expect("encrypt");
2240            prop_assert!(String::from_utf8(encrypted).is_ok());
2241        }
2242
2243        #[test]
2244        fn each_encrypt_produces_unique_ciphertext(data in proptest::collection::vec(any::<u8>(), 0..256)) {
2245            let a = encrypt(&data, "same-pass").expect("encrypt");
2246            let b = encrypt(&data, "same-pass").expect("encrypt");
2247            prop_assert_ne!(a, b);
2248        }
2249
2250        #[test]
2251        fn encryption_config_serde_roundtrip_arbitrary(passphrase in "\\PC{1,100}") {
2252            let cfg = EncryptionConfig::new(passphrase.clone());
2253            let json = serde_json::to_string(&cfg).expect("serialize");
2254            let de: EncryptionConfig = serde_json::from_str(&json).expect("deserialize");
2255            prop_assert_eq!(de.enabled, true);
2256            prop_assert_eq!(de.passphrase.as_deref(), Some(passphrase.as_str()));
2257        }
2258
2259        #[test]
2260        fn state_encryption_roundtrip_arbitrary(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
2261            let config = EncryptionConfig::new("state-prop-pass".to_string());
2262            let se = StateEncryption::new(config).expect("create");
2263            let encrypted = se.encrypt(&data).expect("encrypt");
2264            let decrypted = se.decrypt(&encrypted).expect("decrypt");
2265            prop_assert_eq!(data, decrypted);
2266        }
2267
2268        #[test]
2269        fn encryption_output_always_longer_than_input(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
2270            let encrypted = encrypt(&data, "length-test").expect("encrypt");
2271            // Encrypted output (base64 of salt+nonce+ciphertext+tag) is always longer than plaintext
2272            prop_assert!(encrypted.len() > data.len());
2273        }
2274
2275        #[test]
2276        fn tampered_ciphertext_always_fails(data in proptest::collection::vec(any::<u8>(), 1..512)) {
2277            let passphrase = "tamper-prop-test";
2278            let encrypted = encrypt(&data, passphrase).expect("encrypt");
2279            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2280
2281            let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2282            // Flip a byte in the ciphertext region (after salt+nonce)
2283            let idx = SALT_SIZE + NONCE_SIZE + (raw.len() - SALT_SIZE - NONCE_SIZE) / 2;
2284            raw[idx] ^= 0xFF;
2285            let corrupted = BASE64.encode(&raw);
2286
2287            prop_assert!(decrypt(&corrupted, passphrase).is_err());
2288        }
2289
2290        #[test]
2291        fn derive_key_always_produces_32_bytes_prop(
2292            passphrase in "\\PC{0,200}",
2293            salt in proptest::collection::vec(any::<u8>(), 0..64),
2294        ) {
2295            let key = derive_key(&passphrase, &salt);
2296            prop_assert_eq!(key.len(), KEY_SIZE);
2297        }
2298
2299        #[test]
2300        fn decrypt_truncated_ciphertext_always_fails_prop(
2301            data in proptest::collection::vec(any::<u8>(), 1..512),
2302            trim in 1usize..17,
2303        ) {
2304            let passphrase = "truncation-prop";
2305            let encrypted = encrypt(&data, passphrase).expect("encrypt");
2306            let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2307            let raw = BASE64.decode(&encrypted_str).expect("base64");
2308
2309            // Trim `trim` bytes off the end (corrupts auth tag or ciphertext)
2310            if raw.len() > SALT_SIZE + NONCE_SIZE + 16 {
2311                let truncated = &raw[..raw.len() - trim];
2312                if truncated.len() >= SALT_SIZE + NONCE_SIZE + 16 {
2313                    let encoded = BASE64.encode(truncated);
2314                    prop_assert!(decrypt(&encoded, passphrase).is_err());
2315                }
2316            }
2317        }
2318
2319        #[test]
2320        fn derive_key_deterministic_prop(
2321            passphrase in "\\PC{0,100}",
2322            salt in proptest::collection::vec(any::<u8>(), 0..32),
2323        ) {
2324            let key1 = derive_key(&passphrase, &salt);
2325            let key2 = derive_key(&passphrase, &salt);
2326            prop_assert_eq!(key1, key2, "derive_key must be deterministic");
2327        }
2328
2329        #[test]
2330        fn salt_differs_across_encryptions_prop(data in proptest::collection::vec(any::<u8>(), 0..256)) {
2331            let a = encrypt(&data, "same-pass").expect("encrypt");
2332            let b = encrypt(&data, "same-pass").expect("encrypt");
2333            let raw_a = BASE64.decode(String::from_utf8(a).expect("utf8")).expect("base64");
2334            let raw_b = BASE64.decode(String::from_utf8(b).expect("utf8")).expect("base64");
2335            let salt_a = &raw_a[..SALT_SIZE];
2336            let salt_b = &raw_b[..SALT_SIZE];
2337            prop_assert_ne!(salt_a.to_vec(), salt_b.to_vec(), "salts must differ");
2338        }
2339
2340        #[test]
2341        fn double_encrypt_roundtrip_prop(data in proptest::collection::vec(any::<u8>(), 0..256)) {
2342            let pass1 = "layer-one";
2343            let pass2 = "layer-two";
2344            let enc1 = encrypt(&data, pass1).expect("encrypt 1");
2345            let enc2 = encrypt(&enc1, pass2).expect("encrypt 2");
2346            let enc2_str = String::from_utf8(enc2).expect("utf8");
2347            let dec2 = decrypt(&enc2_str, pass2).expect("decrypt 2");
2348            let dec2_str = String::from_utf8(dec2).expect("utf8");
2349            let dec1 = decrypt(&dec2_str, pass1).expect("decrypt 1");
2350            prop_assert_eq!(data, dec1);
2351        }
2352    }
2353}
2354
2355// ── Snapshot tests ──────────────────────────────────────────────────────
2356
2357#[cfg(test)]
2358mod snapshot_tests {
2359    use super::*;
2360    use insta::{assert_debug_snapshot, assert_snapshot};
2361
2362    // ── EncryptionConfig serialization ──────────────────────────────────
2363
2364    #[test]
2365    fn config_default_json() {
2366        let cfg = EncryptionConfig::default();
2367        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
2368        assert_snapshot!(json);
2369    }
2370
2371    #[test]
2372    fn config_with_passphrase_json() {
2373        let cfg = EncryptionConfig::new("my-secret".to_string());
2374        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
2375        assert_snapshot!(json);
2376    }
2377
2378    #[test]
2379    fn config_with_env_var_json() {
2380        let cfg = EncryptionConfig::from_env("SHIPPER_ENCRYPT_KEY".to_string());
2381        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
2382        assert_snapshot!(json);
2383    }
2384
2385    #[test]
2386    fn config_enabled_no_passphrase_json() {
2387        let cfg = EncryptionConfig {
2388            enabled: true,
2389            passphrase: None,
2390            env_var: None,
2391        };
2392        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
2393        assert_snapshot!(json);
2394    }
2395
2396    #[test]
2397    fn config_with_both_passphrase_and_env_json() {
2398        let cfg = EncryptionConfig {
2399            enabled: true,
2400            passphrase: Some("inline-pass".to_string()),
2401            env_var: Some("SHIPPER_ENCRYPT_KEY".to_string()),
2402        };
2403        let json = serde_json::to_string_pretty(&cfg).expect("serialize");
2404        assert_snapshot!(json);
2405    }
2406
2407    // ── Masked token format ─────────────────────────────────────────────
2408
2409    #[test]
2410    fn mask_passphrase_normal() {
2411        assert_snapshot!(mask_passphrase("my-secret-passphrase"));
2412    }
2413
2414    #[test]
2415    fn mask_passphrase_short_three_chars() {
2416        assert_snapshot!(mask_passphrase("abc"));
2417    }
2418
2419    #[test]
2420    fn mask_passphrase_two_chars() {
2421        assert_snapshot!(mask_passphrase("ab"));
2422    }
2423
2424    #[test]
2425    fn mask_passphrase_single_char() {
2426        assert_snapshot!(mask_passphrase("x"));
2427    }
2428
2429    #[test]
2430    fn mask_passphrase_empty() {
2431        assert_snapshot!(mask_passphrase(""));
2432    }
2433
2434    #[test]
2435    fn mask_passphrase_unicode() {
2436        assert_snapshot!(mask_passphrase("🔑secret🔒"));
2437    }
2438
2439    // ── StateEncryption config display ──────────────────────────────────
2440
2441    #[test]
2442    fn display_config_disabled() {
2443        let cfg = EncryptionConfig::default();
2444        assert_snapshot!(cfg.to_string());
2445    }
2446
2447    #[test]
2448    fn display_config_with_passphrase() {
2449        let cfg = EncryptionConfig::new("super-secret-key".to_string());
2450        assert_snapshot!(cfg.to_string());
2451    }
2452
2453    #[test]
2454    fn display_config_with_env_var() {
2455        let cfg = EncryptionConfig::from_env("SHIPPER_ENCRYPT_KEY".to_string());
2456        assert_snapshot!(cfg.to_string());
2457    }
2458
2459    #[test]
2460    fn display_config_enabled_no_source() {
2461        let cfg = EncryptionConfig {
2462            enabled: true,
2463            passphrase: None,
2464            env_var: None,
2465        };
2466        assert_snapshot!(cfg.to_string());
2467    }
2468
2469    #[test]
2470    fn display_state_encryption_wrapper() {
2471        let cfg = EncryptionConfig::new("my-passphrase".to_string());
2472        let se = StateEncryption::new(cfg).expect("create");
2473        assert_snapshot!(se.to_string());
2474    }
2475
2476    // ── Decryption failure error messages ───────────────────────────────
2477
2478    #[test]
2479    fn error_invalid_base64() {
2480        let err = decrypt("not-valid-base64!!!", "pass").unwrap_err();
2481        assert_snapshot!(err.to_string());
2482    }
2483
2484    #[test]
2485    fn error_data_too_short() {
2486        let short = BASE64.encode(vec![0u8; SALT_SIZE + NONCE_SIZE + 15]);
2487        let err = decrypt(&short, "pass").unwrap_err();
2488        assert_snapshot!(err.to_string());
2489    }
2490
2491    #[test]
2492    fn error_wrong_passphrase() {
2493        let encrypted = encrypt(b"secret data", "correct-pass").expect("encrypt");
2494        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2495        let err = decrypt(&encrypted_str, "wrong-pass").unwrap_err();
2496        assert_snapshot!(err.to_string());
2497    }
2498
2499    #[test]
2500    fn error_corrupted_ciphertext() {
2501        let encrypted = encrypt(b"data", "pass").expect("encrypt");
2502        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2503        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2504        raw[SALT_SIZE + NONCE_SIZE + 1] ^= 0xFF;
2505        let corrupted = BASE64.encode(&raw);
2506        let err = decrypt(&corrupted, "pass").unwrap_err();
2507        assert_snapshot!(err.to_string());
2508    }
2509
2510    #[test]
2511    fn error_empty_input() {
2512        let err = decrypt("", "pass").unwrap_err();
2513        assert_snapshot!(err.to_string());
2514    }
2515
2516    // ── Snapshot: error types for tampered regions ──────────────────────
2517
2518    #[test]
2519    fn error_corrupted_salt_message() {
2520        let encrypted = encrypt(b"snapshot salt", "pass").expect("encrypt");
2521        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2522        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2523        raw[0] ^= 0xFF;
2524        let corrupted = BASE64.encode(&raw);
2525        let err = decrypt(&corrupted, "pass").unwrap_err();
2526        assert_snapshot!(err.to_string());
2527    }
2528
2529    #[test]
2530    fn error_corrupted_nonce_message() {
2531        let encrypted = encrypt(b"snapshot nonce", "pass").expect("encrypt");
2532        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2533        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2534        raw[SALT_SIZE] ^= 0xFF;
2535        let corrupted = BASE64.encode(&raw);
2536        let err = decrypt(&corrupted, "pass").unwrap_err();
2537        assert_snapshot!(err.to_string());
2538    }
2539
2540    #[test]
2541    fn error_corrupted_auth_tag_message() {
2542        let encrypted = encrypt(b"snapshot tag", "pass").expect("encrypt");
2543        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2544        let mut raw = BASE64.decode(&encrypted_str).expect("base64");
2545        let last = raw.len() - 1;
2546        raw[last] ^= 0xFF;
2547        let corrupted = BASE64.encode(&raw);
2548        let err = decrypt(&corrupted, "pass").unwrap_err();
2549        assert_snapshot!(err.to_string());
2550    }
2551
2552    // ── Snapshot: key generation output format ──────────────────────────
2553
2554    #[test]
2555    fn snapshot_derive_key_output_format() {
2556        let key = derive_key("test-passphrase", &[0u8; SALT_SIZE]);
2557        // Snapshot the hex-encoded key to verify deterministic output format
2558        let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
2559        assert_snapshot!(hex);
2560    }
2561
2562    #[test]
2563    fn snapshot_derive_key_length() {
2564        let key = derive_key("any-passphrase", &[42u8; SALT_SIZE]);
2565        assert_debug_snapshot!(key.len());
2566    }
2567
2568    // ── Snapshot: EncryptionConfig Debug output ─────────────────────────
2569
2570    #[test]
2571    fn snapshot_encryption_config_debug_default() {
2572        let cfg = EncryptionConfig::default();
2573        assert_debug_snapshot!(cfg);
2574    }
2575
2576    #[test]
2577    fn snapshot_encryption_config_debug_with_passphrase() {
2578        let cfg = EncryptionConfig::new("debug-pass".to_string());
2579        assert_debug_snapshot!(cfg);
2580    }
2581
2582    #[test]
2583    fn snapshot_encryption_config_debug_from_env() {
2584        let cfg = EncryptionConfig::from_env("MY_SECRET_VAR".to_string());
2585        assert_debug_snapshot!(cfg);
2586    }
2587
2588    // ── Snapshot: encrypted output structure ─────────────────────────────
2589
2590    #[test]
2591    fn snapshot_encrypted_data_component_sizes() {
2592        let plaintext = b"snapshot-structure-test";
2593        let encrypted = encrypt(plaintext, "snap-pass").expect("encrypt");
2594        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2595        let raw = BASE64.decode(&encrypted_str).expect("base64");
2596
2597        let info = format!(
2598            "salt_bytes={}, nonce_bytes={}, ciphertext_plus_tag_bytes={}, plaintext_len={}, overhead={}",
2599            SALT_SIZE,
2600            NONCE_SIZE,
2601            raw.len() - SALT_SIZE - NONCE_SIZE,
2602            plaintext.len(),
2603            raw.len() - plaintext.len(),
2604        );
2605        assert_snapshot!(info);
2606    }
2607
2608    #[test]
2609    fn snapshot_derive_key_alternate_passphrase() {
2610        let key = derive_key("alternate-passphrase-for-snapshot", &[0xAB; SALT_SIZE]);
2611        let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
2612        assert_snapshot!(hex);
2613    }
2614
2615    #[test]
2616    fn snapshot_is_encrypted_results() {
2617        let results = format!(
2618            "empty={}, json={}, short_b64={}, garbage={}",
2619            is_encrypted(""),
2620            is_encrypted(r#"{"key":"value"}"#),
2621            is_encrypted(&BASE64.encode(vec![0u8; 10])),
2622            is_encrypted("!!!not-base64!!!"),
2623        );
2624        assert_snapshot!(results);
2625    }
2626
2627    // ── Snapshot: StateEncryption no-passphrase error ────────────────────
2628
2629    #[test]
2630    fn snapshot_state_encryption_no_passphrase_error() {
2631        let config = EncryptionConfig {
2632            enabled: true,
2633            passphrase: None,
2634            env_var: None,
2635        };
2636        let encryption = StateEncryption::new(config).expect("create");
2637        let err = encryption.encrypt(b"data").unwrap_err();
2638        assert_snapshot!(err.to_string());
2639    }
2640
2641    // ── Snapshot: Display with both passphrase and env_var ───────────────
2642
2643    #[test]
2644    fn snapshot_display_config_with_both_sources() {
2645        let cfg = EncryptionConfig {
2646            enabled: true,
2647            passphrase: Some("inline-secret".to_string()),
2648            env_var: Some("SHIPPER_KEY".to_string()),
2649        };
2650        assert_snapshot!(cfg.to_string());
2651    }
2652
2653    // ── Snapshot: mask_passphrase additional lengths ─────────────────────
2654
2655    #[test]
2656    fn snapshot_mask_passphrase_four_chars() {
2657        assert_snapshot!(mask_passphrase("abcd"));
2658    }
2659
2660    #[test]
2661    fn snapshot_mask_passphrase_with_spaces() {
2662        assert_snapshot!(mask_passphrase("a b c d"));
2663    }
2664
2665    #[test]
2666    fn snapshot_mask_passphrase_with_newline() {
2667        assert_snapshot!(mask_passphrase("pass\nword"));
2668    }
2669
2670    // ── Snapshot: encrypted output overhead for empty plaintext ──────────
2671
2672    #[test]
2673    fn snapshot_encrypted_empty_plaintext_structure() {
2674        let encrypted = encrypt(b"", "snap-pass").expect("encrypt");
2675        let encrypted_str = String::from_utf8(encrypted).expect("utf8");
2676        let raw = BASE64.decode(&encrypted_str).expect("base64");
2677
2678        let info = format!(
2679            "raw_len={}, salt={}, nonce={}, ciphertext_plus_tag={}, plaintext_len=0",
2680            raw.len(),
2681            SALT_SIZE,
2682            NONCE_SIZE,
2683            raw.len() - SALT_SIZE - NONCE_SIZE,
2684        );
2685        assert_snapshot!(info);
2686    }
2687}