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