Skip to main content

shadow_crypt_shell/decryption/
file_ops.rs

1use std::io::{Read, Seek, SeekFrom, Write};
2use std::path::{Path, PathBuf};
3
4use shadow_crypt_core::{
5    archive::{ArchiveEvent, ArchiveParser},
6    file::{ContentKind, FileMetadata},
7    memory::{SecureKey, SecureString},
8    vault::{ContentDecryptor, ParsedFile},
9};
10
11use crate::{
12    decryption::file::{DecryptionInputFile, DecryptionOutputFile},
13    errors::{WorkflowError, WorkflowResult},
14    utils::{AtomicOutputFile, read_up_to, sanitize_relative_path},
15};
16
17/// Claims `path` for output, enforcing the no-overwrite policy. Returns
18/// true when a fresh placeholder file was created. With --force an existing
19/// file is left untouched (returns false) and only replaced when
20/// [`AtomicOutputFile::commit`] renames the finished content over it, so a
21/// failed decryption never destroys what was there before. The rename
22/// replaces a symlink at the target rather than following it.
23fn claim_output_path(path: &Path, display_name: &str, force: bool) -> WorkflowResult<bool> {
24    // create_new makes the no-overwrite check atomic: no window between an
25    // exists() check and creation, and symlinks are never followed to clobber
26    // an existing target.
27    match std::fs::File::create_new(path) {
28        Ok(_) => Ok(true),
29        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
30            if force {
31                Ok(false)
32            } else {
33                Err(WorkflowError::File(format!(
34                    "Output file '{}' already exists (use --force to overwrite)",
35                    display_name
36                )))
37            }
38        }
39        Err(e) => Err(WorkflowError::Io(e)),
40    }
41}
42
43/// Creates the plaintext output for a decrypted filename, enforcing the
44/// no-traversal and no-overwrite policies. Multi-component names (written
45/// by older versions' recursive mode) recreate their directories under
46/// `output_dir`.
47/// The returned writer is crash-safe: the final path holds an empty
48/// placeholder (or, with --force, the pre-existing file) until
49/// [`AtomicOutputFile::commit`] renames the finished content over it. The
50/// returned bool says whether a placeholder was created and may be removed
51/// on failure.
52fn create_output_file(
53    filename: &SecureString,
54    output_dir: &Path,
55    force: bool,
56) -> WorkflowResult<(AtomicOutputFile, DecryptionOutputFile, bool)> {
57    let safe_rel = sanitize_relative_path(filename.as_str())?;
58    let path = output_dir.join(&safe_rel);
59    if let Some(parent) = path.parent() {
60        std::fs::create_dir_all(parent)?;
61    }
62
63    let display_name = safe_rel.to_string_lossy().into_owned();
64    // Claim the final name (placeholder), then write next to it.
65    let claimed = claim_output_path(&path, &display_name, force)?;
66    let out = AtomicOutputFile::start(path.clone())?;
67
68    Ok((
69        out,
70        DecryptionOutputFile {
71            path,
72            filename: display_name,
73        },
74        claimed,
75    ))
76}
77
78/// Restores preserved metadata onto the decrypted file. Applied after the
79/// content is written, since writing would bump the mtime again.
80fn apply_metadata(f: &std::fs::File, metadata: &FileMetadata) -> WorkflowResult<()> {
81    #[cfg(unix)]
82    if let Some(mode) = metadata.mode() {
83        use std::os::unix::fs::PermissionsExt;
84        // The mode comes from (authenticated but sender-controlled)
85        // metadata: drop setuid/setgid/sticky so decrypting someone else's
86        // file can never plant a privilege-escalation primitive.
87        f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777))?;
88    }
89    if let Some(mtime) = metadata.mtime() {
90        f.set_modified(mtime)?;
91    }
92    Ok(())
93}
94
95/// Feeds the encrypted file's content ciphertext (everything after the
96/// header) through the decryptor, passing each decrypted piece to `sink`.
97fn pump_content(
98    file: &DecryptionInputFile,
99    parsed: &ParsedFile,
100    decryptor: &mut ContentDecryptor<'_>,
101    mut sink: impl FnMut(&[u8]) -> WorkflowResult<()>,
102) -> WorkflowResult<()> {
103    let mut reader = std::fs::File::open(&file.path)?;
104    reader.seek(SeekFrom::Start(parsed.header_length() as u64))?;
105
106    match decryptor.chunk_len() {
107        // The whole content is a single AEAD message: feed it at once.
108        None => {
109            let mut content = Vec::new();
110            reader.read_to_end(&mut content)?;
111            sink(decryptor.decrypt_chunk(&content, true)?.as_slice())?;
112        }
113        // Chunked content: double-buffered read, a chunk is final when the
114        // read after it returns nothing.
115        Some(chunk_len) => {
116            let mut current = vec![0u8; chunk_len];
117            let mut next = vec![0u8; chunk_len];
118            let mut current_len = read_up_to(&mut reader, &mut current)?;
119            loop {
120                let next_len = read_up_to(&mut reader, &mut next)?;
121                let is_last = next_len == 0;
122                sink(
123                    decryptor
124                        .decrypt_chunk(&current[..current_len], is_last)?
125                        .as_slice(),
126                )?;
127                if is_last {
128                    break;
129                }
130                std::mem::swap(&mut current, &mut next);
131                current_len = next_len;
132            }
133        }
134    }
135    Ok(())
136}
137
138/// Streams the encrypted input file's content into plaintext output: a
139/// single file, or an extracted directory tree when the metadata marks the
140/// content as an archive. Preserved metadata is restored afterwards. Memory
141/// is bounded by the format's chunk size (formats without chunking are
142/// decrypted as one piece).
143pub fn stream_decrypt_file(
144    file: &DecryptionInputFile,
145    parsed: &ParsedFile,
146    key: &SecureKey,
147    metadata: &FileMetadata,
148    output_dir: &std::path::Path,
149    force: bool,
150) -> WorkflowResult<DecryptionOutputFile> {
151    match metadata.kind() {
152        ContentKind::File => {
153            stream_decrypt_single_file(file, parsed, key, metadata, output_dir, force)
154        }
155        ContentKind::Archive => extract_archive(file, parsed, key, metadata, output_dir, force),
156    }
157}
158
159/// Single-file case: the content stream is the file's bytes. A partially
160/// written output is removed on failure.
161fn stream_decrypt_single_file(
162    file: &DecryptionInputFile,
163    parsed: &ParsedFile,
164    key: &SecureKey,
165    metadata: &FileMetadata,
166    output_dir: &std::path::Path,
167    force: bool,
168) -> WorkflowResult<DecryptionOutputFile> {
169    let (mut out, output_file, claimed) =
170        create_output_file(metadata.filename(), output_dir, force)?;
171
172    let result = (|| -> WorkflowResult<()> {
173        let mut decryptor = parsed.content_decryptor(key);
174        pump_content(file, parsed, &mut decryptor, |plaintext| {
175            out.write_all(plaintext)?;
176            Ok(())
177        })?;
178        apply_metadata(out.as_file(), metadata)?;
179        out.commit()?;
180        Ok(())
181    })();
182
183    match result {
184        Ok(()) => Ok(output_file),
185        Err(e) => {
186            drop(out); // removes the temporary file
187            // Only remove the placeholder we created; with --force the
188            // final path may still hold the user's pre-existing file.
189            if claimed {
190                let _ = std::fs::remove_file(&output_file.path);
191            }
192            Err(e)
193        }
194    }
195}
196
197/// Archive case: the content stream is a directory tree, extracted under
198/// `output_dir/<archive name>`. Already-completed entries are kept on
199/// failure (they are valid files); only the entry being written is removed.
200fn extract_archive(
201    file: &DecryptionInputFile,
202    parsed: &ParsedFile,
203    key: &SecureKey,
204    metadata: &FileMetadata,
205    output_dir: &std::path::Path,
206    force: bool,
207) -> WorkflowResult<DecryptionOutputFile> {
208    let root_rel = sanitize_relative_path(metadata.filename().as_str())?;
209    let root = output_dir.join(&root_rel);
210    let display_name = root_rel.to_string_lossy().into_owned();
211
212    match root.symlink_metadata() {
213        Ok(meta) => {
214            if !force {
215                return Err(WorkflowError::File(format!(
216                    "Output directory '{}' already exists (use --force to extract into it)",
217                    display_name
218                )));
219            }
220            // symlink_metadata does not follow symlinks: a link to a
221            // directory elsewhere is rejected rather than extracted
222            // through, which would write (and force-remove) files outside
223            // the output directory.
224            if !meta.file_type().is_dir() {
225                return Err(WorkflowError::File(format!(
226                    "Output path '{}' exists and is not a directory",
227                    display_name
228                )));
229            }
230        }
231        Err(_) => std::fs::create_dir_all(&root)?,
232    }
233
234    let mut parser = ArchiveParser::new();
235    // (atomic writer, on-disk path, entry metadata, placeholder claimed) of
236    // the file being written.
237    let mut current: Option<(AtomicOutputFile, PathBuf, FileMetadata, bool)> = None;
238    let mut directory_metas: Vec<(PathBuf, FileMetadata)> = Vec::new();
239
240    let result = (|| -> WorkflowResult<()> {
241        let mut decryptor = parsed.content_decryptor(key);
242        pump_content(file, parsed, &mut decryptor, |plaintext| {
243            parser.feed(plaintext);
244            while let Some(event) = parser.next_event()? {
245                match event {
246                    ArchiveEvent::Directory { metadata } => {
247                        let path = root.join(sanitize_relative_path(metadata.filename().as_str())?);
248                        std::fs::create_dir_all(&path)?;
249                        directory_metas.push((path, metadata));
250                    }
251                    ArchiveEvent::FileStart { metadata, .. } => {
252                        let rel = sanitize_relative_path(metadata.filename().as_str())?;
253                        let path = root.join(&rel);
254                        if let Some(parent) = path.parent() {
255                            std::fs::create_dir_all(parent)?;
256                        }
257                        // Claim the final name, then write crash-safely next
258                        // to it.
259                        let claimed = claim_output_path(&path, &rel.to_string_lossy(), force)?;
260                        let out = AtomicOutputFile::start(path.clone())?;
261                        current = Some((out, path, metadata, claimed));
262                    }
263                    ArchiveEvent::FileData(data) => {
264                        let (out, _, _, _) = current.as_mut().ok_or_else(|| {
265                            WorkflowError::File("Archive stream out of order".to_string())
266                        })?;
267                        out.write_all(data.as_slice())?;
268                    }
269                    ArchiveEvent::FileEnd => {
270                        let (mut out, _, entry_metadata, _) = current.take().ok_or_else(|| {
271                            WorkflowError::File("Archive stream out of order".to_string())
272                        })?;
273                        apply_metadata(out.as_file(), &entry_metadata)?;
274                        out.commit()?;
275                    }
276                    ArchiveEvent::End => {}
277                }
278            }
279            Ok(())
280        })?;
281        parser.finish()?;
282
283        // Restore directory metadata deepest-first: writing children bumps a
284        // parent's mtime, so parents must be stamped after their contents.
285        directory_metas.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count()));
286        for (path, dir_metadata) in &directory_metas {
287            apply_path_metadata(path, dir_metadata)?;
288        }
289        apply_path_metadata(&root, metadata)?;
290        Ok(())
291    })();
292
293    match result {
294        Ok(()) => Ok(DecryptionOutputFile {
295            path: root,
296            filename: display_name,
297        }),
298        Err(e) => {
299            // Only remove the placeholder we created; with --force the
300            // final path may still hold the user's pre-existing file.
301            if let Some((out, path, _, claimed)) = current {
302                drop(out); // removes the temporary file
303                if claimed {
304                    let _ = std::fs::remove_file(path);
305                }
306            }
307            Err(e)
308        }
309    }
310}
311
312/// [`apply_metadata`] for paths without an open handle (directories).
313fn apply_path_metadata(path: &Path, metadata: &FileMetadata) -> WorkflowResult<()> {
314    let mut options = std::fs::OpenOptions::new();
315    options.read(true);
316    // Windows can only open directories with FILE_FLAG_BACKUP_SEMANTICS,
317    // and setting the mtime needs write access to the handle.
318    #[cfg(windows)]
319    {
320        use std::os::windows::fs::OpenOptionsExt;
321        options.write(true).custom_flags(0x0200_0000); // FILE_FLAG_BACKUP_SEMANTICS
322    }
323    let f = options.open(path)?;
324    apply_metadata(&f, metadata)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use shadow_crypt_core::memory::SecureBytes;
331    use std::fs;
332
333    fn write_output(
334        filename: &str,
335        content: &[u8],
336        output_dir: &std::path::Path,
337        force: bool,
338    ) -> WorkflowResult<DecryptionOutputFile> {
339        let filename = SecureString::new(filename.to_string());
340        let content = SecureBytes::new(content.to_vec());
341        let (mut f, output_file, _) = create_output_file(&filename, output_dir, force)?;
342        f.write_all(content.as_slice())?;
343        f.commit()?;
344        Ok(output_file)
345    }
346
347    #[test]
348    fn test_create_and_write_output_file() {
349        let temp_dir = tempfile::TempDir::new().unwrap();
350
351        let output = write_output("test.txt", b"test content", temp_dir.path(), false).unwrap();
352        assert_eq!(output.filename, "test.txt");
353        assert_eq!(fs::read(&output.path).unwrap(), b"test content");
354    }
355
356    #[test]
357    fn test_multi_component_name_recreates_directories() {
358        // Files written by older versions' recursive mode store relative
359        // paths; decrypting them must keep working.
360        let temp_dir = tempfile::TempDir::new().unwrap();
361
362        let output = write_output("photos/sub/b.txt", b"bravo", temp_dir.path(), false).unwrap();
363        assert_eq!(output.filename, "photos/sub/b.txt");
364        assert_eq!(
365            fs::read(temp_dir.path().join("photos/sub/b.txt")).unwrap(),
366            b"bravo"
367        );
368    }
369
370    #[test]
371    fn test_output_file_path_traversal_rejected() {
372        let temp_dir = tempfile::TempDir::new().unwrap();
373
374        for malicious_name in &["../../etc/passwd", "../sibling", "/abs/path", ".."] {
375            let result = write_output(malicious_name, b"evil", temp_dir.path(), false);
376
377            match malicious_name {
378                &".." => {
379                    assert!(
380                        result.is_err(),
381                        "Expected error for filename '{malicious_name}'"
382                    );
383                }
384                _ => {
385                    // file_name() strips leading directories, so it succeeds
386                    // but writes into temp_dir, not to the traversed path
387                    if let Ok(output) = result {
388                        assert!(
389                            output.path.starts_with(temp_dir.path()),
390                            "Output escaped temp_dir for '{malicious_name}'"
391                        );
392                    }
393                }
394            }
395        }
396    }
397
398    #[test]
399    fn test_output_file_no_overwrite() {
400        let temp_dir = tempfile::TempDir::new().unwrap();
401
402        let output_path = temp_dir.path().join("test.txt");
403        let existing_content = b"existing content";
404        fs::write(&output_path, existing_content).unwrap();
405
406        let result = write_output("test.txt", b"new content", temp_dir.path(), false);
407        assert!(result.is_err());
408        if let Err(WorkflowError::File(msg)) = result {
409            assert!(msg.contains("already exists"));
410        } else {
411            panic!("Expected File error");
412        }
413
414        // Check existing content unchanged
415        assert_eq!(fs::read(&output_path).unwrap(), existing_content);
416    }
417
418    #[test]
419    fn test_force_keeps_existing_file_when_not_committed() {
420        let temp_dir = tempfile::TempDir::new().unwrap();
421        let output_path = temp_dir.path().join("test.txt");
422        fs::write(&output_path, b"precious").unwrap();
423
424        let filename = SecureString::new("test.txt".to_string());
425        let (mut f, _, claimed) = create_output_file(&filename, temp_dir.path(), true).unwrap();
426        assert!(
427            !claimed,
428            "existing file must not be claimed as a placeholder"
429        );
430        f.write_all(b"partial").unwrap();
431        drop(f); // simulated failure: dropped without commit
432
433        // A failed forced decryption must leave the pre-existing file intact.
434        assert_eq!(fs::read(&output_path).unwrap(), b"precious");
435    }
436
437    #[test]
438    fn test_output_file_force_overwrites() {
439        let temp_dir = tempfile::TempDir::new().unwrap();
440
441        let output_path = temp_dir.path().join("test.txt");
442        fs::write(&output_path, b"existing content").unwrap();
443
444        let output = write_output("test.txt", b"new content", temp_dir.path(), true).unwrap();
445        assert_eq!(fs::read(&output.path).unwrap(), b"new content");
446    }
447
448    #[test]
449    fn test_output_file_force_without_existing_file() {
450        let temp_dir = tempfile::TempDir::new().unwrap();
451
452        let output = write_output("test.txt", b"content", temp_dir.path(), true).unwrap();
453        assert_eq!(fs::read(&output.path).unwrap(), b"content");
454    }
455}