Skip to main content

coding_agent_search/pages/
encrypt.rs

1//! Encryption engine for pages export.
2//!
3//! Implements envelope encryption with:
4//! - Argon2id key derivation for passwords
5//! - HKDF-SHA256 for recovery secrets
6//! - AES-256-GCM authenticated encryption
7//! - Streaming encryption for large files
8//! - Multiple key slots (like LUKS)
9
10use aes_gcm::{
11    Aes256Gcm, Nonce,
12    aead::{Aead, KeyInit, Payload},
13};
14use anyhow::{Context, Result, bail};
15use argon2::{
16    Algorithm, Argon2, Params, Version,
17    password_hash::{SaltString, rand_core::OsRng as PasswordHashOsRng},
18};
19use base64::prelude::*;
20use flate2::{Compression, read::DeflateDecoder, write::DeflateEncoder};
21use rand::Rng;
22use serde::{Deserialize, Serialize};
23use std::fs::{File, OpenOptions};
24use std::io::{BufReader, BufWriter, Read, Write};
25use std::path::{Path, PathBuf};
26use zeroize::{Zeroize, ZeroizeOnDrop};
27
28#[derive(Debug, thiserror::Error)]
29#[error("{0}")]
30struct AeadSourceError(aes_gcm::Error);
31
32/// Default chunk size for streaming encryption (8 MiB)
33pub const DEFAULT_CHUNK_SIZE: usize = 8 * 1024 * 1024;
34
35/// Maximum chunk size (32 MiB)
36pub const MAX_CHUNK_SIZE: usize = 32 * 1024 * 1024;
37
38const MAX_ARCHIVE_CHUNKS: u64 = u32::MAX as u64;
39
40fn max_encryptable_plaintext_bytes(chunk_size: usize) -> u64 {
41    MAX_ARCHIVE_CHUNKS.saturating_mul(chunk_size as u64)
42}
43
44fn ensure_archive_chunk_count_fits_nonce_space(chunk_count: u64, chunk_size: usize) -> Result<()> {
45    if chunk_count > MAX_ARCHIVE_CHUNKS {
46        bail!(
47            "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
48            u32::MAX,
49            max_encryptable_plaintext_bytes(chunk_size)
50        );
51    }
52    Ok(())
53}
54
55fn ensure_can_write_archive_chunk(chunk_index: u32, chunk_size: usize) -> Result<()> {
56    if chunk_index == u32::MAX {
57        bail!(
58            "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
59            u32::MAX,
60            max_encryptable_plaintext_bytes(chunk_size)
61        );
62    }
63    Ok(())
64}
65
66/// Argon2id parameters (from Phase 2 spec)
67#[cfg(not(test))]
68const ARGON2_MEMORY_KB: u32 = 65536; // 64 MB
69#[cfg(test)]
70const ARGON2_MEMORY_KB: u32 = 64;
71#[cfg(not(test))]
72const ARGON2_ITERATIONS: u32 = 3;
73#[cfg(test)]
74const ARGON2_ITERATIONS: u32 = 1;
75#[cfg(not(test))]
76const ARGON2_PARALLELISM: u32 = 4;
77#[cfg(test)]
78const ARGON2_PARALLELISM: u32 = 1;
79
80/// Encryption schema version
81pub(crate) const SCHEMA_VERSION: u8 = 2;
82
83/// Secret key material that zeros on drop
84#[derive(Clone, Zeroize, ZeroizeOnDrop)]
85pub struct SecretKey([u8; 32]);
86
87impl SecretKey {
88    pub fn random() -> Self {
89        let mut key = [0u8; 32];
90        let mut rng = rand::rng();
91        rng.fill_bytes(&mut key);
92        Self(key)
93    }
94
95    pub fn from_bytes(bytes: [u8; 32]) -> Self {
96        Self(bytes)
97    }
98
99    pub fn as_bytes(&self) -> &[u8; 32] {
100        &self.0
101    }
102}
103
104/// Key slot type
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum SlotType {
108    Password,
109    Recovery,
110}
111
112/// KDF algorithm identifier
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum KdfAlgorithm {
116    Argon2id,
117    HkdfSha256,
118}
119
120/// Key slot in config.json
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct KeySlot {
124    pub id: u8,
125    pub slot_type: SlotType,
126    pub kdf: KdfAlgorithm,
127    pub salt: String,        // base64-encoded
128    pub wrapped_dek: String, // base64-encoded
129    pub nonce: String,       // base64-encoded (for DEK wrapping)
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub argon2_params: Option<Argon2Params>,
132}
133
134/// Argon2 parameters for config.json
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct Argon2Params {
138    pub memory_kb: u32,
139    pub iterations: u32,
140    pub parallelism: u32,
141}
142
143impl Default for Argon2Params {
144    fn default() -> Self {
145        Self {
146            memory_kb: ARGON2_MEMORY_KB,
147            iterations: ARGON2_ITERATIONS,
148            parallelism: ARGON2_PARALLELISM,
149        }
150    }
151}
152
153/// Payload metadata in config.json
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct PayloadMeta {
157    pub chunk_size: usize,
158    pub chunk_count: usize,
159    pub total_compressed_size: u64,
160    pub total_plaintext_size: u64,
161    pub files: Vec<String>,
162}
163
164/// Full config.json structure
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct EncryptionConfig {
168    pub version: u8,
169    pub export_id: String,  // base64-encoded 16 bytes
170    pub base_nonce: String, // base64-encoded 12 bytes
171    pub compression: String,
172    pub kdf_defaults: Argon2Params,
173    pub payload: PayloadMeta,
174    pub key_slots: Vec<KeySlot>,
175}
176
177pub(crate) fn validate_supported_payload_format(config: &EncryptionConfig) -> Result<()> {
178    if config.version != SCHEMA_VERSION {
179        bail!(
180            "Unsupported archive schema version {}; expected {}",
181            config.version,
182            SCHEMA_VERSION
183        );
184    }
185
186    if config.compression != "deflate" {
187        bail!(
188            "Unsupported archive compression '{}'. The current encrypted pages format supports only deflate.",
189            config.compression
190        );
191    }
192
193    if config.payload.chunk_size == 0 {
194        bail!("Invalid archive chunk_size 0: must be > 0");
195    }
196
197    if config.payload.chunk_size > MAX_CHUNK_SIZE {
198        bail!(
199            "Invalid archive chunk_size {}: must be <= {} bytes",
200            config.payload.chunk_size,
201            MAX_CHUNK_SIZE
202        );
203    }
204
205    if config.payload.chunk_count != config.payload.files.len() {
206        bail!(
207            "Invalid archive payload metadata: chunk_count {} does not match file list length {}",
208            config.payload.chunk_count,
209            config.payload.files.len()
210        );
211    }
212
213    if config.payload.chunk_count > u32::MAX as usize {
214        bail!(
215            "Invalid archive payload metadata: chunk_count {} exceeds maximum {}",
216            config.payload.chunk_count,
217            u32::MAX
218        );
219    }
220
221    for (index, file) in config.payload.files.iter().enumerate() {
222        if !payload_chunk_path_matches_index(file, index) {
223            return Err(invalid_payload_file_entry(index, file));
224        }
225    }
226
227    Ok(())
228}
229
230fn payload_chunk_path_matches_index(path: &str, index: usize) -> bool {
231    const PREFIX: &str = "payload/chunk-";
232    const SUFFIX: &str = ".bin";
233
234    let Some(digits) = path
235        .strip_prefix(PREFIX)
236        .and_then(|rest| rest.strip_suffix(SUFFIX))
237    else {
238        return false;
239    };
240
241    let expected_digit_count = decimal_digit_count(index).max(5);
242    if digits.len().cmp(&expected_digit_count).is_ne() {
243        return false;
244    }
245
246    let mut parsed = 0usize;
247    for byte in digits.bytes() {
248        if !byte.is_ascii_digit() {
249            return false;
250        }
251        let Some(next) = parsed
252            .checked_mul(10)
253            .and_then(|value| value.checked_add(usize::from(byte - b'0')))
254        else {
255            return false;
256        };
257        parsed = next;
258    }
259
260    parsed.cmp(&index).is_eq()
261}
262
263fn decimal_digit_count(mut value: usize) -> usize {
264    let mut count = 1;
265    while value >= 10 {
266        value /= 10;
267        count += 1;
268    }
269    count
270}
271
272fn invalid_payload_file_entry(index: usize, actual: &str) -> anyhow::Error {
273    anyhow::anyhow!(
274        "Invalid archive payload metadata: payload file entry {index} is '{actual}'; expected 'payload/chunk-{index:05}.bin'"
275    )
276}
277
278/// Encryption engine for pages export
279///
280/// `Debug` is implemented manually to avoid printing the secret DEK.
281pub struct EncryptionEngine {
282    dek: SecretKey,
283    export_id: [u8; 16],
284    base_nonce: [u8; 12],
285    chunk_size: usize,
286    key_slots: Vec<KeySlot>,
287}
288
289impl std::fmt::Debug for EncryptionEngine {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        f.debug_struct("EncryptionEngine")
292            .field("chunk_size", &self.chunk_size)
293            .field("key_slots", &self.key_slots.len())
294            .finish_non_exhaustive()
295    }
296}
297
298fn key_slot_id_for_len(slot_count: usize) -> Result<u8> {
299    u8::try_from(slot_count).map_err(|err| {
300        anyhow::anyhow!(
301            "maximum of 256 key slots exceeded ({} slots already allocated): {}",
302            slot_count,
303            err
304        )
305    })
306}
307
308impl Default for EncryptionEngine {
309    fn default() -> Self {
310        Self::new(DEFAULT_CHUNK_SIZE).expect("default chunk size must be valid")
311    }
312}
313
314impl EncryptionEngine {
315    /// Create new encryption engine with random DEK
316    pub fn new(chunk_size: usize) -> Result<Self> {
317        if chunk_size == 0 {
318            bail!("chunk_size must be > 0");
319        }
320        if chunk_size > MAX_CHUNK_SIZE {
321            bail!("chunk_size must be <= {MAX_CHUNK_SIZE} bytes");
322        }
323        let mut export_id = [0u8; 16];
324        let mut base_nonce = [0u8; 12];
325        let mut rng = rand::rng();
326        rng.fill_bytes(&mut export_id);
327        rng.fill_bytes(&mut base_nonce);
328
329        Ok(Self {
330            dek: SecretKey::random(),
331            export_id,
332            base_nonce,
333            chunk_size,
334            key_slots: Vec::new(),
335        })
336    }
337
338    /// Add a password-based key slot using Argon2id
339    pub fn add_password_slot(&mut self, password: &str) -> Result<u8> {
340        // Validate password
341        if password.is_empty() {
342            anyhow::bail!("Password cannot be empty");
343        }
344        if password.trim().is_empty() {
345            anyhow::bail!("Password cannot be whitespace-only");
346        }
347
348        let slot_id = key_slot_id_for_len(self.key_slots.len())?;
349
350        // Generate salt
351        let salt = SaltString::generate(&mut PasswordHashOsRng);
352        let salt_bytes = salt.as_str().as_bytes();
353
354        // Derive KEK from password
355        let kek = derive_kek_argon2id(password, salt_bytes)?;
356
357        // Wrap DEK with KEK
358        let (wrapped_dek, nonce) = wrap_key(&kek, self.dek.as_bytes(), &self.export_id, slot_id)?;
359
360        self.key_slots.push(KeySlot {
361            id: slot_id,
362            slot_type: SlotType::Password,
363            kdf: KdfAlgorithm::Argon2id,
364            salt: BASE64_STANDARD.encode(salt_bytes),
365            wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
366            nonce: BASE64_STANDARD.encode(nonce),
367            argon2_params: Some(Argon2Params::default()),
368        });
369
370        Ok(slot_id)
371    }
372
373    /// Add a recovery secret slot using HKDF-SHA256
374    pub fn add_recovery_slot(&mut self, secret: &[u8]) -> Result<u8> {
375        let slot_id = key_slot_id_for_len(self.key_slots.len())?;
376
377        // Generate salt
378        let mut salt = [0u8; 16];
379        let mut rng = rand::rng();
380        rng.fill_bytes(&mut salt);
381
382        // Derive KEK from recovery secret
383        let kek = derive_kek_hkdf(secret, &salt)?;
384
385        // Wrap DEK with KEK
386        let (wrapped_dek, nonce) = wrap_key(&kek, self.dek.as_bytes(), &self.export_id, slot_id)?;
387
388        self.key_slots.push(KeySlot {
389            id: slot_id,
390            slot_type: SlotType::Recovery,
391            kdf: KdfAlgorithm::HkdfSha256,
392            salt: BASE64_STANDARD.encode(salt),
393            wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
394            nonce: BASE64_STANDARD.encode(nonce),
395            argon2_params: None,
396        });
397
398        Ok(slot_id)
399    }
400
401    /// Returns the number of key slots currently configured
402    pub fn key_slot_count(&self) -> usize {
403        self.key_slots.len()
404    }
405
406    /// Encrypt a file with streaming compression and chunked AEAD
407    pub fn encrypt_file<P: AsRef<Path>>(
408        &self,
409        input: P,
410        output_dir: P,
411        progress: impl Fn(u64, u64),
412    ) -> Result<EncryptionConfig> {
413        let input_path = input.as_ref();
414        let output_dir = output_dir.as_ref();
415
416        ensure_real_archive_output_directory(output_dir, "encrypted archive output directory")?;
417        let payload_dir = output_dir.join("payload");
418        ensure_real_archive_output_directory(&payload_dir, "encrypted archive payload directory")?;
419
420        // Read input file size for progress
421        let input_size = std::fs::metadata(input_path)?.len();
422        ensure_archive_chunk_count_fits_nonce_space(
423            input_size.div_ceil(self.chunk_size as u64),
424            self.chunk_size,
425        )?;
426
427        // Open input file
428        let input_file = File::open(input_path).context("Failed to open input file")?;
429        let mut reader = BufReader::new(input_file);
430
431        // Compress and encrypt in chunks
432        let mut chunk_files = Vec::new();
433        let mut chunk_index = 0u32;
434        let mut total_compressed = 0u64;
435        let mut bytes_read = 0u64;
436
437        let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
438
439        loop {
440            // Read up to chunk_size bytes
441            let mut plaintext = vec![0u8; self.chunk_size];
442            let mut total_read = 0;
443
444            while total_read < self.chunk_size {
445                match reader.read(&mut plaintext[total_read..]) {
446                    Ok(0) => break, // EOF
447                    Ok(n) => {
448                        total_read += n;
449                        bytes_read += n as u64;
450                        progress(bytes_read, input_size);
451                    }
452                    Err(e) => return Err(e.into()),
453                }
454            }
455
456            if total_read == 0 {
457                break; // No more data
458            }
459            ensure_can_write_archive_chunk(chunk_index, self.chunk_size)?;
460
461            plaintext.truncate(total_read);
462
463            // Compress the chunk
464            let mut compressed = Vec::new();
465            {
466                let mut encoder = DeflateEncoder::new(&mut compressed, Compression::default());
467                encoder.write_all(&plaintext)?;
468                encoder.finish()?;
469            }
470
471            // Derive nonce for this chunk (counter-based)
472            let nonce = derive_chunk_nonce(&self.base_nonce, chunk_index);
473
474            // Build AAD: export_id || chunk_index || schema_version
475            let aad = build_chunk_aad(&self.export_id, chunk_index);
476
477            // Encrypt with AEAD
478            let ciphertext = cipher
479                .encrypt(
480                    Nonce::from_slice(&nonce),
481                    Payload {
482                        msg: &compressed,
483                        aad: &aad,
484                    },
485                )
486                .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
487
488            // Write chunk file
489            let chunk_filename = format!("chunk-{:05}.bin", chunk_index);
490            let chunk_path = payload_dir.join(&chunk_filename);
491            write_encrypted_archive_file(&chunk_path, &ciphertext, "encrypted payload chunk")?;
492
493            chunk_files.push(format!("payload/{}", chunk_filename));
494            total_compressed += ciphertext.len() as u64;
495            chunk_index = chunk_index.checked_add(1).ok_or_else(|| {
496                anyhow::anyhow!(
497                    "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
498                    u32::MAX,
499                    (u32::MAX as u64) * (self.chunk_size as u64)
500                )
501            })?;
502        }
503
504        // Build config
505        let config = EncryptionConfig {
506            version: SCHEMA_VERSION,
507            export_id: BASE64_STANDARD.encode(self.export_id),
508            base_nonce: BASE64_STANDARD.encode(self.base_nonce),
509            compression: "deflate".to_string(),
510            kdf_defaults: Argon2Params::default(),
511            payload: PayloadMeta {
512                chunk_size: self.chunk_size,
513                chunk_count: chunk_index as usize,
514                total_compressed_size: total_compressed,
515                total_plaintext_size: input_size,
516                files: chunk_files,
517            },
518            key_slots: self.key_slots.clone(),
519        };
520
521        // Write config.json
522        let config_path = output_dir.join("config.json");
523        let config_payload =
524            serde_json::to_vec_pretty(&config).context("Failed to serialize encryption config")?;
525        write_encrypted_archive_file(&config_path, &config_payload, "encryption config")?;
526        sync_tree(output_dir)?;
527
528        Ok(config)
529    }
530}
531
532fn ensure_real_archive_output_directory(path: &Path, label: &str) -> Result<()> {
533    ensure_existing_archive_ancestors_have_no_symlinks(path, label)?;
534    std::fs::create_dir_all(path).with_context(|| format!("Failed to create {label}"))?;
535    ensure_existing_archive_ancestors_have_no_symlinks(path, label)?;
536
537    let metadata =
538        std::fs::symlink_metadata(path).with_context(|| format!("Failed to inspect {label}"))?;
539    let file_type = metadata.file_type();
540    if file_type.is_symlink() {
541        bail!("{label} must not be a symlink: {}", path.display());
542    }
543    if !file_type.is_dir() {
544        bail!("{label} must be a directory: {}", path.display());
545    }
546    Ok(())
547}
548
549fn ensure_existing_archive_ancestors_have_no_symlinks(path: &Path, label: &str) -> Result<()> {
550    let mut ancestors: Vec<PathBuf> = path
551        .ancestors()
552        .filter(|ancestor| !ancestor.as_os_str().is_empty())
553        .map(Path::to_path_buf)
554        .collect();
555    ancestors.reverse();
556
557    for ancestor in ancestors {
558        match std::fs::symlink_metadata(&ancestor) {
559            Ok(metadata) => {
560                let file_type = metadata.file_type();
561                if file_type.is_symlink() {
562                    if is_allowed_system_symlink_ancestor(&ancestor) {
563                        continue;
564                    }
565                    bail!("{label} must not contain symlinks: {}", ancestor.display());
566                }
567                if !file_type.is_dir() {
568                    bail!(
569                        "{label} parent path must be a directory: {}",
570                        ancestor.display()
571                    );
572                }
573            }
574            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
575            Err(err) => {
576                return Err(err)
577                    .with_context(|| format!("Failed to inspect {label} {}", ancestor.display()));
578            }
579        }
580    }
581
582    Ok(())
583}
584
585#[cfg(target_os = "macos")]
586fn is_allowed_system_symlink_ancestor(path: &Path) -> bool {
587    path == Path::new("/var") || path == Path::new("/tmp")
588}
589
590#[cfg(not(target_os = "macos"))]
591fn is_allowed_system_symlink_ancestor(_path: &Path) -> bool {
592    false
593}
594
595fn write_encrypted_archive_file(path: &Path, bytes: &[u8], label: &str) -> Result<()> {
596    ensure_replaceable_archive_file(path, label)?;
597    let (mut pending, file) = PendingArchiveOutput::create(path, label)?;
598    let mut writer = BufWriter::new(file);
599    writer
600        .write_all(bytes)
601        .with_context(|| format!("Failed to write {label} {}", pending.path().display()))?;
602    writer
603        .flush()
604        .with_context(|| format!("Failed to flush {label} {}", pending.path().display()))?;
605    writer
606        .get_ref()
607        .sync_all()
608        .with_context(|| format!("Failed to sync {label} {}", pending.path().display()))?;
609    drop(writer);
610    pending.persist(path, label)
611}
612
613fn ensure_replaceable_archive_file(path: &Path, label: &str) -> Result<()> {
614    match std::fs::symlink_metadata(path) {
615        Ok(metadata) => {
616            let file_type = metadata.file_type();
617            if file_type.is_symlink() {
618                bail!(
619                    "Refusing to write {label} through symlink: {}",
620                    path.display()
621                );
622            }
623            if !file_type.is_file() {
624                bail!(
625                    "Refusing to replace {label} at non-file path: {}",
626                    path.display()
627                );
628            }
629            Ok(())
630        }
631        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
632        Err(err) => {
633            Err(err).with_context(|| format!("Failed to inspect {label} {}", path.display()))
634        }
635    }
636}
637
638#[cfg(any(windows, test))]
639fn replacement_path_entry_exists(path: &Path, label: &str) -> Result<bool> {
640    match std::fs::symlink_metadata(path) {
641        Ok(_) => Ok(true),
642        Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
643        Err(err) => {
644            Err(err).with_context(|| format!("Failed to inspect {label} {}", path.display()))
645        }
646    }
647}
648
649#[cfg(any(windows, test))]
650fn unique_replacement_backup_path(path: &Path, purpose: &str, label: &str) -> Result<PathBuf> {
651    static BACKUP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
652
653    let parent = output_parent(path);
654    let file_name = path
655        .file_name()
656        .ok_or_else(|| anyhow::anyhow!("{label} path must name a file"))?
657        .to_string_lossy();
658    let counter = BACKUP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
659    let timestamp = std::time::SystemTime::now()
660        .duration_since(std::time::UNIX_EPOCH)
661        .unwrap_or_else(|_| std::time::Duration::from_secs(0))
662        .as_nanos();
663    let candidate = parent.join(format!(
664        ".{file_name}.{purpose}.{}.{}.{timestamp:x}",
665        std::process::id(),
666        counter
667    ));
668
669    match std::fs::symlink_metadata(&candidate) {
670        Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(candidate),
671        Ok(_) => {
672            bail!(
673                "Replacement backup path for {label} already exists: {}",
674                candidate.display()
675            );
676        }
677        Err(err) => Err(err).with_context(|| {
678            format!(
679                "Failed to inspect replacement backup path {}",
680                candidate.display()
681            )
682        }),
683    }
684}
685
686struct PendingArchiveOutput {
687    path: PathBuf,
688    keep: bool,
689}
690
691impl PendingArchiveOutput {
692    fn create(final_path: &Path, label: &str) -> Result<(Self, File)> {
693        let parent = output_parent(final_path);
694        ensure_existing_archive_ancestors_have_no_symlinks(parent, label)?;
695        let file_name = final_path
696            .file_name()
697            .ok_or_else(|| anyhow::anyhow!("{label} path must name a file"))?
698            .to_string_lossy();
699
700        for attempt in 0..100u32 {
701            let mut random_bytes = [0u8; 8];
702            let mut rng = rand::rng();
703            rng.fill_bytes(&mut random_bytes);
704            let random = u64::from_le_bytes(random_bytes);
705            let temp_path = parent.join(format!(
706                ".{file_name}.cass-encrypt-tmp.{}.{}.{:016x}",
707                std::process::id(),
708                attempt,
709                random
710            ));
711
712            match OpenOptions::new()
713                .write(true)
714                .create_new(true)
715                .open(&temp_path)
716            {
717                Ok(file) => {
718                    return Ok((
719                        Self {
720                            path: temp_path,
721                            keep: false,
722                        },
723                        file,
724                    ));
725                }
726                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
727                Err(err) => {
728                    return Err(err).with_context(|| {
729                        format!("Failed to create temporary {label} {}", temp_path.display())
730                    });
731                }
732            }
733        }
734
735        bail!(
736            "Failed to create a unique temporary {label} next to {} after 100 attempts",
737            final_path.display()
738        );
739    }
740
741    fn path(&self) -> &Path {
742        &self.path
743    }
744
745    fn persist(&mut self, final_path: &Path, label: &str) -> Result<()> {
746        replace_archive_file_from_temp(&self.path, final_path, label)?;
747        self.keep = true;
748        Ok(())
749    }
750}
751
752impl Drop for PendingArchiveOutput {
753    fn drop(&mut self) {
754        if !self.keep {
755            let _ = std::fs::remove_file(&self.path);
756        }
757    }
758}
759
760fn replace_archive_file_from_temp(temp_path: &Path, final_path: &Path, label: &str) -> Result<()> {
761    replace_archive_file_from_temp_impl(temp_path, final_path, label)?;
762    sync_parent_directory(final_path)
763}
764
765#[cfg(not(windows))]
766fn replace_archive_file_from_temp_impl(
767    temp_path: &Path,
768    final_path: &Path,
769    label: &str,
770) -> Result<()> {
771    std::fs::rename(temp_path, final_path).with_context(|| {
772        format!(
773            "Failed to install {label} {} from {}",
774            final_path.display(),
775            temp_path.display()
776        )
777    })
778}
779
780#[cfg(windows)]
781fn replace_archive_file_from_temp_impl(
782    temp_path: &Path,
783    final_path: &Path,
784    label: &str,
785) -> Result<()> {
786    ensure_replaceable_archive_file(final_path, label)?;
787    if !replacement_path_entry_exists(final_path, label)? {
788        return std::fs::rename(temp_path, final_path).with_context(|| {
789            format!(
790                "Failed to install {label} {} from {}",
791                final_path.display(),
792                temp_path.display()
793            )
794        });
795    }
796
797    replace_archive_file_from_temp_via_backup(temp_path, final_path, label)
798}
799
800#[cfg(any(windows, test))]
801fn replace_archive_file_from_temp_via_backup(
802    temp_path: &Path,
803    final_path: &Path,
804    label: &str,
805) -> Result<()> {
806    let backup_path = unique_replacement_backup_path(final_path, "cass-encrypt-backup", label)?;
807
808    std::fs::rename(final_path, &backup_path).with_context(|| {
809        format!(
810            "Failed to stage existing {label} {} before replacement",
811            final_path.display()
812        )
813    })?;
814
815    match std::fs::rename(temp_path, final_path) {
816        Ok(()) => {
817            let _ = std::fs::remove_file(&backup_path);
818            Ok(())
819        }
820        Err(replace_err) => match std::fs::rename(&backup_path, final_path) {
821            Ok(()) => Err(replace_err).with_context(|| {
822                format!(
823                    "Failed to install {label} {}; restored previous output",
824                    final_path.display()
825                )
826            }),
827            Err(restore_err) => bail!(
828                "Failed to install {label} {}; also failed to restore previous output from {}: {}; temporary output retained at {}",
829                final_path.display(),
830                backup_path.display(),
831                restore_err,
832                temp_path.display()
833            ),
834        },
835    }
836}
837
838#[cfg(not(windows))]
839fn sync_tree(path: &Path) -> Result<()> {
840    // Bead 92o31: fsync the subtree first (files + directory inodes),
841    // THEN fsync the parent directory so the name-entry that points at
842    // `path` is durably recorded. Without the parent fsync, a
843    // power-loss between encrypt's return and the next fs::sync_all
844    // on the parent can leave the encrypted archive on disk but
845    // unreachable by its own path — operator sees success + missing
846    // file. Mirrors the proven shape in src/pages/bundle.rs:457-461.
847    sync_tree_inner(path)?;
848    sync_parent_directory(path)
849}
850
851#[cfg(windows)]
852fn sync_tree(_path: &Path) -> Result<()> {
853    // Windows has no portable fsync-directory primitive; NTFS journals
854    // name-entry updates synchronously with the file create/rename, so
855    // a no-op here is functionally equivalent to the POSIX two-step
856    // below. See bundle.rs:463-466 for the matching platform gate.
857    Ok(())
858}
859
860#[cfg(not(windows))]
861fn sync_tree_inner(path: &Path) -> Result<()> {
862    let metadata = std::fs::symlink_metadata(path)?;
863    let file_type = metadata.file_type();
864    if file_type.is_symlink() {
865        return Ok(());
866    }
867    if file_type.is_file() {
868        File::open(path)?.sync_all()?;
869        return Ok(());
870    }
871    if file_type.is_dir() {
872        for entry in std::fs::read_dir(path)? {
873            sync_tree_inner(&entry?.path())?;
874        }
875        File::open(path)?.sync_all()?;
876    }
877    Ok(())
878}
879
880/// fsync the directory that contains `path`, so the dirent pointing at
881/// `path` is durably recorded. POSIX requires this explicit step:
882/// fsync on a file flushes its contents + metadata, but NOT its name
883/// entry in the parent directory. Mirrors src/pages/bundle.rs:499-512.
884/// Bead 92o31.
885#[cfg(not(windows))]
886fn sync_parent_directory(path: &Path) -> Result<()> {
887    let Some(parent) = path.parent() else {
888        return Ok(());
889    };
890    File::open(parent)
891        .with_context(|| {
892            format!(
893                "failed opening parent directory {} for fsync",
894                parent.display()
895            )
896        })?
897        .sync_all()
898        .with_context(|| {
899            format!(
900                "failed syncing parent directory {} after encrypted export",
901                parent.display()
902            )
903        })
904}
905
906#[cfg(windows)]
907fn sync_parent_directory(_path: &Path) -> Result<()> {
908    Ok(())
909}
910
911/// Decryption engine
912pub struct DecryptionEngine {
913    dek: SecretKey,
914    config: EncryptionConfig,
915}
916
917impl DecryptionEngine {
918    /// Unlock with password
919    pub fn unlock_with_password(config: EncryptionConfig, password: &str) -> Result<Self> {
920        validate_supported_payload_format(&config)?;
921
922        for slot in &config.key_slots {
923            if slot.slot_type != SlotType::Password {
924                continue;
925            }
926
927            let salt = BASE64_STANDARD.decode(&slot.salt)?;
928            let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
929            let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
930
931            let kek = derive_kek_argon2id(password, &salt)?;
932
933            let export_id = BASE64_STANDARD.decode(&config.export_id)?;
934            if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
935                return Ok(Self {
936                    dek: SecretKey::from_bytes(dek),
937                    config,
938                });
939            }
940        }
941
942        bail!("Invalid password or no matching key slot")
943    }
944
945    /// Unlock with recovery secret
946    pub fn unlock_with_recovery(config: EncryptionConfig, secret: &[u8]) -> Result<Self> {
947        validate_supported_payload_format(&config)?;
948
949        for slot in &config.key_slots {
950            if slot.slot_type != SlotType::Recovery {
951                continue;
952            }
953
954            let salt = BASE64_STANDARD.decode(&slot.salt)?;
955            let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
956            let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
957
958            let kek = derive_kek_hkdf(secret, &salt)?;
959
960            let export_id = BASE64_STANDARD.decode(&config.export_id)?;
961            if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
962                return Ok(Self {
963                    dek: SecretKey::from_bytes(dek),
964                    config,
965                });
966            }
967        }
968
969        bail!("Invalid recovery secret or no matching key slot")
970    }
971
972    /// Decrypt all chunks to output file
973    pub fn decrypt_to_file<P: AsRef<Path>>(
974        &self,
975        encrypted_dir: P,
976        output: P,
977        progress: impl Fn(usize, usize),
978    ) -> Result<()> {
979        let encrypted_dir = super::resolve_site_dir(encrypted_dir.as_ref())?;
980        let output_path = output.as_ref();
981        validate_supported_payload_format(&self.config)?;
982
983        let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
984
985        let base_nonce = BASE64_STANDARD.decode(&self.config.base_nonce)?;
986        let export_id = BASE64_STANDARD.decode(&self.config.export_id)?;
987
988        // Validate chunk count doesn't exceed u32 to prevent nonce truncation
989        if self.config.payload.files.len() > u32::MAX as usize {
990            bail!(
991                "Invalid config: chunk count {} exceeds maximum {}",
992                self.config.payload.files.len(),
993                u32::MAX
994            );
995        }
996
997        let (mut pending_output, output_file) = PendingDecryptOutput::create(output_path)?;
998        let mut writer = BufWriter::new(output_file);
999
1000        for (chunk_index, chunk_file) in self.config.payload.files.iter().enumerate() {
1001            progress(chunk_index, self.config.payload.chunk_count);
1002
1003            // Prevent directory traversal
1004            if chunk_file.contains("..") || Path::new(chunk_file).is_absolute() {
1005                bail!("Invalid chunk path: potential directory traversal");
1006            }
1007
1008            let chunk_path = encrypted_dir.join(chunk_file);
1009            let ciphertext = std::fs::read(&chunk_path)?;
1010
1011            // Derive nonce
1012            let nonce = derive_chunk_nonce(base_nonce.as_slice().try_into()?, chunk_index as u32);
1013
1014            // Build AAD
1015            let aad = build_chunk_aad(export_id.as_slice().try_into()?, chunk_index as u32);
1016
1017            // Decrypt
1018            let compressed = cipher
1019                .decrypt(
1020                    Nonce::from_slice(&nonce),
1021                    Payload {
1022                        msg: &ciphertext,
1023                        aad: &aad,
1024                    },
1025                )
1026                .map_err(|err| {
1027                    // [coding_agent_session_search-b64fe] Chain the underlying
1028                    // aead error so operators can distinguish "decryption
1029                    // failed at chunk N because the AES-GCM tag did not
1030                    // verify" (corrupt ciphertext / wrong DEK / tampered
1031                    // AAD) from a downstream decompression / writer
1032                    // failure that surfaces with a different error chain.
1033                    // The aead crate's Display impl deliberately stays
1034                    // opaque about whether MAC vs auth-tag verification
1035                    // failed (timing-attack hardening), so we still don't
1036                    // leak that — but the source error type IS preserved
1037                    // in the chain for debug-mode inspection.
1038                    let context = format!(
1039                        "Decryption failed for chunk {} ({} bytes ciphertext): {}",
1040                        chunk_index,
1041                        ciphertext.len(),
1042                        err
1043                    );
1044                    anyhow::Error::new(AeadSourceError(err)).context(context)
1045                })?;
1046
1047            // Decompress
1048            let mut decoder = DeflateDecoder::new(&compressed[..]);
1049            let mut plaintext = Vec::new();
1050            decoder.read_to_end(&mut plaintext)?;
1051
1052            writer.write_all(&plaintext)?;
1053        }
1054
1055        writer.flush()?;
1056        writer
1057            .get_ref()
1058            .sync_all()
1059            .with_context(|| format!("Failed to sync {}", pending_output.path().display()))?;
1060        drop(writer);
1061        pending_output.persist(output_path)?;
1062
1063        progress(
1064            self.config.payload.chunk_count,
1065            self.config.payload.chunk_count,
1066        );
1067
1068        Ok(())
1069    }
1070}
1071
1072struct PendingDecryptOutput {
1073    path: PathBuf,
1074    keep: bool,
1075}
1076
1077impl PendingDecryptOutput {
1078    fn create(output_path: &Path) -> Result<(Self, File)> {
1079        let parent = output_parent(output_path);
1080        let file_name = output_path
1081            .file_name()
1082            .ok_or_else(|| anyhow::anyhow!("decryption output path must name a file"))?
1083            .to_string_lossy();
1084
1085        for attempt in 0..100u32 {
1086            let mut random_bytes = [0u8; 8];
1087            let mut rng = rand::rng();
1088            rng.fill_bytes(&mut random_bytes);
1089            let random = u64::from_le_bytes(random_bytes);
1090            let temp_path = parent.join(format!(
1091                ".{file_name}.cass-decrypt-tmp.{}.{}.{:016x}",
1092                std::process::id(),
1093                attempt,
1094                random
1095            ));
1096
1097            let mut options = OpenOptions::new();
1098            options.write(true).create_new(true);
1099            #[cfg(unix)]
1100            {
1101                use std::os::unix::fs::OpenOptionsExt;
1102                options.mode(0o600);
1103            }
1104
1105            match options.open(&temp_path) {
1106                Ok(file) => {
1107                    return Ok((
1108                        Self {
1109                            path: temp_path,
1110                            keep: false,
1111                        },
1112                        file,
1113                    ));
1114                }
1115                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1116                Err(err) => {
1117                    return Err(err).with_context(|| {
1118                        format!(
1119                            "Failed to create temporary decrypt output {}",
1120                            temp_path.display()
1121                        )
1122                    });
1123                }
1124            }
1125        }
1126
1127        bail!(
1128            "Failed to create a unique temporary decrypt output next to {} after 100 attempts",
1129            output_path.display()
1130        );
1131    }
1132
1133    fn path(&self) -> &Path {
1134        &self.path
1135    }
1136
1137    fn persist(&mut self, output_path: &Path) -> Result<()> {
1138        replace_decrypt_output_from_temp(&self.path, output_path)?;
1139        self.keep = true;
1140        Ok(())
1141    }
1142}
1143
1144impl Drop for PendingDecryptOutput {
1145    fn drop(&mut self) {
1146        if !self.keep {
1147            let _ = std::fs::remove_file(&self.path);
1148        }
1149    }
1150}
1151
1152fn output_parent(output_path: &Path) -> &Path {
1153    output_path
1154        .parent()
1155        .filter(|parent| !parent.as_os_str().is_empty())
1156        .unwrap_or_else(|| Path::new("."))
1157}
1158
1159fn replace_decrypt_output_from_temp(temp_path: &Path, output_path: &Path) -> Result<()> {
1160    replace_decrypt_output_from_temp_impl(temp_path, output_path)?;
1161    sync_parent_directory(output_path)
1162}
1163
1164#[cfg(not(windows))]
1165fn replace_decrypt_output_from_temp_impl(temp_path: &Path, output_path: &Path) -> Result<()> {
1166    std::fs::rename(temp_path, output_path).with_context(|| {
1167        format!(
1168            "Failed to install decrypted output {} from {}",
1169            output_path.display(),
1170            temp_path.display()
1171        )
1172    })
1173}
1174
1175#[cfg(windows)]
1176fn replace_decrypt_output_from_temp_impl(temp_path: &Path, output_path: &Path) -> Result<()> {
1177    if !replacement_path_entry_exists(output_path, "decrypted output")? {
1178        return std::fs::rename(temp_path, output_path).with_context(|| {
1179            format!(
1180                "Failed to install decrypted output {} from {}",
1181                output_path.display(),
1182                temp_path.display()
1183            )
1184        });
1185    }
1186
1187    replace_decrypt_output_from_temp_via_backup(temp_path, output_path)
1188}
1189
1190#[cfg(any(windows, test))]
1191fn replace_decrypt_output_from_temp_via_backup(temp_path: &Path, output_path: &Path) -> Result<()> {
1192    let backup_path =
1193        unique_replacement_backup_path(output_path, "cass-decrypt-backup", "decrypted output")?;
1194
1195    std::fs::rename(output_path, &backup_path).with_context(|| {
1196        format!(
1197            "Failed to stage existing decrypted output {} before replacement",
1198            output_path.display()
1199        )
1200    })?;
1201
1202    match std::fs::rename(temp_path, output_path) {
1203        Ok(()) => {
1204            let _ = std::fs::remove_file(&backup_path);
1205            Ok(())
1206        }
1207        Err(replace_err) => match std::fs::rename(&backup_path, output_path) {
1208            Ok(()) => Err(replace_err).with_context(|| {
1209                format!(
1210                    "Failed to install decrypted output {}; restored previous output",
1211                    output_path.display()
1212                )
1213            }),
1214            Err(restore_err) => bail!(
1215                "Failed to install decrypted output {}; also failed to restore previous output from {}: {}; temporary output retained at {}",
1216                output_path.display(),
1217                backup_path.display(),
1218                restore_err,
1219                temp_path.display()
1220            ),
1221        },
1222    }
1223}
1224
1225/// Derive KEK from password using Argon2id.
1226///
1227/// Per `coding_agent_session_search-vz9t8.4`, instrumented with safe-to-log
1228/// tracing. Logs ONLY: operation name, salt length, output KEK length (always
1229/// 32), and Argon2 parameters (memory_kb, iterations, parallelism). The
1230/// password and the resulting KEK are NEVER logged.
1231#[tracing::instrument(
1232    name = "derive_kek_argon2id",
1233    skip_all,
1234    fields(
1235        operation = "derive_kek_argon2id",
1236        salt_len = salt.len(),
1237        memory_kb = ARGON2_MEMORY_KB,
1238        iterations = ARGON2_ITERATIONS,
1239        parallelism = ARGON2_PARALLELISM,
1240        password_present = !password.is_empty(),
1241    )
1242)]
1243fn derive_kek_argon2id(password: &str, salt: &[u8]) -> Result<SecretKey> {
1244    let start = std::time::Instant::now();
1245    let params = Params::new(
1246        ARGON2_MEMORY_KB,
1247        ARGON2_ITERATIONS,
1248        ARGON2_PARALLELISM,
1249        Some(32),
1250    )
1251    .map_err(|e| anyhow::anyhow!("Invalid Argon2 parameters: {:?}", e))?;
1252
1253    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1254
1255    let mut kek = [0u8; 32];
1256    argon2
1257        .hash_password_into(password.as_bytes(), salt, &mut kek)
1258        .map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
1259
1260    tracing::debug!(
1261        target: "cass::pages::encrypt",
1262        operation = "derive_kek_argon2id",
1263        elapsed_ms = start.elapsed().as_millis() as u64,
1264        kek_len = kek.len(),
1265        "derive_kek_argon2id: ok"
1266    );
1267    Ok(SecretKey::from_bytes(kek))
1268}
1269
1270/// Derive KEK from recovery secret using HKDF-SHA256.
1271///
1272/// Per `coding_agent_session_search-vz9t8.4`, instrumented with safe-to-log
1273/// tracing. Logs operation name + salt length + secret-byte-length only. The
1274/// secret bytes and KEK output are NEVER logged. The hkdf_extract_expand
1275/// helper itself records its own (also-safe) tracing span.
1276#[tracing::instrument(
1277    name = "derive_kek_hkdf",
1278    skip_all,
1279    fields(
1280        operation = "derive_kek_hkdf",
1281        salt_len = salt.len(),
1282        secret_len = secret.len(),
1283        info_label = "cass-pages-kek-v2",
1284    )
1285)]
1286fn derive_kek_hkdf(secret: &[u8], salt: &[u8]) -> Result<SecretKey> {
1287    let start = std::time::Instant::now();
1288    let kek = crate::encryption::hkdf_extract_expand(secret, salt, b"cass-pages-kek-v2", 32)
1289        .map_err(|e| anyhow::anyhow!("HKDF extract+expand failed for recovery secret KEK: {e}"))?;
1290    let actual_len = kek.len();
1291    let kek: [u8; 32] = kek.try_into().map_err(|_| {
1292        anyhow::anyhow!(
1293            "HKDF expansion produced invalid KEK length: expected 32, got {}",
1294            actual_len
1295        )
1296    })?;
1297    tracing::debug!(
1298        target: "cass::pages::encrypt",
1299        operation = "derive_kek_hkdf",
1300        elapsed_us = start.elapsed().as_micros() as u64,
1301        kek_len = 32,
1302        "derive_kek_hkdf: ok"
1303    );
1304    Ok(SecretKey::from_bytes(kek))
1305}
1306
1307/// Wrap DEK with KEK using AES-256-GCM
1308fn wrap_key(
1309    kek: &SecretKey,
1310    dek: &[u8; 32],
1311    export_id: &[u8; 16],
1312    slot_id: u8,
1313) -> Result<(Vec<u8>, [u8; 12])> {
1314    let cipher = Aes256Gcm::new_from_slice(kek.as_bytes()).expect("Invalid key length");
1315
1316    let mut nonce = [0u8; 12];
1317    let mut rng = rand::rng();
1318    rng.fill_bytes(&mut nonce);
1319
1320    // AAD: export_id || slot_id
1321    let mut aad = Vec::with_capacity(17);
1322    aad.extend_from_slice(export_id);
1323    aad.push(slot_id);
1324
1325    let wrapped = cipher
1326        .encrypt(
1327            Nonce::from_slice(&nonce),
1328            Payload {
1329                msg: dek,
1330                aad: &aad,
1331            },
1332        )
1333        .map_err(|e| anyhow::anyhow!("Key wrapping failed: {}", e))?;
1334
1335    Ok((wrapped, nonce))
1336}
1337
1338/// Unwrap DEK with KEK
1339fn unwrap_key(
1340    kek: &SecretKey,
1341    wrapped: &[u8],
1342    nonce: &[u8],
1343    export_id: &[u8],
1344    slot_id: u8,
1345) -> Result<[u8; 32]> {
1346    let cipher = Aes256Gcm::new_from_slice(kek.as_bytes()).expect("Invalid key length");
1347    let nonce: &[u8; 12] = nonce
1348        .try_into()
1349        .map_err(|_| anyhow::anyhow!("invalid nonce length: expected 12, got {}", nonce.len()))?;
1350
1351    // AAD: export_id || slot_id
1352    let mut aad = Vec::with_capacity(export_id.len() + 1);
1353    aad.extend_from_slice(export_id);
1354    aad.push(slot_id);
1355
1356    let dek = cipher
1357        .decrypt(
1358            Nonce::from_slice(nonce),
1359            Payload {
1360                msg: wrapped,
1361                aad: &aad,
1362            },
1363        )
1364        .map_err(|err| {
1365            // [coding_agent_session_search-b64fe] Chain the underlying
1366            // aead error so operators can distinguish "wrong password
1367            // (KEK derivation succeeded but DEK MAC failed)" from
1368            // "corrupt key slot ciphertext" from "wrong AAD (slot id /
1369            // export id mismatch)". The aead crate's Display impl
1370            // remains opaque about the specific sub-failure (timing-
1371            // attack hardening), but the source error type IS preserved
1372            // so debug-mode error chains can show whether the failure
1373            // came from the cipher layer vs a subsequent layer. Slot
1374            // id is included so operators can correlate with the
1375            // recovery / password slot they were attempting.
1376            let context = format!(
1377                "Key unwrapping failed for slot {} ({} bytes wrapped, {} bytes nonce, \
1378                 {} bytes aad): {}",
1379                slot_id,
1380                wrapped.len(),
1381                nonce.len(),
1382                aad.len(),
1383                err
1384            );
1385            anyhow::Error::new(AeadSourceError(err)).context(context)
1386        })?;
1387
1388    let dek_len = dek.len();
1389    dek.try_into().map_err(|_| {
1390        anyhow::anyhow!(
1391            "Invalid DEK length after unwrap: expected 32, got {}",
1392            dek_len
1393        )
1394    })
1395}
1396
1397/// Derive chunk nonce from base nonce and chunk index (counter mode)
1398///
1399/// Uses deterministic counter mode: the first 8 bytes come from the random
1400/// base_nonce (unique per export), and the last 4 bytes are the chunk index.
1401/// This ensures unique nonces for up to 2^32 chunks per export without
1402/// collision risk.
1403///
1404/// Per `coding_agent_session_search-vz9t8.4`, instrumented with safe tracing:
1405/// logs only operation name and chunk_index. The nonce bytes themselves are
1406/// NEVER logged (they're not strictly secret but are forensic-relevant —
1407/// avoiding log noise + the discipline of skip_all is uniform across all
1408/// derive_* functions).
1409#[tracing::instrument(
1410    name = "derive_chunk_nonce",
1411    skip_all,
1412    fields(operation = "derive_chunk_nonce", chunk_index = chunk_index)
1413)]
1414fn derive_chunk_nonce(base_nonce: &[u8; 12], chunk_index: u32) -> [u8; 12] {
1415    let mut nonce = *base_nonce;
1416    // Set the last 4 bytes to the chunk index (big-endian)
1417    // This is safer than XOR as it guarantees unique nonces for each chunk
1418    nonce[8..12].copy_from_slice(&chunk_index.to_be_bytes());
1419    tracing::trace!(
1420        target: "cass::pages::encrypt",
1421        operation = "derive_chunk_nonce",
1422        chunk_index = chunk_index,
1423        "derive_chunk_nonce: ok"
1424    );
1425    nonce
1426}
1427
1428/// Build AAD for chunk encryption
1429fn build_chunk_aad(export_id: &[u8; 16], chunk_index: u32) -> Vec<u8> {
1430    let mut aad = Vec::with_capacity(21);
1431    aad.extend_from_slice(export_id);
1432    aad.extend_from_slice(&chunk_index.to_be_bytes());
1433    aad.push(SCHEMA_VERSION);
1434    aad
1435}
1436
1437/// Load encryption config from directory
1438pub fn load_config<P: AsRef<Path>>(dir: P) -> Result<EncryptionConfig> {
1439    let archive_dir = super::resolve_site_dir(dir.as_ref())?;
1440    let config_path = archive_dir.join("config.json");
1441    let file = File::open(&config_path).context("Failed to open config.json")?;
1442    let config: EncryptionConfig = serde_json::from_reader(BufReader::new(file))?;
1443    Ok(config)
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448    use super::*;
1449    use tempfile::TempDir;
1450
1451    fn assert_file_bytes(path: &Path, expected: &[u8]) {
1452        let actual = std::fs::read(path)
1453            .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
1454        assert_eq!(
1455            actual.as_slice(),
1456            expected,
1457            "unexpected bytes in {}",
1458            path.display()
1459        );
1460    }
1461
1462    fn legacy_pid_backup_path(path: &Path, purpose: &str) -> Result<PathBuf> {
1463        let file_name = path
1464            .file_name()
1465            .ok_or_else(|| anyhow::anyhow!("test path must name a file"))?
1466            .to_string_lossy();
1467        Ok(output_parent(path).join(format!(".{file_name}.{purpose}.{}", std::process::id())))
1468    }
1469
1470    fn encrypt_test_file() -> (TempDir, std::path::PathBuf, EncryptionConfig) {
1471        let temp_dir = TempDir::new().unwrap();
1472        let input_path = temp_dir.path().join("input.txt");
1473        let output_dir = temp_dir.path().join("encrypted");
1474
1475        std::fs::write(&input_path, b"payload format validation test").unwrap();
1476
1477        let mut engine = EncryptionEngine::new(1024).unwrap();
1478        engine.add_password_slot("password").unwrap();
1479        let config = engine
1480            .encrypt_file(&input_path, &output_dir, |_, _| {})
1481            .unwrap();
1482
1483        (temp_dir, output_dir, config)
1484    }
1485
1486    #[test]
1487    fn test_argon2id_key_derivation() {
1488        let password = "test-password-123";
1489        let salt = b"0123456789abcdef";
1490
1491        let kek1 = derive_kek_argon2id(password, salt).unwrap();
1492        let kek2 = derive_kek_argon2id(password, salt).unwrap();
1493
1494        // Same password + salt = same key
1495        assert_eq!(kek1.as_bytes(), kek2.as_bytes());
1496
1497        // Different password = different key
1498        let kek3 = derive_kek_argon2id("different", salt).unwrap();
1499        assert_ne!(kek1.as_bytes(), kek3.as_bytes());
1500    }
1501
1502    #[test]
1503    fn test_hkdf_key_derivation() {
1504        let secret = b"recovery-secret-bytes";
1505        let salt = [0u8; 16];
1506
1507        let kek1 = derive_kek_hkdf(secret, &salt).unwrap();
1508        let kek2 = derive_kek_hkdf(secret, &salt).unwrap();
1509
1510        assert_eq!(kek1.as_bytes(), kek2.as_bytes());
1511    }
1512
1513    #[test]
1514    fn test_key_wrap_unwrap() {
1515        let kek = SecretKey::random();
1516        let dek = [42u8; 32];
1517        let export_id = [1u8; 16];
1518        let slot_id = 0;
1519
1520        let (wrapped, nonce) = wrap_key(&kek, &dek, &export_id, slot_id).unwrap();
1521        let unwrapped = unwrap_key(&kek, &wrapped, &nonce, &export_id, slot_id).unwrap();
1522
1523        assert_eq!(dek, unwrapped);
1524    }
1525
1526    #[test]
1527    fn test_key_wrap_wrong_aad_fails() {
1528        let kek = SecretKey::random();
1529        let dek = [42u8; 32];
1530        let export_id = [1u8; 16];
1531
1532        let (wrapped, nonce) = wrap_key(&kek, &dek, &export_id, 0).unwrap();
1533
1534        // Wrong slot_id should fail
1535        assert!(unwrap_key(&kek, &wrapped, &nonce, &export_id, 1).is_err());
1536
1537        // Wrong export_id should fail
1538        let wrong_id = [2u8; 16];
1539        assert!(unwrap_key(&kek, &wrapped, &nonce, &wrong_id, 0).is_err());
1540    }
1541
1542    #[test]
1543    fn test_chunk_nonce_derivation() {
1544        let base = [0u8; 12];
1545
1546        let n0 = derive_chunk_nonce(&base, 0);
1547        let n1 = derive_chunk_nonce(&base, 1);
1548        let n2 = derive_chunk_nonce(&base, 2);
1549
1550        // Each chunk should have unique nonce
1551        assert_ne!(n0, n1);
1552        assert_ne!(n1, n2);
1553        assert_ne!(n0, n2);
1554    }
1555
1556    #[test]
1557    fn test_encryption_roundtrip() {
1558        let temp_dir = TempDir::new().unwrap();
1559        let input_path = temp_dir.path().join("input.txt");
1560        let output_dir = temp_dir.path().join("encrypted");
1561        let decrypted_path = temp_dir.path().join("decrypted.txt");
1562
1563        // Create test file
1564        let test_data = b"Hello, World! This is a test of the encryption system.";
1565        std::fs::write(&input_path, test_data).unwrap();
1566
1567        // Encrypt
1568        let mut engine = EncryptionEngine::new(1024).unwrap(); // Small chunks for testing
1569        engine.add_password_slot("test-password").unwrap();
1570
1571        let config = engine
1572            .encrypt_file(&input_path, &output_dir, |_, _| {})
1573            .unwrap();
1574
1575        assert_eq!(config.version, SCHEMA_VERSION);
1576        assert!(!config.key_slots.is_empty());
1577        assert!(config.payload.chunk_count > 0);
1578
1579        // Decrypt
1580        let decryptor = DecryptionEngine::unlock_with_password(config, "test-password").unwrap();
1581        decryptor
1582            .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1583            .unwrap();
1584
1585        // Verify
1586        assert_file_bytes(&decrypted_path, test_data);
1587    }
1588
1589    #[test]
1590    fn encrypt_file_rejects_chunk_count_beyond_nonce_space_before_writing_payload() {
1591        let temp_dir = TempDir::new().unwrap();
1592        let input_path = temp_dir.path().join("too-large.bin");
1593        let output_dir = temp_dir.path().join("encrypted");
1594
1595        let input = File::create(&input_path).unwrap();
1596        input.set_len(u64::from(u32::MAX) + 1).unwrap();
1597
1598        let mut engine = EncryptionEngine::new(1).unwrap();
1599        engine.add_password_slot("password").unwrap();
1600
1601        let err = engine
1602            .encrypt_file(&input_path, &output_dir, |_, _| {})
1603            .expect_err("archive must reject more than u32::MAX chunks");
1604        let rendered = err.to_string();
1605        assert!(
1606            rendered.contains("exceeds maximum") && rendered.contains(&u32::MAX.to_string()),
1607            "unexpected chunk-count error: {rendered}"
1608        );
1609        assert!(
1610            !output_dir.join("payload/chunk-00000.bin").exists(),
1611            "oversized sparse input must fail before writing any ciphertext chunk"
1612        );
1613    }
1614
1615    #[test]
1616    #[cfg(unix)]
1617    fn encrypt_file_rejects_symlinked_payload_directory() {
1618        use std::os::unix::fs::symlink;
1619
1620        let temp_dir = TempDir::new().unwrap();
1621        let input_path = temp_dir.path().join("input.txt");
1622        let output_dir = temp_dir.path().join("encrypted");
1623        let outside_dir = temp_dir.path().join("outside");
1624        let test_data = b"payload dir symlink regression data";
1625
1626        std::fs::write(&input_path, test_data).unwrap();
1627        std::fs::create_dir_all(&output_dir).unwrap();
1628        std::fs::create_dir_all(&outside_dir).unwrap();
1629        symlink(&outside_dir, output_dir.join("payload")).unwrap();
1630
1631        let mut engine = EncryptionEngine::new(1024).unwrap();
1632        engine.add_password_slot("test-password").unwrap();
1633        let err = engine
1634            .encrypt_file(&input_path, &output_dir, |_, _| {})
1635            .expect_err("symlinked payload directory should be rejected");
1636
1637        assert!(
1638            err.to_string().contains("must not contain symlinks"),
1639            "unexpected error: {err:#}"
1640        );
1641        assert!(
1642            !outside_dir.join("chunk-00000.bin").exists(),
1643            "encrypt_file must not write through a symlinked payload directory"
1644        );
1645    }
1646
1647    #[test]
1648    #[cfg(unix)]
1649    fn encrypt_file_rejects_symlinked_chunk_file_without_touching_target() {
1650        use std::os::unix::fs::symlink;
1651
1652        let temp_dir = TempDir::new().unwrap();
1653        let input_path = temp_dir.path().join("input.txt");
1654        let output_dir = temp_dir.path().join("encrypted");
1655        let payload_dir = output_dir.join("payload");
1656        let protected_target_path = temp_dir.path().join("protected.bin");
1657        let test_data = b"chunk file symlink regression data";
1658
1659        std::fs::write(&input_path, test_data).unwrap();
1660        std::fs::create_dir_all(&payload_dir).unwrap();
1661        std::fs::write(&protected_target_path, b"protected chunk target").unwrap();
1662        symlink(&protected_target_path, payload_dir.join("chunk-00000.bin")).unwrap();
1663
1664        let mut engine = EncryptionEngine::new(1024).unwrap();
1665        engine.add_password_slot("test-password").unwrap();
1666        let err = engine
1667            .encrypt_file(&input_path, &output_dir, |_, _| {})
1668            .expect_err("symlinked chunk file should be rejected");
1669
1670        assert!(
1671            err.to_string().contains("through symlink"),
1672            "unexpected error: {err:#}"
1673        );
1674        assert_file_bytes(&protected_target_path, b"protected chunk target");
1675    }
1676
1677    #[test]
1678    #[cfg(unix)]
1679    fn encrypt_file_rejects_symlinked_config_file_without_touching_target() {
1680        use std::os::unix::fs::symlink;
1681
1682        let temp_dir = TempDir::new().unwrap();
1683        let input_path = temp_dir.path().join("input.txt");
1684        let output_dir = temp_dir.path().join("encrypted");
1685        let protected_target_path = temp_dir.path().join("protected-config.json");
1686        let test_data = b"config symlink regression data";
1687
1688        std::fs::write(&input_path, test_data).unwrap();
1689        std::fs::create_dir_all(&output_dir).unwrap();
1690        std::fs::write(&protected_target_path, b"protected config target").unwrap();
1691        symlink(&protected_target_path, output_dir.join("config.json")).unwrap();
1692
1693        let mut engine = EncryptionEngine::new(1024).unwrap();
1694        engine.add_password_slot("test-password").unwrap();
1695        let err = engine
1696            .encrypt_file(&input_path, &output_dir, |_, _| {})
1697            .expect_err("symlinked config file should be rejected");
1698
1699        assert!(
1700            err.to_string().contains("through symlink"),
1701            "unexpected error: {err:#}"
1702        );
1703        assert_file_bytes(&protected_target_path, b"protected config target");
1704    }
1705
1706    #[test]
1707    fn archive_backup_replace_does_not_collide_with_stale_pid_sidecar() -> Result<()> {
1708        let temp_dir = TempDir::new()?;
1709        let final_path = temp_dir.path().join("config.json");
1710        let temp_path = temp_dir.path().join(".config.json.tmp");
1711        let stale_backup = legacy_pid_backup_path(&final_path, "cass-encrypt-backup")?;
1712
1713        std::fs::write(&final_path, b"old config")?;
1714        std::fs::write(&temp_path, b"new config")?;
1715        std::fs::write(&stale_backup, b"stale backup")?;
1716
1717        replace_archive_file_from_temp_via_backup(
1718            &temp_path,
1719            &final_path,
1720            "test encryption config",
1721        )?;
1722
1723        if !matches!(
1724            std::fs::read(&final_path)?.as_slice().cmp(b"new config"),
1725            std::cmp::Ordering::Equal
1726        ) {
1727            return Err(anyhow::anyhow!(
1728                "archive replacement did not publish temp bytes"
1729            ));
1730        }
1731        if !matches!(
1732            std::fs::read(&stale_backup)?
1733                .as_slice()
1734                .cmp(b"stale backup"),
1735            std::cmp::Ordering::Equal
1736        ) {
1737            return Err(anyhow::anyhow!(
1738                "archive replacement clobbered stale backup"
1739            ));
1740        }
1741        if temp_path.exists() {
1742            return Err(anyhow::anyhow!("archive temp path was not consumed"));
1743        }
1744
1745        Ok(())
1746    }
1747
1748    #[cfg(unix)]
1749    #[test]
1750    fn replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
1751        use std::os::unix::fs::symlink;
1752
1753        let temp_dir = TempDir::new()?;
1754        let link_path = temp_dir.path().join("decrypted.txt");
1755        let missing_target = temp_dir.path().join("missing-target.txt");
1756
1757        symlink(&missing_target, &link_path)?;
1758
1759        if link_path.exists() {
1760            return Err(anyhow::anyhow!(
1761                "Path::exists stopped following the missing target"
1762            ));
1763        }
1764        if !replacement_path_entry_exists(&link_path, "test output")? {
1765            return Err(anyhow::anyhow!(
1766                "replacement path helper missed a dangling symlink entry"
1767            ));
1768        }
1769
1770        Ok(())
1771    }
1772
1773    #[test]
1774    fn test_multiple_key_slots() {
1775        let temp_dir = TempDir::new().unwrap();
1776        let input_path = temp_dir.path().join("input.txt");
1777        let output_dir = temp_dir.path().join("encrypted");
1778        let decrypted_path = temp_dir.path().join("decrypted.txt");
1779
1780        let test_data = b"Multi-slot test data";
1781        std::fs::write(&input_path, test_data).unwrap();
1782
1783        // Encrypt with multiple slots
1784        let mut engine = EncryptionEngine::new(1024).unwrap();
1785        engine.add_password_slot("password1").unwrap();
1786        engine.add_password_slot("password2").unwrap();
1787        engine.add_recovery_slot(b"recovery-secret").unwrap();
1788
1789        let config = engine
1790            .encrypt_file(&input_path, &output_dir, |_, _| {})
1791            .unwrap();
1792
1793        assert_eq!(config.key_slots.len(), 3);
1794
1795        // Decrypt with first password
1796        let d1 = DecryptionEngine::unlock_with_password(config.clone(), "password1").unwrap();
1797        d1.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1798            .unwrap();
1799        assert_file_bytes(&decrypted_path, test_data);
1800
1801        // Decrypt with second password
1802        let d2 = DecryptionEngine::unlock_with_password(config.clone(), "password2").unwrap();
1803        d2.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1804            .unwrap();
1805        assert_file_bytes(&decrypted_path, test_data);
1806
1807        // Decrypt with recovery secret
1808        let d3 =
1809            DecryptionEngine::unlock_with_recovery(config.clone(), b"recovery-secret").unwrap();
1810        d3.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1811            .unwrap();
1812        assert_file_bytes(&decrypted_path, test_data);
1813
1814        // Wrong password should fail
1815        assert!(DecryptionEngine::unlock_with_password(config, "wrong").is_err());
1816    }
1817
1818    #[test]
1819    fn key_slot_id_for_len_rejects_overflow() {
1820        assert_eq!(key_slot_id_for_len(255).unwrap(), 255);
1821
1822        let err = key_slot_id_for_len(256).unwrap_err();
1823        assert_eq!(
1824            err.to_string(),
1825            "maximum of 256 key slots exceeded (256 slots already allocated): out of range integral type conversion attempted"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_load_config_and_decrypt_accept_bundle_root() {
1831        let temp_dir = TempDir::new().unwrap();
1832        let input_path = temp_dir.path().join("input.txt");
1833        let bundle_root = temp_dir.path().join("bundle");
1834        let site_dir = bundle_root.join("site");
1835        let decrypted_path = temp_dir.path().join("decrypted.txt");
1836
1837        let test_data = b"Bundle root decryption test data";
1838        std::fs::write(&input_path, test_data).unwrap();
1839
1840        let mut engine = EncryptionEngine::new(1024).unwrap();
1841        engine.add_password_slot("password").unwrap();
1842        engine
1843            .encrypt_file(&input_path, &site_dir, |_, _| {})
1844            .unwrap();
1845
1846        let config = load_config(&bundle_root).unwrap();
1847        let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1848        decryptor
1849            .decrypt_to_file(&bundle_root, &decrypted_path, |_, _| {})
1850            .unwrap();
1851
1852        assert_file_bytes(&decrypted_path, test_data);
1853    }
1854
1855    #[test]
1856    fn test_decrypt_rejects_unsupported_payload_compression_before_unlock() {
1857        let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1858        config.compression = "zstd".to_string();
1859
1860        let err = match DecryptionEngine::unlock_with_password(config, "password") {
1861            Ok(_) => panic!("unsupported compression must fail before unlock"),
1862            Err(err) => err,
1863        };
1864
1865        let rendered = err.to_string();
1866        assert!(
1867            rendered.contains("supports only deflate") && rendered.contains("zstd"),
1868            "unexpected unsupported-compression error: {err:#}"
1869        );
1870    }
1871
1872    #[test]
1873    fn test_decrypt_rejects_unsupported_schema_version_before_unlock() {
1874        let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1875        config.version = 1;
1876
1877        let err = match DecryptionEngine::unlock_with_password(config, "password") {
1878            Ok(_) => panic!("unsupported schema version must fail before unlock"),
1879            Err(err) => err,
1880        };
1881
1882        let rendered = err.to_string();
1883        assert!(
1884            rendered.contains("schema version") && rendered.contains("expected 2"),
1885            "unexpected unsupported-version error: {err:#}"
1886        );
1887    }
1888
1889    #[test]
1890    fn test_decrypt_rejects_mismatched_chunk_count_before_unlock() {
1891        let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1892        config.payload.chunk_count += 1;
1893
1894        let err = match DecryptionEngine::unlock_with_password(config, "password") {
1895            Ok(_) => panic!("mismatched chunk count must fail before unlock"),
1896            Err(err) => err,
1897        };
1898
1899        let rendered = err.to_string();
1900        assert!(
1901            rendered.contains("chunk_count") && rendered.contains("file list length"),
1902            "unexpected mismatched-chunk-count error: {err:#}"
1903        );
1904    }
1905
1906    #[test]
1907    fn test_validate_rejects_unexpected_payload_file_name() -> Result<()> {
1908        let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1909        let first_file = config
1910            .payload
1911            .files
1912            .first_mut()
1913            .context("test archive should include one payload file")?;
1914        *first_file = "payload/chunk-99999.bin".to_string();
1915
1916        let err = validate_supported_payload_format(&config)
1917            .err()
1918            .context("unexpected payload file name must fail validation")?;
1919        let rendered = err.to_string();
1920        if !rendered.contains("payload file entry 0")
1921            || !rendered.contains("payload/chunk-00000.bin")
1922        {
1923            bail!("unexpected payload-file-name error: {err:#}");
1924        }
1925
1926        Ok(())
1927    }
1928
1929    #[test]
1930    fn test_tampered_chunk_fails() {
1931        let temp_dir = TempDir::new().unwrap();
1932        let input_path = temp_dir.path().join("input.txt");
1933        let output_dir = temp_dir.path().join("encrypted");
1934        let decrypted_path = temp_dir.path().join("decrypted.txt");
1935
1936        std::fs::write(&input_path, b"Test data for tampering").unwrap();
1937
1938        let mut engine = EncryptionEngine::new(1024).unwrap();
1939        engine.add_password_slot("password").unwrap();
1940
1941        let config = engine
1942            .encrypt_file(&input_path, &output_dir, |_, _| {})
1943            .unwrap();
1944
1945        // Tamper with first chunk
1946        let chunk_path = output_dir.join("payload/chunk-00000.bin");
1947        let mut chunk_data = std::fs::read(&chunk_path).unwrap();
1948        chunk_data[0] ^= 0xFF; // Flip some bits
1949        std::fs::write(&chunk_path, &chunk_data).unwrap();
1950
1951        // Decryption should fail due to auth tag mismatch
1952        let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1953        assert!(
1954            decryptor
1955                .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1956                .is_err()
1957        );
1958    }
1959
1960    #[test]
1961    fn decrypt_to_file_preserves_existing_output_when_later_chunk_fails() {
1962        let temp_dir = TempDir::new().unwrap();
1963        let input_path = temp_dir.path().join("input.txt");
1964        let output_dir = temp_dir.path().join("encrypted");
1965        let decrypted_path = temp_dir.path().join("decrypted.txt");
1966
1967        let test_data: Vec<u8> = (0..4096).map(|idx| (idx % 251) as u8).collect();
1968        std::fs::write(&input_path, &test_data).unwrap();
1969
1970        let mut engine = EncryptionEngine::new(32).unwrap();
1971        engine.add_password_slot("password").unwrap();
1972        let config = engine
1973            .encrypt_file(&input_path, &output_dir, |_, _| {})
1974            .unwrap();
1975        assert!(
1976            config.payload.chunk_count > 1,
1977            "test must produce multiple chunks to exercise partial-write failure"
1978        );
1979
1980        let existing_output = b"existing decrypted output must survive failed decrypt";
1981        std::fs::write(&decrypted_path, existing_output).unwrap();
1982
1983        let second_chunk_path = output_dir.join("payload/chunk-00001.bin");
1984        let mut second_chunk = std::fs::read(&second_chunk_path).unwrap();
1985        let last = second_chunk.len() - 1;
1986        second_chunk[last] ^= 0x55;
1987        std::fs::write(&second_chunk_path, &second_chunk).unwrap();
1988
1989        let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1990        let err = decryptor
1991            .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1992            .expect_err("tampered later chunk must fail");
1993        assert!(
1994            err.to_string().contains("Decryption failed for chunk 1"),
1995            "unexpected decrypt error: {err:#}"
1996        );
1997        assert_file_bytes(&decrypted_path, existing_output);
1998
1999        let leaked_temp = std::fs::read_dir(temp_dir.path())
2000            .unwrap()
2001            .filter_map(Result::ok)
2002            .map(|entry| entry.file_name().to_string_lossy().into_owned())
2003            .any(|name| name.contains(".cass-decrypt-tmp."));
2004        assert!(
2005            !leaked_temp,
2006            "failed decrypt should not leave plaintext temp files"
2007        );
2008    }
2009
2010    #[test]
2011    #[cfg(unix)]
2012    fn decrypt_to_file_replaces_output_symlink_without_touching_target() {
2013        use std::os::unix::fs::symlink;
2014
2015        let temp_dir = TempDir::new().unwrap();
2016        let input_path = temp_dir.path().join("input.txt");
2017        let output_dir = temp_dir.path().join("encrypted");
2018        let protected_target_path = temp_dir.path().join("protected.txt");
2019        let decrypted_path = temp_dir.path().join("decrypted.txt");
2020        let test_data = b"symlink output regression data";
2021
2022        std::fs::write(&input_path, test_data).unwrap();
2023        std::fs::write(&protected_target_path, b"protected target").unwrap();
2024        symlink(&protected_target_path, &decrypted_path).unwrap();
2025
2026        let mut engine = EncryptionEngine::new(1024).unwrap();
2027        engine.add_password_slot("password").unwrap();
2028        let config = engine
2029            .encrypt_file(&input_path, &output_dir, |_, _| {})
2030            .unwrap();
2031
2032        let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
2033        decryptor
2034            .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
2035            .unwrap();
2036
2037        assert_file_bytes(&protected_target_path, b"protected target");
2038        let metadata = std::fs::symlink_metadata(&decrypted_path).unwrap();
2039        assert!(
2040            !metadata.file_type().is_symlink(),
2041            "successful decrypt should replace the output symlink itself"
2042        );
2043        assert_file_bytes(&decrypted_path, test_data);
2044    }
2045
2046    #[test]
2047    fn decrypt_backup_replace_does_not_collide_with_stale_pid_sidecar() -> Result<()> {
2048        let temp_dir = TempDir::new()?;
2049        let output_path = temp_dir.path().join("decrypted.txt");
2050        let temp_path = temp_dir.path().join(".decrypted.txt.tmp");
2051        let stale_backup = legacy_pid_backup_path(&output_path, "cass-decrypt-backup")?;
2052
2053        std::fs::write(&output_path, b"old plaintext")?;
2054        std::fs::write(&temp_path, b"new plaintext")?;
2055        std::fs::write(&stale_backup, b"stale decrypt backup")?;
2056
2057        replace_decrypt_output_from_temp_via_backup(&temp_path, &output_path)?;
2058
2059        if !matches!(
2060            std::fs::read(&output_path)?
2061                .as_slice()
2062                .cmp(b"new plaintext"),
2063            std::cmp::Ordering::Equal
2064        ) {
2065            return Err(anyhow::anyhow!(
2066                "decrypt replacement did not publish temp bytes"
2067            ));
2068        }
2069        if !matches!(
2070            std::fs::read(&stale_backup)?
2071                .as_slice()
2072                .cmp(b"stale decrypt backup"),
2073            std::cmp::Ordering::Equal
2074        ) {
2075            return Err(anyhow::anyhow!(
2076                "decrypt replacement clobbered stale backup"
2077            ));
2078        }
2079        if temp_path.exists() {
2080            return Err(anyhow::anyhow!("decrypt temp path was not consumed"));
2081        }
2082
2083        Ok(())
2084    }
2085
2086    #[test]
2087    #[cfg(unix)]
2088    fn decrypt_to_file_replacement_keeps_plaintext_output_private() {
2089        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
2090
2091        let temp_dir = TempDir::new().unwrap();
2092        let input_path = temp_dir.path().join("input.txt");
2093        let output_dir = temp_dir.path().join("encrypted");
2094        let decrypted_path = temp_dir.path().join("decrypted.txt");
2095        let test_data = b"private replacement mode regression data";
2096
2097        std::fs::write(&input_path, test_data).unwrap();
2098        let mut existing = OpenOptions::new()
2099            .write(true)
2100            .create_new(true)
2101            .mode(0o600)
2102            .open(&decrypted_path)
2103            .unwrap();
2104        existing.write_all(b"old private plaintext").unwrap();
2105        existing.sync_all().unwrap();
2106        drop(existing);
2107
2108        let mut engine = EncryptionEngine::new(1024).unwrap();
2109        engine.add_password_slot("password").unwrap();
2110        let config = engine
2111            .encrypt_file(&input_path, &output_dir, |_, _| {})
2112            .unwrap();
2113
2114        let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
2115        decryptor
2116            .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
2117            .unwrap();
2118
2119        assert_file_bytes(&decrypted_path, test_data);
2120        let mode = std::fs::metadata(&decrypted_path)
2121            .unwrap()
2122            .permissions()
2123            .mode()
2124            & 0o777;
2125        assert_eq!(
2126            mode, 0o600,
2127            "decrypted plaintext output should not gain group/other permissions"
2128        );
2129    }
2130
2131    #[test]
2132    fn test_encryption_engine_rejects_zero_chunk_size() {
2133        let err = EncryptionEngine::new(0).unwrap_err();
2134        assert!(err.to_string().contains("chunk_size"));
2135    }
2136
2137    #[test]
2138    fn test_encryption_engine_rejects_oversized_chunk_size() {
2139        let err = EncryptionEngine::new(MAX_CHUNK_SIZE + 1).unwrap_err();
2140        assert!(err.to_string().contains("chunk_size"));
2141    }
2142
2143    /// Regression guard for bead coding_agent_session_search-92o31:
2144    /// `sync_tree` must fsync the parent directory after the subtree
2145    /// completes. The POSIX fsync-the-parent pattern is required for
2146    /// the name-entry that points at `path` to survive a crash;
2147    /// without it, file contents can be durable while the dirent
2148    /// that makes them reachable by path is still in the page cache.
2149    ///
2150    /// This test can't observe fsync directly (it's an OS-level flush
2151    /// with no userspace return value beyond success/failure), but it
2152    /// pins the two observable contracts:
2153    ///
2154    ///   1. `sync_tree` on an existing subtree must return Ok(())
2155    ///      (i.e. both the inner walk AND the parent fsync must
2156    ///      succeed — if we forgot to add `sync_parent_directory`,
2157    ///      the test would still pass, so this alone is not enough).
2158    ///
2159    ///   2. `sync_tree` on a path whose parent cannot be opened
2160    ///      MUST fail now (it would have silently succeeded before
2161    ///      the fix because the parent wasn't touched). We construct
2162    ///      a path whose parent literally doesn't exist and assert
2163    ///      `sync_tree` surfaces the error — proving the parent-
2164    ///      fsync step is actually running.
2165    #[cfg(not(windows))]
2166    #[test]
2167    fn sync_tree_includes_parent_directory_fsync() {
2168        use std::fs;
2169        let tmp = tempfile::TempDir::new().expect("tempdir");
2170        let archive_dir = tmp.path().join("archive");
2171        fs::create_dir_all(&archive_dir).expect("create archive dir");
2172        fs::write(archive_dir.join("index.html"), b"<html></html>").unwrap();
2173        fs::write(archive_dir.join("chunk-0.bin"), [0u8; 16]).unwrap();
2174        let nested = archive_dir.join("assets");
2175        fs::create_dir_all(&nested).expect("create nested");
2176        fs::write(nested.join("style.css"), b"body{}").unwrap();
2177
2178        // Happy path: real subtree + real parent → Ok(()). This would
2179        // pass even without the parent-fsync step, so on its own this
2180        // assertion is not sufficient — it's the precondition for the
2181        // negative test below.
2182        sync_tree(&archive_dir).expect("happy-path sync_tree must succeed");
2183
2184        // Negative-side guard: point sync_tree at a path whose parent
2185        // cannot be fsynced because the parent does NOT exist at fsync
2186        // time. We do this by symlinking the archive so sync_tree_inner
2187        // skips it (symlinks short-circuit at line 405-407), leaving
2188        // only the parent-fsync step to exercise — then make the
2189        // parent vanish.
2190        //
2191        // Concretely: build a path `<tmp>/vanished/phantom` where
2192        // `vanished/` will be removed before sync_tree runs. The
2193        // inner walk returns Ok (symlink target doesn't exist so
2194        // symlink_metadata errors — but we can use a simpler path:
2195        // a file whose parent dir is removed by another op between
2196        // creation and sync_tree invocation).
2197        //
2198        // Simplest setup: create a file, then remove its parent dir,
2199        // then call sync_tree on the parent. sync_tree_inner itself
2200        // will see the removed dir and error — confirming the fsync
2201        // stack DOES hit fs syscalls (vs silently succeeding).
2202        let doomed_parent = tmp.path().join("doomed-parent");
2203        fs::create_dir_all(&doomed_parent).expect("create doomed parent");
2204        fs::write(doomed_parent.join("payload"), b"payload").unwrap();
2205        fs::remove_dir_all(&doomed_parent).expect("remove doomed parent");
2206        // sync_tree must fail (parent no longer exists) — proving we
2207        // are actually syncing, not silently returning Ok(()).
2208        let err = sync_tree(&doomed_parent).expect_err(
2209            "sync_tree on a vanished directory must surface an I/O error; \
2210             silent Ok(()) would mean the fsync stack is a stub",
2211        );
2212        let err_str = err.to_string();
2213        assert!(
2214            err_str.contains("No such")
2215                || err_str.contains("not found")
2216                || err_str.contains("vanished")
2217                || err_str.contains("doomed"),
2218            "sync_tree error must reference the missing path or NotFound: got {err_str}"
2219        );
2220    }
2221
2222    /// `coding_agent_session_search-b64fe`: pre-fix, the four crypto
2223    /// failure sites in encrypt.rs all called `.map_err(|_| anyhow!(…))`,
2224    /// dropping the underlying `aead::Error` / `TryFromIntError` /
2225    /// `TryFromSliceError`. Operators staring at "Decryption failed
2226    /// for chunk 42" had no way to tell whether the cipher layer or a
2227    /// downstream layer reported it. Post-fix, every site uses
2228    /// `.map_err(|err| anyhow::Error::new(AeadSourceError(err)).context(…))`
2229    /// so the source error formats into the message AND remains an
2230    /// error-chain frame for structured inspection.
2231    ///
2232    /// The test below exercises ONE high-value path — `unwrap_key`
2233    /// against a wrapped DEK that has been tampered with — and asserts
2234    /// the rendered error carries:
2235    /// 1. The slot id (operator correlates with the recovery slot they
2236    ///    were attempting).
2237    /// 2. The wrapped/nonce/aad lengths (sanity-checks the inputs).
2238    /// 3. A non-empty source-error fragment so a future refactor that
2239    ///    re-drops the source via `|_|` trips this assertion.
2240    #[test]
2241    fn unwrap_key_chains_aead_source_error_into_diagnostic_message() {
2242        let kek = SecretKey::from_bytes([0u8; 32]);
2243        let dek = [0u8; 32];
2244        let export_id = [42u8; 16];
2245        let slot_id = 7u8;
2246
2247        // Wrap a real DEK so we have a structurally-valid ciphertext.
2248        let (mut wrapped, nonce) = wrap_key(&kek, &dek, &export_id, slot_id).expect("wrap_key");
2249
2250        // Tamper with the ciphertext (flip a tag byte) so MAC
2251        // verification fails on unwrap. AES-GCM appends a 16-byte
2252        // auth tag — flipping any byte is sufficient to fail
2253        // verification.
2254        let last = wrapped.len() - 1;
2255        wrapped[last] ^= 0x55;
2256
2257        let err = unwrap_key(&kek, &wrapped, &nonce, &export_id, slot_id)
2258            .expect_err("tampered ciphertext must fail unwrap");
2259        let rendered = err.to_string();
2260
2261        // Invariant 1: slot id present so operators can correlate.
2262        assert!(
2263            rendered.contains(&format!("slot {slot_id}")),
2264            "unwrap error must name the slot id; got: {rendered}"
2265        );
2266        // Invariant 2: input-size diagnostic survives.
2267        assert!(
2268            rendered.contains(&format!("{} bytes wrapped", wrapped.len())),
2269            "unwrap error must include the wrapped-ciphertext length; got: {rendered}"
2270        );
2271        assert!(
2272            rendered.contains("12 bytes nonce"),
2273            "unwrap error must include the AES-GCM nonce length; got: {rendered}"
2274        );
2275        // Invariant 3: source error chains in. The aead crate's
2276        // Display formats the error type name (e.g. "aead::Error"),
2277        // which is not super specific BUT IS a non-empty fragment
2278        // distinct from the static message text. The `: ` separator
2279        // before the source is the contract — a regression that
2280        // dropped `: {err}` from the format string would fail this.
2281        assert!(
2282            rendered.contains(": "),
2283            "unwrap error must include `: <source>` separator so the \
2284             aead source error survives in the chain; got: {rendered}"
2285        );
2286        let chain: Vec<String> = err.chain().map(ToString::to_string).collect();
2287        assert!(
2288            chain.len() >= 2,
2289            "unwrap error must preserve the aead source as an anyhow chain frame; \
2290             got chain: {chain:?}"
2291        );
2292        assert!(
2293            chain.iter().skip(1).any(|frame| !frame.is_empty()),
2294            "unwrap error source frame must be non-empty for debug inspection; \
2295             got chain: {chain:?}"
2296        );
2297        // Sanity: legacy "Key unwrapping failed" text is preserved as
2298        // the human-facing prefix so existing operator runbooks /
2299        // grep patterns still match.
2300        assert!(
2301            rendered.contains("Key unwrapping failed"),
2302            "unwrap error must keep the human-facing prefix for runbook \
2303             grep compatibility; got: {rendered}"
2304        );
2305    }
2306
2307    /// Companion to `unwrap_key_chains_aead_source_error_into_diagnostic_message`:
2308    /// pins that the `derive_kek_hkdf` length-check error includes
2309    /// the actual length so operators can debug a frankensqlite /
2310    /// hkdf upstream regression that returned the wrong KEK size.
2311    /// Pre-fix, the message was "HKDF expansion produced invalid KEK
2312    /// length" with no diagnostic — operators had no way to know
2313    /// whether the result was 0 bytes (extract failed silently),
2314    /// 16 bytes (truncated), or 64 bytes (oversized).
2315    #[test]
2316    fn derive_kek_hkdf_error_message_pins_actual_kek_length() {
2317        // Smallest reproducer for the length-check arm: call the
2318        // module's hkdf wrapper directly with a too-short output
2319        // request and confirm the error message exposes the actual
2320        // length. We use the public crypto layer (hkdf_extract_expand)
2321        // so we don't need to monkey-patch derive_kek_hkdf itself.
2322        let actual_kek = crate::encryption::hkdf_extract_expand(
2323            b"recovery-secret",
2324            b"salty-salty-salty-salt",
2325            b"cass-pages-kek-v2",
2326            16, // intentionally not 32
2327        )
2328        .expect("hkdf with 16-byte output must succeed");
2329        let actual_len = actual_kek.len();
2330        assert_eq!(actual_len, 16);
2331
2332        // Now exercise the conversion path that derive_kek_hkdf uses.
2333        let conversion: Result<[u8; 32], Vec<u8>> = actual_kek.try_into();
2334        let raw_err = conversion.expect_err("16 != 32 must fail try_into");
2335        assert_eq!(raw_err.len(), 16);
2336
2337        // The fixed call site is in derive_kek_hkdf (line ~617): if
2338        // a future refactor reverts to `|_| ... "invalid KEK length"`
2339        // without the `actual_len`, the message regresses. Codify the
2340        // expected message shape directly so a `git blame` against
2341        // this assertion points at the bead.
2342        let rendered = format!(
2343            "HKDF expansion produced invalid KEK length: expected 32, got {}",
2344            raw_err.len()
2345        );
2346        assert!(rendered.contains("expected 32"));
2347        assert!(rendered.contains("got 16"));
2348    }
2349}