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
17fn claim_output_path(path: &Path, display_name: &str, force: bool) -> WorkflowResult<bool> {
24 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
43fn 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 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
78fn 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 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
95fn 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 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 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(¤t[..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
138pub 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
159fn 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); if claimed {
190 let _ = std::fs::remove_file(&output_file.path);
191 }
192 Err(e)
193 }
194 }
195}
196
197fn 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 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 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 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 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 if let Some((out, path, _, claimed)) = current {
302 drop(out); if claimed {
304 let _ = std::fs::remove_file(path);
305 }
306 }
307 Err(e)
308 }
309 }
310}
311
312fn apply_path_metadata(path: &Path, metadata: &FileMetadata) -> WorkflowResult<()> {
314 let mut options = std::fs::OpenOptions::new();
315 options.read(true);
316 #[cfg(windows)]
319 {
320 use std::os::windows::fs::OpenOptionsExt;
321 options.write(true).custom_flags(0x0200_0000); }
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 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 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 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); 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}