shadow_crypt_shell/
utils.rs1use 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
15pub 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 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 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
68pub 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
83pub 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 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 pub fn as_file(&self) -> &std::fs::File {
133 self.file.as_ref().expect("not committed")
134 }
135
136 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 #[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 #[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
182pub 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 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 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 for evil in ["..", "../x", "a/../b", "a/..", "a\\b", "", ".", "//"] {
279 assert!(sanitize_relative_path(evil).is_err(), "accepted {evil:?}");
280 }
281 #[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(); 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 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 }
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}