Skip to main content

shadow_crypt_shell/encryption/
file_ops.rs

1use std::{
2    io::{BufWriter, Write},
3    path::{Path, PathBuf},
4};
5
6use rand::{rand_core::TryRng, rngs::SysRng};
7use shadow_crypt_core::{
8    archive,
9    file::FileMetadata,
10    memory::SecureString,
11    v3::{header::FileHeader, stream::StreamSealer},
12};
13use zeroize::Zeroizing;
14
15use crate::{
16    encryption::file::{EncryptionInputFile, EncryptionOutputFile},
17    errors::{WorkflowError, WorkflowResult},
18    utils::{AtomicOutputFile, read_up_to},
19};
20
21/// Collects the metadata to preserve for an input file or directory. Fields
22/// that cannot be read (or do not exist on this platform) are simply absent.
23pub fn gather_metadata(file: &EncryptionInputFile) -> FileMetadata {
24    gather_path_metadata(&file.path, file.filename.clone())
25}
26
27fn gather_path_metadata(path: &Path, stored_name: String) -> FileMetadata {
28    let fs_metadata = std::fs::metadata(path).ok();
29    let mtime = fs_metadata.as_ref().and_then(|m| m.modified().ok());
30
31    #[cfg(unix)]
32    let mode = fs_metadata.as_ref().map(|m| {
33        use std::os::unix::fs::PermissionsExt;
34        m.permissions().mode() & 0o7777
35    });
36    #[cfg(not(unix))]
37    let mode = None;
38
39    FileMetadata::new(SecureString::new(stored_name), mtime, mode)
40}
41
42/// One entry found while walking a directory tree.
43#[derive(Debug)]
44pub struct WalkedEntry {
45    /// On-disk path of the entry.
46    pub path: PathBuf,
47    /// '/'-separated path relative to the walked root.
48    pub rel: String,
49    pub is_dir: bool,
50    pub size: u64,
51}
52
53/// Recursively walks a directory in sorted order, listing directories before
54/// their contents. Entries that cannot be archived (symlinks, special
55/// files, non-UTF-8 or backslash-containing names) are skipped; the count
56/// of skipped entries is returned alongside.
57pub fn walk_directory(root: &Path) -> WorkflowResult<(Vec<WalkedEntry>, usize)> {
58    let mut entries = Vec::new();
59    let mut skipped = 0;
60    walk_into(root, "", &mut entries, &mut skipped)?;
61    Ok((entries, skipped))
62}
63
64fn walk_into(
65    dir: &Path,
66    prefix: &str,
67    entries: &mut Vec<WalkedEntry>,
68    skipped: &mut usize,
69) -> WorkflowResult<()> {
70    let mut children: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
71    children.sort_by_key(|e| e.file_name());
72
73    for child in children {
74        let Some(name) = child.file_name().to_str().map(str::to_string) else {
75            *skipped += 1;
76            continue;
77        };
78        // The archive format rejects backslashes in entry paths (they are
79        // separators on the extracting side); skip such names like non-UTF-8
80        // ones instead of aborting the whole directory at encode time.
81        if name.contains('\\') {
82            *skipped += 1;
83            continue;
84        }
85        let rel = if prefix.is_empty() {
86            name
87        } else {
88            format!("{prefix}/{name}")
89        };
90
91        // file_type() does not follow symlinks, so links are skipped rather
92        // than followed (following could escape the tree or loop).
93        let file_type = child.file_type()?;
94        if file_type.is_dir() {
95            entries.push(WalkedEntry {
96                path: child.path(),
97                rel: rel.clone(),
98                is_dir: true,
99                size: 0,
100            });
101            walk_into(&child.path(), &rel, entries, skipped)?;
102        } else if file_type.is_file() {
103            entries.push(WalkedEntry {
104                path: child.path(),
105                rel,
106                is_dir: false,
107                size: child.metadata()?.len(),
108            });
109        } else {
110            *skipped += 1;
111        }
112    }
113    Ok(())
114}
115
116/// Accumulates plaintext bytes and seals them through the stream in
117/// chunk-sized pieces; [`ChunkPump::finish`] seals whatever remains as the
118/// final chunk. The buffer is zeroized on drop.
119struct ChunkPump<W: Write> {
120    sealer: StreamSealer,
121    writer: W,
122    buf: Zeroizing<Vec<u8>>,
123}
124
125impl<W: Write> ChunkPump<W> {
126    fn new(sealer: StreamSealer, writer: W) -> Self {
127        Self {
128            sealer,
129            writer,
130            buf: Zeroizing::new(Vec::new()),
131        }
132    }
133
134    fn feed(&mut self, bytes: &[u8]) -> WorkflowResult<()> {
135        self.buf.extend_from_slice(bytes);
136        let chunk_size = self.sealer.chunk_plaintext_len();
137        // Keep at least one full-or-partial chunk back: only when more data
138        // than a chunk is buffered do we know the sealed chunk is not final.
139        while self.buf.len() > chunk_size {
140            let sealed = self.sealer.seal_chunk(&self.buf[..chunk_size], false)?;
141            self.writer.write_all(&sealed)?;
142            self.buf.drain(..chunk_size);
143        }
144        Ok(())
145    }
146
147    fn finish(mut self) -> WorkflowResult<W> {
148        let sealed = self.sealer.seal_chunk(&self.buf, true)?;
149        self.writer.write_all(&sealed)?;
150        Ok(self.writer)
151    }
152}
153
154/// Streams a walked directory tree as an encrypted archive into a fresh
155/// output file. Memory stays bounded by the chunk size regardless of tree
156/// size. A partially written output is removed on failure.
157pub fn stream_encrypt_directory(
158    entries: &[WalkedEntry],
159    header: &FileHeader,
160    sealer: StreamSealer,
161    output_dir: &Path,
162) -> WorkflowResult<EncryptionOutputFile> {
163    let (claim, output_file) = create_encryption_output_file(output_dir)?;
164    drop(claim); // the empty placeholder keeps the name reserved
165    let atomic = AtomicOutputFile::start(output_file.path.clone())?;
166
167    let result = (|| -> WorkflowResult<()> {
168        let mut writer = BufWriter::new(atomic);
169        writer.write_all(&header.serialize())?;
170
171        let mut pump = ChunkPump::new(sealer, writer);
172        pump.feed(&archive::MAGIC)?;
173
174        for entry in entries {
175            let entry_metadata = gather_path_metadata(&entry.path, entry.rel.clone());
176            if entry.is_dir {
177                pump.feed(&archive::encode_directory(&entry_metadata)?)?;
178            } else {
179                pump.feed(&archive::encode_file(&entry_metadata, entry.size)?)?;
180                feed_file_content(&mut pump, &entry.path, entry.size)?;
181            }
182        }
183        pump.feed(&archive::encode_end())?;
184
185        let mut writer = pump.finish()?;
186        writer.flush()?;
187        writer
188            .into_inner()
189            .map_err(|e| WorkflowError::Io(e.into_error()))?
190            .commit()?;
191        Ok(())
192    })();
193
194    match result {
195        Ok(()) => Ok(output_file),
196        Err(e) => {
197            let _ = std::fs::remove_file(&output_file.path);
198            Err(e)
199        }
200    }
201}
202
203/// Feeds exactly `declared_len` bytes of a file into the pump, failing if
204/// the file shrank or grew since it was walked (either way the entry no
205/// longer matches the file on disk).
206fn feed_file_content<W: Write>(
207    pump: &mut ChunkPump<W>,
208    path: &Path,
209    declared_len: u64,
210) -> WorkflowResult<()> {
211    let mut reader = std::fs::File::open(path)?;
212    let mut buf = Zeroizing::new(vec![0u8; 64 * 1024]);
213    let mut remaining = declared_len;
214    while remaining > 0 {
215        let take = buf
216            .len()
217            .min(usize::try_from(remaining).unwrap_or(buf.len()));
218        let n = read_up_to(&mut reader, &mut buf[..take])?;
219        if n == 0 {
220            return Err(WorkflowError::File(format!(
221                "File changed while archiving: {}",
222                path.display()
223            )));
224        }
225        pump.feed(&buf[..n])?;
226        remaining -= n as u64;
227    }
228    // A file that grew since the walk would be archived truncated to the
229    // stale declared length — silent data loss once --delete removes the
230    // original. Treat growth like shrinkage: the file changed, so fail.
231    if read_up_to(&mut reader, &mut buf[..1])? > 0 {
232        return Err(WorkflowError::File(format!(
233            "File changed while archiving: {}",
234            path.display()
235        )));
236    }
237    Ok(())
238}
239
240/// Streams the input file through the sealer into a fresh output file:
241/// header first, then one encrypted chunk at a time, with memory bounded by
242/// the chunk size. A partially written output is removed on failure.
243pub fn stream_encrypt_file(
244    file: &EncryptionInputFile,
245    header: &FileHeader,
246    mut sealer: StreamSealer,
247    output_dir: &std::path::Path,
248) -> WorkflowResult<EncryptionOutputFile> {
249    let (claim, output_file) = create_encryption_output_file(output_dir)?;
250    drop(claim); // the empty placeholder keeps the name reserved
251    let atomic = AtomicOutputFile::start(output_file.path.clone())?;
252
253    let result = (|| -> WorkflowResult<()> {
254        let mut writer = BufWriter::new(atomic);
255        writer.write_all(&header.serialize())?;
256
257        let mut reader = std::fs::File::open(&file.path)?;
258        let chunk_size = sealer.chunk_plaintext_len();
259        // The buffers hold plaintext: zeroize on drop like every other
260        // plaintext buffer in this module.
261        let mut current = Zeroizing::new(vec![0u8; chunk_size]);
262        let mut next = Zeroizing::new(vec![0u8; chunk_size]);
263
264        // Double-buffered read: a chunk is final when the read after it
265        // returns nothing, so exact-multiple files end on a full final chunk
266        // and empty files produce one empty final chunk.
267        let mut current_len = read_up_to(&mut reader, &mut current)?;
268        loop {
269            let next_len = read_up_to(&mut reader, &mut next)?;
270            let is_last = next_len == 0;
271            writer.write_all(&sealer.seal_chunk(&current[..current_len], is_last)?)?;
272            if is_last {
273                break;
274            }
275            std::mem::swap(&mut current, &mut next);
276            current_len = next_len;
277        }
278
279        writer.flush()?;
280        writer
281            .into_inner()
282            .map_err(|e| WorkflowError::Io(e.into_error()))?
283            .commit()?;
284        Ok(())
285    })();
286
287    match result {
288        Ok(()) => Ok(output_file),
289        Err(e) => {
290            let _ = std::fs::remove_file(&output_file.path);
291            Err(e)
292        }
293    }
294}
295
296fn generate_output_filename() -> WorkflowResult<String> {
297    const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
298    const NAME_LENGTH: usize = 16;
299    // Largest multiple of CHARSET.len() that fits in a byte; rejection
300    // sampling below this bound keeps every character equally likely.
301    const REJECTION_BOUND: u8 = (u8::MAX / CHARSET.len() as u8) * CHARSET.len() as u8;
302
303    let mut name = String::with_capacity(NAME_LENGTH);
304    let mut bytes = [0u8; 2 * NAME_LENGTH];
305    while name.len() < NAME_LENGTH {
306        SysRng.try_fill_bytes(&mut bytes).map_err(|e| {
307            WorkflowError::File(format!("Failed to generate output filename: {}", e))
308        })?;
309        for byte in bytes {
310            if byte < REJECTION_BOUND && name.len() < NAME_LENGTH {
311                name.push(CHARSET[byte as usize % CHARSET.len()] as char);
312            }
313        }
314    }
315    Ok(name)
316}
317
318fn create_encryption_output_file(
319    output_dir: &std::path::Path,
320) -> WorkflowResult<(std::fs::File, EncryptionOutputFile)> {
321    // create_new claims the filename atomically, so concurrent encryptions
322    // can never race each other (or an attacker) into overwriting a file.
323    for _ in 0..1000 {
324        let mut path = PathBuf::from(generate_output_filename()?);
325        path.set_extension("shadow");
326
327        let full_path = output_dir.join(&path);
328
329        match std::fs::File::create_new(&full_path) {
330            Ok(f) => {
331                let filename_str = path
332                    .to_str()
333                    .ok_or_else(|| WorkflowError::File("Invalid output filename".to_string()))?
334                    .to_string();
335
336                return Ok((
337                    f,
338                    EncryptionOutputFile {
339                        path: full_path,
340                        filename: filename_str,
341                    },
342                ));
343            }
344            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
345            Err(e) => return Err(e.into()),
346        }
347    }
348
349    Err(WorkflowError::File(
350        "Unable to generate a unique output filename after 1000 attempts".to_string(),
351    ))
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use shadow_crypt_core::{
358        memory::SecureKey, v3::file::EncryptedFile, v3::key::KeyDerivationParams,
359    };
360    use std::fs;
361    use tempfile::TempDir;
362
363    fn input_file(dir: &std::path::Path, name: &str, content: &[u8]) -> EncryptionInputFile {
364        let path = dir.join(name);
365        fs::write(&path, content).unwrap();
366        EncryptionInputFile {
367            path,
368            filename: name.to_string(),
369            size: content.len() as u64,
370            kind: crate::encryption::file::InputKind::File,
371        }
372    }
373
374    #[test]
375    fn test_generate_output_filename() {
376        let filename = generate_output_filename().unwrap();
377        assert_eq!(filename.len(), 16);
378        assert!(filename.chars().all(|c| c.is_ascii_alphabetic()));
379    }
380
381    #[test]
382    fn test_create_encryption_output_file() {
383        let temp_dir = TempDir::new().unwrap();
384
385        let (_, first) = create_encryption_output_file(temp_dir.path()).unwrap();
386        let (_, second) = create_encryption_output_file(temp_dir.path()).unwrap();
387
388        assert!(first.path.exists());
389        assert!(second.path.exists());
390        assert_ne!(first.filename, second.filename);
391        assert!(first.filename.ends_with(".shadow"));
392    }
393
394    #[test]
395    fn test_gather_metadata() {
396        let temp_dir = TempDir::new().unwrap();
397        let file = input_file(temp_dir.path(), "meta.txt", b"content");
398
399        let metadata = gather_metadata(&file);
400        assert_eq!(metadata.filename().as_str(), "meta.txt");
401        assert!(metadata.mtime().is_some());
402        #[cfg(unix)]
403        assert!(metadata.mode().is_some());
404    }
405
406    #[test]
407    fn test_stream_encrypt_file_round_trips() {
408        let temp_dir = TempDir::new().unwrap();
409        let content = b"stream me please".repeat(10);
410        let file = input_file(temp_dir.path(), "in.txt", &content);
411
412        let key = SecureKey::new([7u8; 32]);
413        let params = KeyDerivationParams::test_defaults();
414        let (header, sealer) = StreamSealer::begin(
415            &gather_metadata(&file),
416            &key,
417            params,
418            [1u8; 16],
419            [2u8; 16],
420            [3u8; 24],
421        )
422        .unwrap();
423
424        let output_file = stream_encrypt_file(&file, &header, sealer, temp_dir.path()).unwrap();
425        assert!(output_file.path.exists());
426        assert!(output_file.filename.ends_with(".shadow"));
427
428        // The written file must decrypt back to the original content.
429        let bytes = fs::read(&output_file.path).unwrap();
430        let decrypted = EncryptedFile::from_bytes(&bytes)
431            .unwrap()
432            .decrypt(&key)
433            .unwrap();
434        assert_eq!(decrypted.filename().as_str(), "in.txt");
435        assert_eq!(decrypted.content().as_slice(), content.as_slice());
436    }
437
438    #[test]
439    fn test_stream_encrypt_empty_file() {
440        let temp_dir = TempDir::new().unwrap();
441        let file = input_file(temp_dir.path(), "empty.txt", b"");
442
443        let key = SecureKey::new([7u8; 32]);
444        let (header, sealer) = StreamSealer::begin(
445            &gather_metadata(&file),
446            &key,
447            KeyDerivationParams::test_defaults(),
448            [1u8; 16],
449            [2u8; 16],
450            [3u8; 24],
451        )
452        .unwrap();
453
454        let output_file = stream_encrypt_file(&file, &header, sealer, temp_dir.path()).unwrap();
455        let bytes = fs::read(&output_file.path).unwrap();
456        let decrypted = EncryptedFile::from_bytes(&bytes)
457            .unwrap()
458            .decrypt(&key)
459            .unwrap();
460        assert!(decrypted.content().as_slice().is_empty());
461    }
462
463    #[cfg(unix)]
464    #[test]
465    fn test_walk_skips_backslash_names() {
466        let temp_dir = TempDir::new().unwrap();
467        fs::write(temp_dir.path().join("a\\b.txt"), b"x").unwrap();
468        fs::write(temp_dir.path().join("ok.txt"), b"x").unwrap();
469
470        let (entries, skipped) = walk_directory(temp_dir.path()).unwrap();
471        assert_eq!(skipped, 1);
472        assert_eq!(entries.len(), 1);
473        assert_eq!(entries[0].rel, "ok.txt");
474    }
475
476    #[test]
477    fn test_archive_errors_when_file_grew_since_walk() {
478        let temp_dir = TempDir::new().unwrap();
479        let path = temp_dir.path().join("grow.txt");
480        fs::write(&path, b"0123456789").unwrap();
481        // Declared size is stale: the file has more bytes than the walk saw.
482        let entries = vec![WalkedEntry {
483            path: path.clone(),
484            rel: "grow.txt".to_string(),
485            is_dir: false,
486            size: 5,
487        }];
488
489        let out_dir = TempDir::new().unwrap();
490        let key = SecureKey::new([7u8; 32]);
491        let metadata = gather_path_metadata(&path, "grow.txt".to_string()).into_archive();
492        let (header, sealer) = StreamSealer::begin(
493            &metadata,
494            &key,
495            KeyDerivationParams::test_defaults(),
496            [1u8; 16],
497            [2u8; 16],
498            [3u8; 24],
499        )
500        .unwrap();
501
502        assert!(stream_encrypt_directory(&entries, &header, sealer, out_dir.path()).is_err());
503    }
504
505    #[test]
506    fn test_stream_encrypt_missing_input_cleans_up_output() {
507        let temp_dir = TempDir::new().unwrap();
508        let file = EncryptionInputFile {
509            path: temp_dir.path().join("missing.txt"),
510            filename: "missing.txt".to_string(),
511            size: 0,
512            kind: crate::encryption::file::InputKind::File,
513        };
514
515        let key = SecureKey::new([7u8; 32]);
516        let (header, sealer) = StreamSealer::begin(
517            &gather_metadata(&file),
518            &key,
519            KeyDerivationParams::test_defaults(),
520            [1u8; 16],
521            [2u8; 16],
522            [3u8; 24],
523        )
524        .unwrap();
525
526        assert!(stream_encrypt_file(&file, &header, sealer, temp_dir.path()).is_err());
527        // No orphaned partial .shadow file may remain.
528        let leftovers: Vec<_> = fs::read_dir(temp_dir.path())
529            .unwrap()
530            .filter_map(|e| e.ok())
531            .filter(|e| e.path().extension().is_some_and(|ext| ext == "shadow"))
532            .collect();
533        assert!(leftovers.is_empty());
534    }
535}