Skip to main content

shadow_crypt_shell/decryption/
file_ops.rs

1use std::io::{Read, Write};
2
3use shadow_crypt_core::memory::{SecureBytes, SecureString};
4
5use crate::{
6    decryption::file::{DecryptionInputFile, DecryptionOutputFile},
7    errors::{WorkflowError, WorkflowResult},
8};
9
10pub fn store_plaintext_file(
11    filename: &SecureString,
12    content: &SecureBytes,
13    output_dir: &std::path::Path,
14) -> WorkflowResult<DecryptionOutputFile> {
15    // Reject any path traversal by taking only the bare filename component.
16    // This prevents a malicious .shadow file from writing to an arbitrary path.
17    let safe_name = std::path::Path::new(filename.as_str())
18        .file_name()
19        .ok_or_else(|| {
20            WorkflowError::File("Decrypted filename contains invalid path components".to_string())
21        })?;
22    let safe_name_str = safe_name
23        .to_str()
24        .ok_or_else(|| WorkflowError::File("Decrypted filename is not valid UTF-8".to_string()))?
25        .to_string();
26
27    let output_file = DecryptionOutputFile {
28        path: output_dir.join(safe_name),
29        filename: safe_name_str,
30    };
31
32    // create_new makes the no-overwrite check atomic: no window between an
33    // exists() check and creation, and symlinks are never followed to clobber
34    // an existing target.
35    let mut f = std::fs::File::create_new(output_file.path.as_path()).map_err(|e| {
36        if e.kind() == std::io::ErrorKind::AlreadyExists {
37            WorkflowError::File(format!(
38                "Output file '{}' already exists",
39                output_file.filename
40            ))
41        } else {
42            WorkflowError::Io(e)
43        }
44    })?;
45    f.write_all(content.as_slice())?;
46
47    Ok(output_file)
48}
49
50/// Reads the complete raw bytes of an encrypted input file. Parsing happens
51/// in the workflow, per format version.
52pub fn load_file_bytes(file: &DecryptionInputFile) -> WorkflowResult<Vec<u8>> {
53    let size: usize = file.size as usize;
54
55    let mut f = std::fs::File::open(&file.path)?;
56    let mut buffer: Vec<u8> = Vec::with_capacity(size);
57
58    f.read_to_end(&mut buffer)?;
59
60    Ok(buffer)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use std::fs;
67    use std::io::Write;
68    use tempfile::NamedTempFile;
69
70    #[test]
71    fn test_store_plaintext_file() {
72        let temp_dir = tempfile::TempDir::new().unwrap();
73        let filename = SecureString::new("test.txt".to_string());
74        let content = SecureBytes::new(b"test content".to_vec());
75
76        let output = store_plaintext_file(&filename, &content, temp_dir.path()).unwrap();
77        assert_eq!(output.filename, "test.txt");
78
79        let read_content = fs::read(&output.path).unwrap();
80        assert_eq!(read_content, b"test content");
81    }
82
83    #[test]
84    fn test_store_plaintext_file_path_traversal_rejected() {
85        let temp_dir = tempfile::TempDir::new().unwrap();
86
87        for malicious_name in &["../../etc/passwd", "../sibling", "/abs/path", ".."] {
88            let filename = SecureString::new(malicious_name.to_string());
89            let content = SecureBytes::new(b"evil".to_vec());
90            let result = store_plaintext_file(&filename, &content, temp_dir.path());
91
92            match malicious_name {
93                &".." => {
94                    assert!(
95                        result.is_err(),
96                        "Expected error for filename '{malicious_name}'"
97                    );
98                }
99                _ => {
100                    // file_name() strips leading directories, so it succeeds
101                    // but writes into temp_dir, not to the traversed path
102                    if let Ok(output) = result {
103                        assert!(
104                            output.path.starts_with(temp_dir.path()),
105                            "Output escaped temp_dir for '{malicious_name}'"
106                        );
107                    }
108                }
109            }
110        }
111    }
112
113    #[test]
114    fn test_store_plaintext_file_no_overwrite() {
115        let temp_dir = tempfile::TempDir::new().unwrap();
116
117        let filename = SecureString::new("test.txt".to_string());
118        let content = SecureBytes::new(b"new content".to_vec());
119
120        // Create existing file
121        let output_path = temp_dir.path().join("test.txt");
122        let existing_content = b"existing content";
123        std::fs::write(&output_path, existing_content).unwrap();
124
125        let result = store_plaintext_file(&filename, &content, temp_dir.path());
126        assert!(result.is_err());
127        if let Err(WorkflowError::File(msg)) = result {
128            assert!(msg.contains("already exists"));
129        } else {
130            panic!("Expected File error");
131        }
132
133        // Check existing content unchanged
134        let read_content = std::fs::read(&output_path).unwrap();
135        assert_eq!(read_content, existing_content);
136    }
137
138    #[test]
139    fn test_load_file_bytes() {
140        let mut temp_file = NamedTempFile::new().unwrap();
141        let data = b"raw file data";
142        temp_file.write_all(data).unwrap();
143        temp_file.flush().unwrap();
144
145        let input_file = DecryptionInputFile {
146            path: temp_file.path().to_path_buf(),
147            filename: "test.shadow".to_string(),
148            size: data.len() as u64,
149        };
150
151        let bytes = load_file_bytes(&input_file).unwrap();
152        assert_eq!(bytes, data);
153    }
154}