Skip to main content

shadow_crypt_shell/
utils.rs

1use std::io::{Read, Write};
2
3use shadow_crypt_core::memory::SecureBytes;
4
5use crate::errors::{WorkflowError, WorkflowResult};
6
7pub fn read_n_bytes_from_file(path: &std::path::Path, n: usize) -> WorkflowResult<SecureBytes> {
8    let f = std::fs::File::open(path)?;
9    let mut buffer = Vec::new();
10    f.take(n as u64).read_to_end(&mut buffer)?;
11
12    Ok(SecureBytes::new(buffer))
13}
14
15/// Sanitizes a '/'-separated path from decrypted metadata into a relative
16/// path that cannot escape the output directory: rejects `..` components,
17/// backslashes, and (on Windows) drive prefixes and `:` stream separators,
18/// drops empty and `.` components (which also relativizes
19/// absolute paths). The decrypted name is deliberately not echoed into
20/// error messages.
21pub fn sanitize_relative_path(name: &str) -> WorkflowResult<std::path::PathBuf> {
22    if name.contains('\\') {
23        return Err(WorkflowError::File(
24            "Decrypted path contains unsupported separators".to_string(),
25        ));
26    }
27    let mut out = std::path::PathBuf::new();
28    for component in name.split('/') {
29        match component {
30            "" | "." => continue,
31            ".." => {
32                return Err(WorkflowError::File(
33                    "Decrypted path contains unsafe components".to_string(),
34                ));
35            }
36            component => {
37                // On Windows, ':' forms drive prefixes ("C:evil" makes
38                // Path::push discard everything accumulated so far) and NTFS
39                // alternate data streams.
40                if cfg!(windows) && component.contains(':') {
41                    return Err(WorkflowError::File(
42                        "Decrypted path contains unsafe components".to_string(),
43                    ));
44                }
45                out.push(component);
46            }
47        }
48    }
49    if out.as_os_str().is_empty() {
50        return Err(WorkflowError::File(
51            "Decrypted path contains no usable components".to_string(),
52        ));
53    }
54    // Belt and braces: anything the platform parses as a prefix, root, or
55    // dot component would let output_dir.join(out) escape the output
56    // directory.
57    if !out
58        .components()
59        .all(|c| matches!(c, std::path::Component::Normal(_)))
60    {
61        return Err(WorkflowError::File(
62            "Decrypted path contains unsafe components".to_string(),
63        ));
64    }
65    Ok(out)
66}
67
68/// Reads from `reader` until `buf` is full or EOF; returns the bytes read.
69/// Unlike a bare `read` call this only returns short on EOF, which the
70/// streaming loops rely on to spot the final chunk.
71pub fn read_up_to(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
72    let mut filled = 0;
73    while filled < buf.len() {
74        let n = reader.read(&mut buf[filled..])?;
75        if n == 0 {
76            break;
77        }
78        filled += n;
79    }
80    Ok(filled)
81}
82
83/// Crash-safe output writing: content goes to a hidden temporary file next
84/// to `final_path`, and [`AtomicOutputFile::commit`] fsyncs it and renames
85/// it into place. Until then the final path holds whatever the caller put
86/// there (typically an empty placeholder claiming the name), so a crash or
87/// error never leaves a truncated file that looks complete. Dropping
88/// without committing removes the temporary file.
89///
90/// A hard crash (kill, power loss) can still leave the empty placeholder
91/// and a `.<name>.tmpN` file behind; both are inert and safe to delete
92/// manually. We deliberately never auto-clean them: deleting files we
93/// cannot prove we created is worse than the litter.
94pub struct AtomicOutputFile {
95    tmp_path: std::path::PathBuf,
96    final_path: std::path::PathBuf,
97    file: Option<std::fs::File>,
98}
99
100impl AtomicOutputFile {
101    /// Starts writing for `final_path`, which the caller must already have
102    /// claimed (created) so the name is reserved under its own overwrite
103    /// policy.
104    pub fn start(final_path: std::path::PathBuf) -> WorkflowResult<Self> {
105        let file_name = final_path
106            .file_name()
107            .ok_or_else(|| WorkflowError::File("Output path has no filename".to_string()))?
108            .to_string_lossy()
109            .into_owned();
110
111        for n in 0..1000u32 {
112            let tmp_path = final_path.with_file_name(format!(".{file_name}.tmp{n}"));
113            match std::fs::File::create_new(&tmp_path) {
114                Ok(file) => {
115                    return Ok(Self {
116                        tmp_path,
117                        final_path,
118                        file: Some(file),
119                    });
120                }
121                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
122                Err(e) => return Err(e.into()),
123            }
124        }
125        Err(WorkflowError::File(
126            "Unable to create a temporary output file".to_string(),
127        ))
128    }
129
130    /// The temporary file being written, e.g. to apply metadata before
131    /// committing (rename preserves it).
132    pub fn as_file(&self) -> &std::fs::File {
133        self.file.as_ref().expect("not committed")
134    }
135
136    /// Flushes the content to disk and atomically renames it over the final
137    /// path (replacing the caller's placeholder).
138    pub fn commit(&mut self) -> WorkflowResult<()> {
139        let file = self
140            .file
141            .take()
142            .ok_or_else(|| WorkflowError::File("Output already committed".to_string()))?;
143        file.sync_all()?;
144        drop(file);
145
146        // On Windows, rename does not replace an existing destination.
147        #[cfg(windows)]
148        {
149            let _ = std::fs::remove_file(&self.final_path);
150        }
151        std::fs::rename(&self.tmp_path, &self.final_path)?;
152
153        // Best-effort directory sync so the rename itself is durable.
154        #[cfg(unix)]
155        if let Some(dir) = self.final_path.parent()
156            && let Ok(dir_handle) = std::fs::File::open(dir)
157        {
158            let _ = dir_handle.sync_all();
159        }
160        Ok(())
161    }
162}
163
164impl Write for AtomicOutputFile {
165    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
166        self.file.as_ref().expect("not committed").write(buf)
167    }
168    fn flush(&mut self) -> std::io::Result<()> {
169        self.file.as_ref().expect("not committed").flush()
170    }
171}
172
173impl Drop for AtomicOutputFile {
174    fn drop(&mut self) {
175        if self.file.is_some() {
176            self.file = None;
177            let _ = std::fs::remove_file(&self.tmp_path);
178        }
179    }
180}
181
182/// Resolves the output directory for a workflow: the given path (created if
183/// missing) or the current directory.
184pub fn resolve_output_dir(
185    output_dir: Option<std::path::PathBuf>,
186) -> WorkflowResult<std::path::PathBuf> {
187    match output_dir {
188        Some(dir) => {
189            std::fs::create_dir_all(&dir)?;
190            Ok(dir)
191        }
192        None => Ok(std::env::current_dir()?),
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::errors::WorkflowError;
200    use std::io::Write;
201    use tempfile::NamedTempFile;
202
203    #[test]
204    fn test_read_n_bytes_from_file_exact() {
205        let mut temp_file = NamedTempFile::new().unwrap();
206        let data = b"hello world";
207        temp_file.write_all(data).unwrap();
208        let path = temp_file.path();
209
210        let result = read_n_bytes_from_file(path, 11).unwrap();
211        assert_eq!(result.as_slice(), data);
212    }
213
214    #[test]
215    fn test_read_n_bytes_from_file_more_than_available() {
216        let mut temp_file = NamedTempFile::new().unwrap();
217        let data = b"hello";
218        temp_file.write_all(data).unwrap();
219        let path = temp_file.path();
220
221        let result = read_n_bytes_from_file(path, 10).unwrap();
222        assert_eq!(result.as_slice(), data);
223    }
224
225    #[test]
226    fn test_read_n_bytes_from_file_less_than_requested() {
227        let mut temp_file = NamedTempFile::new().unwrap();
228        let data = b"hello world this is a test";
229        temp_file.write_all(data).unwrap();
230        let path = temp_file.path();
231
232        let result = read_n_bytes_from_file(path, 5).unwrap();
233        assert_eq!(result.as_slice(), b"hello");
234    }
235
236    #[test]
237    fn test_read_n_bytes_from_file_zero() {
238        let mut temp_file = NamedTempFile::new().unwrap();
239        let data = b"hello";
240        temp_file.write_all(data).unwrap();
241        let path = temp_file.path();
242
243        let result = read_n_bytes_from_file(path, 0).unwrap();
244        assert_eq!(result.as_slice(), b"");
245    }
246
247    #[test]
248    fn test_read_n_bytes_from_file_nonexistent() {
249        let path = std::path::Path::new("/nonexistent/file");
250        let result = read_n_bytes_from_file(path, 10);
251        assert!(result.is_err());
252        // Should be Io error
253        assert!(matches!(result, Err(WorkflowError::Io(_))));
254    }
255
256    #[test]
257    fn test_sanitize_relative_path() {
258        use std::path::PathBuf;
259
260        assert_eq!(
261            sanitize_relative_path("a/b/c.txt").unwrap(),
262            PathBuf::from("a/b/c.txt")
263        );
264        assert_eq!(
265            sanitize_relative_path("plain.txt").unwrap(),
266            PathBuf::from("plain.txt")
267        );
268        // Absolute and dot components are relativized/dropped.
269        assert_eq!(
270            sanitize_relative_path("/abs/path").unwrap(),
271            PathBuf::from("abs/path")
272        );
273        assert_eq!(
274            sanitize_relative_path("./a//b/.").unwrap(),
275            PathBuf::from("a/b")
276        );
277        // Escapes and unsupported separators are rejected outright.
278        for evil in ["..", "../x", "a/../b", "a/..", "a\\b", "", ".", "//"] {
279            assert!(sanitize_relative_path(evil).is_err(), "accepted {evil:?}");
280        }
281        // Drive prefixes and stream separators escape only on Windows;
282        // on Unix a ':' is an ordinary filename character.
283        #[cfg(windows)]
284        for evil in ["C:evil", "C:/evil", "a/C:evil", "file:stream"] {
285            assert!(sanitize_relative_path(evil).is_err(), "accepted {evil:?}");
286        }
287    }
288
289    #[test]
290    fn test_resolve_output_dir_creates_missing_directory() {
291        let temp_dir = tempfile::TempDir::new().unwrap();
292        let nested = temp_dir.path().join("a").join("b");
293
294        let resolved = resolve_output_dir(Some(nested.clone())).unwrap();
295        assert_eq!(resolved, nested);
296        assert!(nested.is_dir());
297    }
298
299    #[test]
300    fn test_resolve_output_dir_defaults_to_current_dir() {
301        let resolved = resolve_output_dir(None).unwrap();
302        assert_eq!(resolved, std::env::current_dir().unwrap());
303    }
304
305    #[test]
306    fn test_atomic_output_file_commit() {
307        let temp_dir = tempfile::TempDir::new().unwrap();
308        let final_path = temp_dir.path().join("out.txt");
309        std::fs::write(&final_path, b"").unwrap(); // placeholder claim
310
311        let mut atomic = AtomicOutputFile::start(final_path.clone()).unwrap();
312        atomic.write_all(b"content").unwrap();
313        atomic.commit().unwrap();
314        drop(atomic);
315
316        assert_eq!(std::fs::read(&final_path).unwrap(), b"content");
317        // No temporary files remain.
318        let leftovers = std::fs::read_dir(temp_dir.path()).unwrap().count();
319        assert_eq!(leftovers, 1);
320    }
321
322    #[test]
323    fn test_atomic_output_file_drop_without_commit_keeps_placeholder() {
324        let temp_dir = tempfile::TempDir::new().unwrap();
325        let final_path = temp_dir.path().join("out.txt");
326        std::fs::write(&final_path, b"placeholder").unwrap();
327
328        {
329            let mut atomic = AtomicOutputFile::start(final_path.clone()).unwrap();
330            atomic.write_all(b"partial").unwrap();
331            // dropped without commit
332        }
333
334        assert_eq!(std::fs::read(&final_path).unwrap(), b"placeholder");
335        let leftovers = std::fs::read_dir(temp_dir.path()).unwrap().count();
336        assert_eq!(leftovers, 1, "temporary file must be cleaned up");
337    }
338}