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