Skip to main content

git_simple_encrypt/
crypt.rs

1//! The core of this program. Encrypt/decrypt, compress/decompress files.
2//!
3//! GITSE Binary Header Layout (64 Bytes)
4//!  00          04  05  06  07           17                  27              3F
5//!  +-----------+---+---+---+-----------+-------------------+---------------+
6//!  |   MAGIC   | V | F | A |   SALT    |     `FILE_ID`     |   RESERVED    |
7//!  |  "GITSE"  |   |   |   | (16 bytes)|    (16 bytes)     |  (24 bytes)   |
8//!  +-----------+---+---+---+-----------+-------------------+---------------+
9//!    5 bytes     1   1   1    16 bytes       16 bytes          24 bytes
10//!                |   |   |
11//!     Version ---+   |   +--- Encryption Algo (1 = XChaCha20-Poly1305 Stream)
12//!                    |
13//!      Flags --------+ (Bit 0: Compression)
14//!
15//! Streaming Format:
16//! Files are processed in 64KB chunks.
17//! Each chunk is individually encrypted using XChaCha20-Poly1305.
18//!
19//! # Nonce Derivation (Content-Based with File ID)
20//!
21//! Per-chunk nonces are derived from the file's random `File_ID` and the
22//! chunk's own plaintext content using keyed Blake3:
23//!
24//! 1. A random 16-byte `File_ID` is generated once per file and stored in the
25//!    header. This ensures that even if two different files have identical
26//!    plaintext at chunk 0, they produce different nonces and ciphertexts.
27//! 2. The Argon2-derived master key is split via `blake3::derive_key` into
28//!    `Key_ENC` (for XChaCha20-Poly1305 encryption) and `Key_MAC` (for nonce
29//!    generation).
30//! 3. For each chunk `i`: `Nonce_i = Blake3_keyed(Key_MAC, File_ID || M_i ||
31//!    chunk_idx_le)[0..24]`
32//! 4. The 24-byte nonce is stored in plaintext at the head of each encrypted
33//!    chunk.
34//!
35//! Different plaintext always produces a different nonce (within the same
36//! file). The `File_ID` ensures cross-file uniqueness. The chunk index prevents
37//! reordering attacks on identical 64 KB blocks.
38//!
39//! # Authenticated Additional Data (AAD)
40//!
41//! Each chunk's AAD binds the ciphertext to the full file header so that any
42//! tampering with header fields (version, compression flag, salt, `file_id`,
43//! reserved) is detected via Poly1305 authentication failure:
44//!
45//! ```text
46//! AAD = HEADER (64B) || chunk_idx (8B LE) || is_last_chunk (1B)   // 73 bytes
47//! ```
48//!
49//! Each encrypted chunk layout: `[NONCE (24B)] [CIPHERTEXT] [TAG (16B)]`
50
51use std::{
52    fs,
53    io::{Read, Seek, SeekFrom, Write},
54    path::{Path, PathBuf},
55    sync::{
56        Arc, OnceLock,
57        atomic::{AtomicUsize, Ordering},
58    },
59};
60
61use argon2::Argon2;
62use chacha20poly1305::{
63    XChaCha20Poly1305, XNonce,
64    aead::{Aead, KeyInit, Payload},
65};
66use dashmap::DashMap;
67use log::{debug, warn};
68use pathdiff::diff_paths;
69use rand::prelude::*;
70use rayon::prelude::*;
71use tempfile::NamedTempFile;
72use zeroize::Zeroizing;
73
74use crate::{
75    error::{Error, Result},
76    repo::Repo,
77    salt_cache::{self, CacheRef, CachedEntry},
78    utils::{
79        Progress, is_file_encrypted, print_post_report, print_pre_report, resolve_target_files,
80    },
81};
82
83// --- Constants & Header Layout ---
84
85pub const MAGIC: &[u8; 5] = b"GITSE";
86/// Current encryption format version.
87pub const VERSION: u8 = 3;
88const FLAG_COMPRESSED: u8 = 1 << 0; // Bit 0
89const ENC_ALGO: u8 = 1; // 1 = XChaCha20-Poly1305
90
91// Sizes
92pub const SALT_LEN: usize = 16;
93pub const FILE_ID_LEN: usize = 16;
94pub const NONCE_LEN: usize = 24; // XChaCha20 uses a 192-bit (24-byte) nonce
95pub const HEADER_LEN: usize = 64;
96const RESERVED_LEN: usize = HEADER_LEN - (MAGIC.len() + 1 + 1 + 1 + SALT_LEN + FILE_ID_LEN); // 24 bytes
97
98// Streaming
99const CHUNK_SIZE: usize = 65536; // 64 KB chunks
100
101/// Returns `true` if the given version byte is supported for decryption.
102#[inline]
103#[must_use]
104pub const fn is_encrypted_version(v: u8) -> bool {
105    v == VERSION
106}
107
108// --- Helper Structures ---
109
110/// Fixed-size file header stored at the beginning of every encrypted file.
111///
112/// The layout is `#[repr(C)]` with all fields being `u8` or `[u8; N]`, so:
113/// - **Alignment** = 1 (same as `u8`)
114/// - **Size** = exactly `HEADER_LEN` (64 bytes)
115/// - **No padding** — the compiler cannot insert any between `u8`-aligned
116///   fields
117///
118/// This makes zero-copy casting to/from `[u8; HEADER_LEN]` sound, verified by
119/// the compile-time assertions below.
120#[repr(C)]
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct FileHeader {
123    magic: [u8; 5],
124    version: u8,
125    flags: u8,
126    enc_algo: u8,
127    salt: [u8; SALT_LEN],
128    file_id: [u8; FILE_ID_LEN],
129    reserved: [u8; RESERVED_LEN],
130}
131
132// Compile-time safety invariants for zero-copy casting.
133const _: () = assert!(std::mem::size_of::<FileHeader>() == HEADER_LEN);
134const _: () = assert!(std::mem::align_of::<FileHeader>() == 1);
135
136impl FileHeader {
137    /// Construct a header with explicit `file_id`. Callers are responsible for
138    /// generating (or looking up the cached) `file_id` so that re-encryption is
139    /// deterministic.
140    #[must_use]
141    pub const fn new(compressed: bool, salt: [u8; SALT_LEN], file_id: [u8; FILE_ID_LEN]) -> Self {
142        let mut flags = 0u8;
143        if compressed {
144            flags |= FLAG_COMPRESSED;
145        }
146
147        Self {
148            magic: *MAGIC,
149            version: VERSION,
150            flags,
151            enc_algo: ENC_ALGO,
152            salt,
153            file_id,
154            reserved: [0u8; RESERVED_LEN],
155        }
156    }
157
158    /// Generate a fresh random `file_id` using the system RNG.
159    #[must_use]
160    pub fn generate_file_id() -> [u8; FILE_ID_LEN] {
161        let mut rng = rand::rng();
162        let mut id = [0u8; FILE_ID_LEN];
163        rng.fill_bytes(&mut id);
164        id
165    }
166
167    /// Zero-copy: validate and cast `&[u8; 64]` → `&FileHeader`.
168    ///
169    /// The returned reference borrows from `bytes` — no allocation or copying.
170    pub fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result<&Self> {
171        // SAFETY: FileHeader is #[repr(C)] with only u8/[u8; N] fields.
172        // Alignment == 1, size == HEADER_LEN, no padding — verified by the
173        // compile-time assertions above.
174        let header: &Self = unsafe { &*(bytes.as_ptr().cast()) };
175
176        if &header.magic != MAGIC {
177            return Err(Error::InvalidMagic);
178        }
179        if !is_encrypted_version(header.version) {
180            return Err(Error::UnsupportedVersion(header.version));
181        }
182        if header.enc_algo != ENC_ALGO {
183            return Err(Error::UnsupportedAlgo(header.enc_algo));
184        }
185
186        Ok(header)
187    }
188
189    /// Read 64 bytes from `reader`, validate, and return an owned header.
190    ///
191    /// This is a convenience wrapper around [`from_bytes`](Self::from_bytes)
192    /// for callers that already have a `Read` impl (e.g. integration tests).
193    pub fn read_from<R: Read>(reader: &mut R) -> Result<Self> {
194        let mut buf = [0u8; HEADER_LEN];
195        reader.read_exact(&mut buf)?;
196        Ok(*Self::from_bytes(&buf)?)
197    }
198
199    /// Write the header bytes to `writer` (zero-copy via [`as_bytes`]).
200    pub fn write_to<W: Write>(&self, writer: &mut W) -> Result<()> {
201        writer.write_all(self.as_bytes())?;
202        Ok(())
203    }
204
205    /// Zero-copy: view the header as a fixed-size byte slice.
206    ///
207    /// The returned `&[u8; HEADER_LEN]` borrows from `self`.
208    #[must_use]
209    #[inline]
210    pub const fn as_bytes(&self) -> &[u8; HEADER_LEN] {
211        // SAFETY: Same reasoning as from_bytes — #[repr(C)], align 1,
212        // size == HEADER_LEN, no padding.
213        unsafe { &*std::ptr::from_ref::<Self>(self).cast() }
214    }
215
216    #[must_use]
217    pub const fn is_compressed(&self) -> bool {
218        (self.flags & FLAG_COMPRESSED) != 0
219    }
220}
221
222// --- Core Logic ---
223
224/// Derive a file-specific key using Argon2.
225/// Input: User Master Key (bytes) + File Salt.
226/// Output: 32 bytes (master key, to be split into `Key_ENC` + `Key_MAC`).
227fn derive_key(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
228    let mut key = Zeroizing::new([0u8; 32]);
229    Argon2::default()
230        .hash_password_into(password, salt, &mut *key)
231        .map_err(|e| Error::Argon2(e.to_string()))?;
232    Ok(key)
233}
234
235/// Split the Argon2-derived master key into `Key_ENC` (encryption) and
236/// `Key_MAC` (nonce generation) using blake3's `derive_key` (HKDF-like).
237fn split_keys(master_key: &[u8; 32]) -> (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>) {
238    let key_enc = blake3::derive_key("git-simple-encrypt-enc", master_key);
239    let key_mac = blake3::derive_key("git-simple-encrypt-mac", master_key);
240    (Zeroizing::new(key_enc), Zeroizing::new(key_mac))
241}
242
243/// Content-based nonce derivation using keyed Blake3 with `File_ID`.
244///
245/// Computes: `Blake3_keyed(Key_MAC, File_ID || plaintext ||
246/// chunk_idx_le)[0..24]`
247///
248/// The `File_ID` ensures cross-file uniqueness: even if two different files
249/// have identical plaintext at the same chunk index, they produce different
250/// nonces. The chunk index prevents reordering attacks on identical 64 KB
251/// blocks.
252fn derive_nonce(
253    key_mac: &[u8; 32],
254    file_id: &[u8; FILE_ID_LEN],
255    plaintext: &[u8],
256    chunk_idx: u64,
257) -> [u8; NONCE_LEN] {
258    let mut hasher = blake3::Hasher::new_keyed(key_mac);
259    hasher.update(file_id);
260    hasher.update(plaintext);
261    hasher.update(&chunk_idx.to_le_bytes());
262    let hash = hasher.finalize();
263    let mut nonce = [0u8; NONCE_LEN];
264    nonce.copy_from_slice(&hash.as_bytes()[..NONCE_LEN]);
265    nonce
266}
267
268/// Safely persist a temporary file while retaining the original file's metadata
269/// (permissions, timestamps).
270fn atomic_write_with_metadata(original_path: &Path, temp_file: NamedTempFile) -> Result<()> {
271    // Attempt to copy metadata. If it fails, we log a warning but proceed to avoid
272    // data loss.
273    if let Err(e) = copy_metadata::copy_metadata(original_path, temp_file.path()) {
274        warn!(
275            "Could not copy metadata for {}: {}",
276            original_path.display(),
277            e
278        );
279    }
280    temp_file
281        .persist(original_path)
282        .map_err(|e| Error::AtomicPersist(original_path.to_path_buf(), e.to_string()))?;
283    Ok(())
284}
285
286/// Compute a repo-relative cache key from a file path.
287///
288/// `list_files` already produces repo-relative paths, so the common case
289/// (relative input) avoids the absolutize+diff round-trip and is reused
290/// directly. Absolute inputs (e.g. user-supplied) are diffed against
291/// `repo_path`. The result is raw OS-encoded bytes with `b'\\'` replaced by
292/// `b'/'` for cross-platform consistency.
293fn cache_key(file_path: &Path, repo_path: &Path) -> Vec<u8> {
294    let relative = if file_path.is_absolute() {
295        diff_paths(file_path, repo_path).unwrap_or_else(|| file_path.to_path_buf())
296    } else {
297        file_path.to_path_buf()
298    };
299    let mut bytes = relative.into_os_string().into_encoded_bytes();
300    for b in &mut bytes {
301        if *b == b'\\' {
302            *b = b'/';
303        }
304    }
305    bytes
306}
307
308// --- Key Derivation Cache (thundering-herd safe) ---
309
310/// Thread-safe key derivation cache with thundering-herd protection.
311///
312/// Uses `Arc<OnceLock>` per salt so that when multiple threads encounter the
313/// same salt simultaneously (e.g. new files sharing a `batch_salt`), only ONE
314/// thread performs the expensive Argon2 computation; all others block on the
315/// `OnceLock` and then clone the result.
316///
317/// The `Arc` allows cloning the handle out of the `DashMap` guard, releasing
318/// the internal shard lock before the Argon2 computation starts. This prevents
319/// contention on other keys sharing the same shard.
320type KeyCache = DashMap<[u8; SALT_LEN], Arc<OnceLock<Result<Zeroizing<[u8; 32]>, String>>>>;
321
322/// Retrieve or derive a key for the given salt, with thundering-herd
323/// protection.
324///
325/// If the key has already been computed, returns a clone immediately.
326/// If multiple threads arrive simultaneously for the same salt, only one
327/// performs the Argon2 computation; the others block and then clone the result.
328fn get_or_derive_key(
329    key_cache: &KeyCache,
330    master_key: &[u8],
331    salt: &[u8; SALT_LEN],
332) -> Result<Zeroizing<[u8; 32]>> {
333    // Atomically insert a placeholder OnceLock if this salt is new.
334    // We clone the Arc so that the DashMap shard lock is released before
335    // the potentially slow Argon2 computation, preventing contention on
336    // other keys in the same shard.
337    let lock = {
338        let guard = key_cache
339            .entry(*salt)
340            .or_insert_with(|| Arc::new(OnceLock::new()));
341        Arc::clone(&*guard)
342    };
343
344    // Only the first thread to reach get_or_init runs the closure;
345    // all others block until the result is available.
346    match lock.get_or_init(|| derive_key(master_key, salt).map_err(|e| e.to_string())) {
347        Ok(key) => Ok(key.clone()),
348        Err(msg) => Err(Error::Argon2(msg.clone())),
349    }
350}
351
352// --- Public Operations ---
353
354/// Encrypt a single file using streaming chunked encryption.
355///
356/// Returns `Some(header)` on success, or `None` if the file was already
357/// encrypted and skipped.
358pub fn encrypt_file(
359    path: &Path,
360    derived_key: &[u8; 32],
361    salt: &[u8; SALT_LEN],
362    file_id: Option<[u8; FILE_ID_LEN]>,
363    zstd: Option<u8>,
364) -> Result<Option<FileHeader>> {
365    let mut file = fs::File::open(path)?;
366
367    // 1. Quick check if already encrypted
368    let mut header_bytes = [0u8; HEADER_LEN];
369    if file.read_exact(&mut header_bytes).is_ok()
370        && &header_bytes[0..5] == MAGIC
371        && is_encrypted_version(header_bytes[5])
372    {
373        warn!("File already encrypted, skipping: {}", path.display());
374        return Ok(None);
375    }
376    file.seek(SeekFrom::Start(0))?; // Rewind to start
377
378    debug!("Encrypting: {}", path.display());
379
380    // 2. Generate File_ID (random if not cached) and prepare Header & Temp File
381    let file_id = file_id.unwrap_or_else(FileHeader::generate_file_id);
382    let header = FileHeader::new(zstd.is_some(), *salt, file_id);
383    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
384    let mut temp_file = NamedTempFile::new_in(parent_dir)?;
385
386    header.write_to(&mut temp_file)?;
387
388    // 3. Split master key into encryption key and MAC key, then setup cipher.
389    let (key_enc, key_mac) = split_keys(derived_key);
390    let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
391
392    // If compression is enabled, wrap the file reader in a Zstd Encoder
393    let mut reader: Box<dyn Read> = if let Some(zstd_level) = zstd {
394        Box::new(zstd::stream::read::Encoder::new(
395            file,
396            i32::from(zstd_level),
397        )?)
398    } else {
399        Box::new(file)
400    };
401
402    // 4. Chunked Encryption Loop — content-based nonce derivation with File_ID. AAD
403    //    header (64B) is invariant across chunks, so we pre-fill it once outside
404    //    the loop and only patch the 9 trailing bytes per chunk.
405    let mut buffer = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
406    let mut out_buf: Vec<u8> = Vec::with_capacity(NONCE_LEN + CHUNK_SIZE + 16);
407    let mut aad = {
408        let mut aad = [0u8; HEADER_LEN + 9];
409        aad[..HEADER_LEN].copy_from_slice(header.as_bytes());
410        aad
411    };
412    let mut chunk_idx = 0u64;
413
414    loop {
415        let mut bytes_read = 0;
416        while bytes_read < CHUNK_SIZE {
417            let n = reader.read(&mut buffer[bytes_read..])?;
418            if n == 0 {
419                break;
420            }
421            bytes_read += n;
422        }
423
424        let is_last_chunk = bytes_read < CHUNK_SIZE;
425        // Only the trailing 9 bytes of AAD change per chunk.
426        aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
427        aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
428
429        // Derive nonce from File_ID + current chunk's plaintext using keyed Blake3.
430        let nonce_bytes = derive_nonce(&key_mac, &file_id, &buffer[..bytes_read], chunk_idx);
431        let nonce = XNonce::from(nonce_bytes);
432
433        let payload = Payload {
434            msg: &buffer[..bytes_read],
435            aad: &aad,
436        };
437
438        let ciphertext = cipher
439            .encrypt(&nonce, payload)
440            .map_err(|e| Error::EncryptFailed(e.to_string()))?;
441
442        // Single write per chunk: nonce (24B) + ciphertext (incl. 16B Poly1305 tag).
443        out_buf.clear();
444        out_buf.extend_from_slice(&nonce_bytes);
445        out_buf.extend_from_slice(&ciphertext);
446        temp_file.write_all(&out_buf)?;
447
448        chunk_idx += 1;
449
450        if is_last_chunk {
451            break;
452        }
453    }
454
455    // 5. Atomic Write with Metadata Preservation
456    drop(reader);
457    atomic_write_with_metadata(path, temp_file)?;
458
459    Ok(Some(header))
460}
461
462/// Decrypt a single file using streaming chunked decryption.
463pub fn decrypt_file(path: &Path, master_key: &[u8]) -> Result<()> {
464    let key_cache: KeyCache = DashMap::new();
465    decrypt_file_with_cache(path, &key_cache, None, master_key)
466}
467
468/// Decrypt a single file using streaming chunked decryption, with a thread-safe
469/// cache for derived keys and an optional cache reference for deterministic
470/// re-encryption.
471///
472/// Pass `cache = None` to skip recording `(salt, file_id)` for the file.
473pub fn decrypt_file_with_cache(
474    path: &Path,
475    key_cache: &KeyCache,
476    cache: Option<CacheRef<'_>>,
477    master_key: &[u8],
478) -> Result<()> {
479    let mut file = fs::File::open(path)?;
480
481    // 1. Read and validate header directly (Redundant I/O fixed)
482    let mut header_bytes = [0u8; HEADER_LEN];
483    if file.read_exact(&mut header_bytes).is_err() {
484        debug!(
485            "File too small to be encrypted, skipping: {}",
486            path.display()
487        );
488        return Ok(());
489    }
490    if &header_bytes[0..5] != MAGIC || !is_encrypted_version(header_bytes[5]) {
491        debug!(
492            "File not encrypted (no magic), skipping: {}",
493            path.display()
494        );
495        return Ok(());
496    }
497
498    debug!("Decrypting: {}", path.display());
499    let header = *FileHeader::from_bytes(&header_bytes)?;
500
501    // Cache the salt+file_id BEFORE decryption so it is preserved even if
502    // decryption fails halfway through.
503    if let Some(cache) = cache {
504        cache.sender.insert(
505            cache.key,
506            CachedEntry {
507                salt: header.salt,
508                file_id: header.file_id,
509            },
510        );
511    }
512
513    // 2. Retrieve or Derive Key (with thundering-herd protection)
514    let derived_key = get_or_derive_key(key_cache, master_key, &header.salt)?;
515
516    // 3. Split master key and setup cipher
517    let (key_enc, _key_mac) = split_keys(&derived_key);
518    let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
519    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
520    let mut temp_file = NamedTempFile::new_in(parent_dir)?;
521
522    // 4. Chunked Decryption Loop
523    if header.is_compressed() {
524        let mut decoder = zstd::stream::write::Decoder::new(&mut temp_file)?.auto_flush();
525        decrypt_chunks(&mut file, &mut decoder, &cipher, header.as_bytes())?;
526        decoder.flush()?;
527    } else {
528        decrypt_chunks(&mut file, &mut temp_file, &cipher, header.as_bytes())?;
529    }
530    drop(file);
531
532    // 5. Atomic Write with Metadata Preservation
533    atomic_write_with_metadata(path, temp_file)?;
534
535    Ok(())
536}
537
538/// Helper function to read ciphertext chunks, decrypt them, and write to the
539/// destination.
540///
541/// Chunk layout: `[NONCE (24B)] [CIPHERTEXT] [TAG (16B)]`
542///
543/// `header_bytes` is the raw 64-byte file header, included in every chunk's
544/// AAD to bind the ciphertext to the exact header that was present during
545/// encryption. Any header tampering will cause Poly1305 authentication failure.
546fn decrypt_chunks(
547    file: &mut fs::File,
548    writer: &mut dyn Write,
549    cipher: &XChaCha20Poly1305,
550    header_bytes: &[u8; HEADER_LEN],
551) -> Result<()> {
552    let mut nonce_buf = [0u8; NONCE_LEN];
553    // Zeroizing so the (briefly buffered) ciphertext is scrubbed on return.
554    let mut ct_buffer = Zeroizing::new(vec![0u8; CHUNK_SIZE + 16]); // ciphertext + Poly1305 tag
555    let ct_len = ct_buffer.len();
556    // Pre-fill the invariant header portion of AAD; only the last 9 bytes
557    // (chunk_idx + is_last_chunk) change per iteration.
558    let mut aad = {
559        let mut aad = [0u8; HEADER_LEN + 9];
560        aad[..HEADER_LEN].copy_from_slice(header_bytes);
561        aad
562    };
563    let mut last_chunk_was_final = false;
564    let mut chunk_idx = 0u64;
565
566    loop {
567        // Read the 24-byte nonce from the chunk header.
568        match file.read_exact(&mut nonce_buf) {
569            Ok(()) => {}
570            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
571            Err(e) => return Err(e.into()),
572        }
573
574        // Read ciphertext + tag (up to CHUNK_SIZE + 16 bytes).
575        let mut bytes_read = 0;
576        while bytes_read < ct_len {
577            let n = file.read(&mut ct_buffer[bytes_read..])?;
578            if n == 0 {
579                break;
580            }
581            bytes_read += n;
582        }
583
584        if bytes_read == 0 {
585            return Err(Error::TruncatedChunk);
586        }
587
588        let is_last_chunk = bytes_read < ct_len;
589
590        // Patch only the 9 trailing bytes of AAD per chunk.
591        aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
592        aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
593
594        let nonce = XNonce::from(nonce_buf);
595        let payload = chacha20poly1305::aead::Payload {
596            msg: &ct_buffer[..bytes_read],
597            aad: &aad,
598        };
599
600        let plaintext = Zeroizing::new(
601            cipher
602                .decrypt(&nonce, payload)
603                .map_err(|e| Error::DecryptFailed(e.to_string()))?,
604        );
605
606        writer.write_all(&plaintext)?;
607
608        chunk_idx += 1;
609
610        if is_last_chunk {
611            last_chunk_was_final = true;
612            break;
613        }
614    }
615
616    if !last_chunk_was_final {
617        return Err(Error::FileTruncated);
618    }
619
620    Ok(())
621}
622
623// --- Repo Integration ---
624
625/// Encrypt given files in the repo. If no paths are given, encrypt all files
626/// in the repo's crypt list.
627///
628/// # Deterministic Re-encryption
629///
630/// Loads the `salt+file_id` cache (populated by a previous decrypt) via mmap +
631/// rkyv zero-copy and reuses cached values so that decrypt→encrypt on
632/// unchanged content produces byte-identical ciphertext. Files not in the
633/// cache share a single new batch salt to minimise Argon2 overhead.
634///
635/// The cache is **read-only** during encryption; only the decrypt path
636/// writes to the cache.
637pub fn encrypt_repo(repo: &Repo, paths: &[PathBuf]) -> Result<()> {
638    let key = repo.get_key()?;
639    if key.is_empty() {
640        return Err(Error::EmptyKey);
641    }
642
643    let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
644    if target_files.is_empty() {
645        return Err(Error::NoFile("encrypt"));
646    }
647
648    // Pre-operation report
649    print_pre_report("Encrypting", &target_files, repo.path());
650
651    // Read-only cache: mmap + rkyv zero-copy. No write during encryption.
652    let reader = salt_cache::SaltCacheReader::load(repo.path());
653
654    let key_cache: KeyCache = DashMap::new();
655
656    // Generate a single batch salt for files that have no cache entry.
657    let mut batch_salt = [0u8; SALT_LEN];
658    rand::rng().fill_bytes(&mut batch_salt);
659
660    let pb = Progress::new(target_files.len(), "Encrypt");
661    let skipped = AtomicUsize::new(0);
662    let failed = AtomicUsize::new(0);
663
664    let result = {
665        let errors: parking_lot::Mutex<Vec<Error>> = parking_lot::Mutex::new(Vec::new());
666        target_files.par_iter().for_each(|f| {
667            let relative_key = cache_key(f, repo.path());
668            let (salt, cached_file_id) = reader
669                .get(&relative_key)
670                .map_or((batch_salt, None), |entry| {
671                    (entry.salt, Some(entry.file_id))
672                });
673
674            // Derive key — thundering-herd safe: only one thread per salt runs Argon2.
675            let derived_key = match get_or_derive_key(&key_cache, key.as_bytes(), &salt) {
676                Ok(k) => k,
677                Err(e) => {
678                    failed.fetch_add(1, Ordering::Relaxed);
679                    errors.lock().push(e);
680                    pb.inc(1);
681                    return;
682                }
683            };
684
685            let r = encrypt_file(
686                f,
687                &derived_key,
688                &salt,
689                cached_file_id,
690                repo.conf.use_zstd.then_some(repo.conf.zstd_level),
691            )
692            .map_err(|e| Error::Other(format!("Failed to encrypt {}: {e}", f.display())));
693
694            match r {
695                Ok(Some(_)) => {}
696                Ok(None) => {
697                    skipped.fetch_add(1, Ordering::Relaxed);
698                }
699                Err(e) => {
700                    failed.fetch_add(1, Ordering::Relaxed);
701                    errors.lock().push(e);
702                }
703            }
704
705            pb.inc(1);
706        });
707        errors.into_inner()
708    };
709
710    pb.finish_and_clear();
711
712    print_post_report(
713        "Encrypt",
714        target_files.len(),
715        skipped.load(Ordering::Relaxed),
716        failed.load(Ordering::Relaxed),
717    );
718
719    if let Some(first) = result.into_iter().next() {
720        return Err(first);
721    }
722
723    Ok(())
724}
725
726/// Decrypt given files in the repo. If no paths are given, decrypt all files
727/// in the repo's crypt list.
728///
729/// The `salt+file_id` of each successfully parsed encrypted file is captured
730/// via an mpsc channel so that a subsequent encrypt can reproduce identical
731/// ciphertext.
732pub fn decrypt_repo(repo: &Repo, paths: &[PathBuf]) -> Result<()> {
733    let key = repo.get_key()?;
734    if key.is_empty() {
735        return Err(Error::EmptyKey);
736    }
737
738    let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
739    if target_files.is_empty() {
740        return Err(Error::NoFile("decrypt"));
741    }
742
743    // Pre-operation report
744    print_pre_report("Decrypting", &target_files, repo.path());
745
746    let key_cache: KeyCache = DashMap::new();
747
748    // Write-only: mpsc channel collects salt/file_id from rayon threads.
749    let (sender, saver) = salt_cache::create_writer(repo.path());
750
751    let pb = Progress::new(target_files.len(), "Decrypt");
752    let skipped = AtomicUsize::new(0);
753    let failed = AtomicUsize::new(0);
754
755    let result = {
756        let errors: parking_lot::Mutex<Vec<Error>> = parking_lot::Mutex::new(Vec::new());
757        target_files.par_iter().for_each(|f| {
758            match is_file_encrypted(f) {
759                Ok(true) => {}
760                Ok(false) => {
761                    skipped.fetch_add(1, Ordering::Relaxed);
762                    pb.inc(1);
763                    return;
764                }
765                Err(e) => {
766                    failed.fetch_add(1, Ordering::Relaxed);
767                    errors.lock().push(e);
768                    pb.inc(1);
769                    return;
770                }
771            }
772
773            let relative_key = cache_key(f, repo.path());
774
775            let r = decrypt_file_with_cache(
776                f,
777                &key_cache,
778                Some(CacheRef {
779                    sender: &sender,
780                    key: &relative_key,
781                }),
782                key.as_bytes(),
783            )
784            .map_err(|e| Error::Other(format!("Failed to decrypt {}: {e}", f.display())));
785
786            if let Err(e) = r {
787                failed.fetch_add(1, Ordering::Relaxed);
788                errors.lock().push(e);
789            }
790
791            pb.inc(1);
792        });
793        errors.into_inner()
794    };
795
796    // Drop sender to close the channel, then persist the cache.
797    drop(sender);
798    saver.save();
799
800    pb.finish_and_clear();
801
802    print_post_report(
803        "Decrypt",
804        target_files.len(),
805        skipped.load(Ordering::Relaxed),
806        failed.load(Ordering::Relaxed),
807    );
808
809    if let Some(first) = result.into_iter().next() {
810        return Err(first);
811    }
812
813    Ok(())
814}
815
816#[cfg(test)]
817mod tests {
818    use std::io::{Read, Write};
819
820    use tempfile::{NamedTempFile, TempPath};
821
822    use super::*;
823
824    // --- Helper Functions ---
825
826    fn get_test_key_and_salt() -> ([u8; 32], [u8; SALT_LEN]) {
827        let password = b"super_secret_password";
828        let mut salt = [0u8; SALT_LEN];
829        rand::rng().fill_bytes(&mut salt);
830        let derived = derive_key(password, &salt).unwrap();
831        let mut key = [0u8; 32];
832        key.copy_from_slice(&*derived);
833        (key, salt)
834    }
835
836    fn create_temp_file(content: &[u8]) -> TempPath {
837        let mut file = NamedTempFile::new().unwrap();
838        file.write_all(content).unwrap();
839        file.flush().unwrap();
840        file.into_temp_path()
841    }
842
843    // --- Tests ---
844
845    #[test]
846    fn test_header_serialization() {
847        let salt = [0xAB; SALT_LEN];
848        let file_id = FileHeader::generate_file_id();
849        let header = FileHeader::new(true, salt, file_id);
850
851        // Write to buffer
852        let mut buf = Vec::new();
853        header.write_to(&mut buf).unwrap();
854        assert_eq!(buf.len(), HEADER_LEN);
855
856        // Roundtrip: bytes → from_bytes (zero-copy)
857        let raw: &[u8; HEADER_LEN] = buf.as_slice().try_into().unwrap();
858        let decoded = FileHeader::from_bytes(raw).unwrap();
859
860        assert_eq!(decoded.magic, *MAGIC);
861        assert_eq!(decoded.version, VERSION);
862        assert_eq!(decoded.flags, FLAG_COMPRESSED);
863        assert_eq!(decoded.enc_algo, ENC_ALGO);
864        assert_eq!(decoded.salt, salt);
865        assert_eq!(decoded.file_id, header.file_id);
866        assert_eq!(decoded.reserved, [0u8; RESERVED_LEN]);
867        assert!(decoded.is_compressed());
868    }
869
870    #[test]
871    fn test_nonce_derivation_deterministic() {
872        let key_mac = [0x42u8; 32];
873        let file_id = [0x99u8; FILE_ID_LEN];
874        let plaintext = b"hello world";
875
876        // Same inputs → same nonce (deterministic).
877        let nonce0_a = derive_nonce(&key_mac, &file_id, plaintext, 0);
878        let nonce0_b = derive_nonce(&key_mac, &file_id, plaintext, 0);
879        assert_eq!(nonce0_a, nonce0_b);
880
881        // Different chunk index → different nonce.
882        let nonce1 = derive_nonce(&key_mac, &file_id, plaintext, 1);
883        assert_ne!(nonce0_a, nonce1);
884
885        // Different plaintext → different nonce.
886        let other_plaintext = b"hello world!";
887        let nonce_other = derive_nonce(&key_mac, &file_id, other_plaintext, 0);
888        assert_ne!(nonce0_a, nonce_other);
889
890        // Different key_mac → different nonce.
891        let key_mac2 = [0x43u8; 32];
892        let nonce_key2 = derive_nonce(&key_mac2, &file_id, plaintext, 0);
893        assert_ne!(nonce0_a, nonce_key2);
894
895        // Different file_id → different nonce (cross-file uniqueness).
896        let file_id2 = [0xAAu8; FILE_ID_LEN];
897        let nonce_file2 = derive_nonce(&key_mac, &file_id2, plaintext, 0);
898        assert_ne!(nonce0_a, nonce_file2);
899
900        // Empty plaintext should still produce a valid nonce.
901        let nonce_empty = derive_nonce(&key_mac, &file_id, b"", 0);
902        assert_ne!(nonce_empty, [0u8; NONCE_LEN]);
903    }
904
905    #[test]
906    fn test_encrypt_decrypt_basic_no_compression() {
907        let plaintext = b"Hello, World! This is a test without compression.";
908        let path = create_temp_file(plaintext);
909
910        let (key, salt) = get_test_key_and_salt();
911        let master_key = b"super_secret_password";
912
913        // Encrypt
914        encrypt_file(&path, &key, &salt, None, None).unwrap();
915
916        // Verify it's encrypted
917        let mut encrypted_content = Vec::new();
918        fs::File::open(&path)
919            .unwrap()
920            .read_to_end(&mut encrypted_content)
921            .unwrap();
922        assert_ne!(encrypted_content, plaintext);
923        assert_eq!(&encrypted_content[0..5], MAGIC);
924        assert_eq!(encrypted_content[5], VERSION);
925
926        // Decrypt
927        decrypt_file(&path, master_key).unwrap();
928
929        // Verify plaintext
930        let mut decrypted_content = Vec::new();
931        fs::File::open(path)
932            .unwrap()
933            .read_to_end(&mut decrypted_content)
934            .unwrap();
935        assert_eq!(decrypted_content, plaintext);
936    }
937
938    #[test]
939    fn test_encrypt_decrypt_with_compression() {
940        // Highly compressible data
941        let plaintext = b"A".repeat(10000);
942        let path = create_temp_file(&plaintext);
943
944        let (key, salt) = get_test_key_and_salt();
945        let master_key = b"super_secret_password";
946
947        // Encrypt with Zstd level 3
948        encrypt_file(&path, &key, &salt, None, Some(3)).unwrap();
949
950        // Verify it's encrypted and compressed (size should be much smaller than 10000
951        // + header)
952        let encrypted_meta = fs::metadata(&path).unwrap();
953        assert!(encrypted_meta.len() < 5000);
954
955        // Decrypt
956        decrypt_file(&path, master_key).unwrap();
957
958        // Verify plaintext
959        let mut decrypted_content = Vec::new();
960        fs::File::open(path)
961            .unwrap()
962            .read_to_end(&mut decrypted_content)
963            .unwrap();
964        assert_eq!(decrypted_content, plaintext);
965    }
966
967    #[test]
968    #[allow(clippy::cast_possible_truncation)]
969    #[allow(clippy::cast_sign_loss)]
970    fn test_chunked_encryption_large_file() {
971        // Create a file larger than CHUNK_SIZE (64KB) to test the streaming loop
972        let plaintext = {
973            let mut data = Vec::with_capacity(100_000);
974            for i in 0..100_000 {
975                data.push((i % 256) as u8);
976            }
977            data
978        };
979
980        let path = create_temp_file(&plaintext);
981
982        let (key, salt) = get_test_key_and_salt();
983        let master_key = b"super_secret_password";
984
985        // Encrypt
986        encrypt_file(&path, &key, &salt, None, None).unwrap();
987
988        // Decrypt
989        decrypt_file(&path, master_key).unwrap();
990
991        // Verify plaintext
992        let mut decrypted_content = Vec::new();
993        fs::File::open(path)
994            .unwrap()
995            .read_to_end(&mut decrypted_content)
996            .unwrap();
997        assert_eq!(decrypted_content, plaintext);
998    }
999
1000    #[test]
1001    fn test_tamper_resistance() {
1002        let plaintext = b"Sensitive data that should not be tampered with.";
1003        let path = create_temp_file(plaintext);
1004
1005        let (key, salt) = get_test_key_and_salt();
1006        let master_key = b"super_secret_password";
1007
1008        // Encrypt
1009        encrypt_file(&path, &key, &salt, None, None).unwrap();
1010
1011        // Tamper with the ciphertext (modify a byte after the 64-byte header)
1012        let mut encrypted_content = Vec::new();
1013        let mut f = fs::OpenOptions::new()
1014            .read(true)
1015            .write(true)
1016            .open(&path)
1017            .unwrap();
1018        f.read_to_end(&mut encrypted_content).unwrap();
1019
1020        // Flip a bit in the ciphertext
1021        encrypted_content[HEADER_LEN + 5] ^= 0xFF;
1022
1023        f.seek(std::io::SeekFrom::Start(0)).unwrap();
1024        f.write_all(&encrypted_content).unwrap();
1025        drop(f);
1026
1027        // Attempt to decrypt, should fail due to Poly1305 MAC mismatch
1028        let result = decrypt_file(&path, master_key);
1029
1030        assert!(result.is_err());
1031        assert!(
1032            result
1033                .unwrap_err()
1034                .to_string()
1035                .to_lowercase()
1036                .contains("decryption failed")
1037        );
1038    }
1039
1040    #[test]
1041    fn test_header_tamper_detected() {
1042        let plaintext = b"Test data with header integrity check.";
1043        let path = create_temp_file(plaintext);
1044
1045        let (key, salt) = get_test_key_and_salt();
1046        let master_key = b"super_secret_password";
1047
1048        // Encrypt
1049        encrypt_file(&path, &key, &salt, None, None).unwrap();
1050
1051        // Tamper with the header flags byte (flip the compressed bit at byte 6)
1052        let mut encrypted_content = Vec::new();
1053        let mut f = fs::OpenOptions::new()
1054            .read(true)
1055            .write(true)
1056            .open(&path)
1057            .unwrap();
1058        f.read_to_end(&mut encrypted_content).unwrap();
1059
1060        encrypted_content[6] ^= FLAG_COMPRESSED;
1061
1062        f.seek(std::io::SeekFrom::Start(0)).unwrap();
1063        f.write_all(&encrypted_content).unwrap();
1064        drop(f);
1065
1066        // Attempt to decrypt — should fail because header tampering changes
1067        // the AAD, causing Poly1305 authentication failure.
1068        let result = decrypt_file(&path, master_key);
1069        assert!(result.is_err());
1070        assert!(
1071            result
1072                .unwrap_err()
1073                .to_string()
1074                .to_lowercase()
1075                .contains("decryption failed")
1076        );
1077    }
1078
1079    #[test]
1080    fn test_deterministic_encrypt_with_fixed_salt_file_id() {
1081        let plaintext = b"Deterministic encryption test data.";
1082
1083        let password = b"test_password";
1084        let salt = [0x42; SALT_LEN];
1085        let file_id = [0x13; FILE_ID_LEN];
1086        let derived = derive_key(password, &salt).unwrap();
1087        let mut key = [0u8; 32];
1088        key.copy_from_slice(&*derived);
1089
1090        // Encrypt twice with the same salt+file_id → identical ciphertext
1091        let path1 = create_temp_file(plaintext);
1092        let path2 = create_temp_file(plaintext);
1093
1094        encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1095        encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1096
1097        let ct1 = fs::read(&path1).unwrap();
1098        let ct2 = fs::read(&path2).unwrap();
1099        assert_eq!(
1100            ct1, ct2,
1101            "Same plaintext + same salt+file_id must produce identical ciphertext"
1102        );
1103
1104        // And both should decrypt correctly
1105        decrypt_file(&path1, password).unwrap();
1106        assert_eq!(fs::read(&path1).unwrap(), plaintext);
1107    }
1108
1109    #[test]
1110    fn test_deterministic_encrypt_multi_chunk() {
1111        // Multi-chunk file: test determinism across chunk boundaries.
1112        #[allow(clippy::cast_possible_truncation)]
1113        let plaintext = {
1114            let mut data = Vec::with_capacity(CHUNK_SIZE * 2 + 1000);
1115            for i in 0..(CHUNK_SIZE * 2 + 1000) {
1116                data.push(i as u8);
1117            }
1118            data
1119        };
1120
1121        let password = b"test_password";
1122        let salt = [0x42; SALT_LEN];
1123        let file_id = [0x13; FILE_ID_LEN];
1124        let derived = derive_key(password, &salt).unwrap();
1125        let mut key = [0u8; 32];
1126        key.copy_from_slice(&*derived);
1127
1128        let path1 = create_temp_file(&plaintext);
1129        let path2 = create_temp_file(&plaintext);
1130
1131        encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1132        encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1133
1134        let ct1 = fs::read(&path1).unwrap();
1135        let ct2 = fs::read(&path2).unwrap();
1136        assert_eq!(
1137            ct1, ct2,
1138            "Same multi-chunk plaintext + same salt+file_id must produce identical ciphertext"
1139        );
1140
1141        // Decrypt and verify
1142        decrypt_file(&path1, password).unwrap();
1143        assert_eq!(fs::read(&path1).unwrap(), plaintext);
1144    }
1145
1146    #[test]
1147    fn test_different_file_id_produces_different_ciphertext() {
1148        // Verify that the same plaintext encrypted with different File_IDs
1149        // produces different ciphertext (cross-file uniqueness).
1150        let plaintext = b"Same content, different file.";
1151
1152        let password = b"test_password";
1153        let salt = [0x42; SALT_LEN];
1154        let derived = derive_key(password, &salt).unwrap();
1155        let mut key = [0u8; 32];
1156        key.copy_from_slice(&*derived);
1157
1158        let path1 = create_temp_file(plaintext);
1159        let path2 = create_temp_file(plaintext);
1160
1161        let file_id1 = [0x01; FILE_ID_LEN];
1162        let file_id2 = [0x02; FILE_ID_LEN];
1163
1164        encrypt_file(&path1, &key, &salt, Some(file_id1), None).unwrap();
1165        encrypt_file(&path2, &key, &salt, Some(file_id2), None).unwrap();
1166
1167        let ct1 = fs::read(&path1).unwrap();
1168        let ct2 = fs::read(&path2).unwrap();
1169        assert_ne!(
1170            ct1, ct2,
1171            "Same plaintext with different File_IDs must produce different ciphertext"
1172        );
1173
1174        // Both should decrypt correctly
1175        decrypt_file(&path1, password).unwrap();
1176        assert_eq!(fs::read(&path1).unwrap(), plaintext);
1177        decrypt_file(&path2, password).unwrap();
1178        assert_eq!(fs::read(&path2).unwrap(), plaintext);
1179    }
1180
1181    #[cfg(unix)]
1182    #[test]
1183    fn test_metadata_preservation() {
1184        use std::os::unix::fs::PermissionsExt;
1185
1186        let plaintext = b"Executable script content";
1187        let file = create_temp_file(plaintext);
1188        let path = file.path();
1189
1190        // Set permissions to 0o755 (rwxr-xr-x)
1191        let mut perms = fs::metadata(path).unwrap().permissions();
1192        perms.set_mode(0o755);
1193        fs::set_permissions(path, perms).unwrap();
1194
1195        let (key, salt) = get_test_key_and_salt();
1196        let master_key = b"super_secret_password";
1197
1198        // Encrypt
1199        encrypt_file(path, &key, &salt, None, None).unwrap();
1200
1201        // Check permissions after encryption
1202        let encrypted_perms = fs::metadata(path).unwrap().permissions();
1203        assert_eq!(encrypted_perms.mode() & 0o777, 0o755);
1204
1205        // Decrypt
1206        let key_cache: KeyCache = DashMap::new();
1207        decrypt_file_with_cache(path, &key_cache, None, master_key).unwrap();
1208
1209        // Check permissions after decryption
1210        let decrypted_perms = fs::metadata(path).unwrap().permissions();
1211        assert_eq!(decrypted_perms.mode() & 0o777, 0o755);
1212    }
1213
1214    #[test]
1215    fn test_empty_file_roundtrip() {
1216        // Edge case: 0-byte plaintext. The encrypt loop should produce a
1217        // single last (empty) chunk; decrypt should yield back 0 bytes.
1218        let plaintext = b"";
1219        let path = create_temp_file(plaintext);
1220
1221        let (key, salt) = get_test_key_and_salt();
1222        let master_key = b"super_secret_password";
1223
1224        encrypt_file(&path, &key, &salt, None, None).unwrap();
1225
1226        // Header + one chunk (24B nonce + 16B tag of empty plaintext).
1227        let enc = fs::read(&path).unwrap();
1228        assert_eq!(enc.len(), HEADER_LEN + NONCE_LEN + 16);
1229
1230        decrypt_file(&path, master_key).unwrap();
1231        assert_eq!(fs::read(&path).unwrap(), plaintext);
1232    }
1233
1234    #[test]
1235    fn test_wrong_password_decrypt_fails() {
1236        let plaintext = b"data encrypted under one password";
1237        let path = create_temp_file(plaintext);
1238
1239        let (key, salt) = get_test_key_and_salt();
1240        encrypt_file(&path, &key, &salt, None, None).unwrap();
1241
1242        // Decrypt with a different password: AEAD authentication must fail.
1243        let result = decrypt_file(&path, b"a_completely_different_password");
1244        assert!(matches!(result, Err(Error::DecryptFailed(_))));
1245
1246        // And the original file must be untouched (atomic writeback did not run).
1247        let bytes = fs::read(&path).unwrap();
1248        assert_eq!(&bytes[..MAGIC.len()], MAGIC);
1249    }
1250
1251    #[test]
1252    fn test_truncated_ciphertext_after_nonce() {
1253        // Header + 24-byte nonce + 0 bytes of ciphertext -> TruncatedChunk.
1254        let plaintext = b"abc";
1255        let path = create_temp_file(plaintext);
1256        let (key, salt) = get_test_key_and_salt();
1257        encrypt_file(&path, &key, &salt, None, None).unwrap();
1258
1259        // Truncate the file to keep only header + nonce (drop ct + tag).
1260        let trunc_len = HEADER_LEN + NONCE_LEN;
1261        let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
1262        f.set_len(trunc_len as u64).unwrap();
1263        drop(f);
1264
1265        let result = decrypt_file(&path, b"super_secret_password");
1266        assert!(matches!(result, Err(Error::TruncatedChunk)));
1267    }
1268
1269    #[test]
1270    fn test_truncated_before_first_nonce() {
1271        // File smaller than HEADER_LEN: decrypt_file_with_cache should treat
1272        // it as "not encrypted" and return Ok without touching it.
1273        let path = create_temp_file(b"tiny");
1274        let key_cache: KeyCache = DashMap::new();
1275        let res = decrypt_file_with_cache(&path, &key_cache, None, b"any");
1276        assert!(res.is_ok());
1277        // Plaintext unchanged.
1278        assert_eq!(fs::read(&path).unwrap(), b"tiny");
1279    }
1280}