1use std::{
2 io::{BufWriter, Write},
3 path::{Path, PathBuf},
4};
5
6use rand::{rand_core::TryRng, rngs::SysRng};
7use shadow_crypt_core::{
8 archive,
9 file::FileMetadata,
10 memory::SecureString,
11 v3::{header::FileHeader, stream::StreamSealer},
12};
13use zeroize::Zeroizing;
14
15use crate::{
16 encryption::file::{EncryptionInputFile, EncryptionOutputFile},
17 errors::{WorkflowError, WorkflowResult},
18 utils::{AtomicOutputFile, read_up_to},
19};
20
21pub fn gather_metadata(file: &EncryptionInputFile) -> FileMetadata {
24 gather_path_metadata(&file.path, file.filename.clone())
25}
26
27fn gather_path_metadata(path: &Path, stored_name: String) -> FileMetadata {
28 let fs_metadata = std::fs::metadata(path).ok();
29 let mtime = fs_metadata.as_ref().and_then(|m| m.modified().ok());
30
31 #[cfg(unix)]
32 let mode = fs_metadata.as_ref().map(|m| {
33 use std::os::unix::fs::PermissionsExt;
34 m.permissions().mode() & 0o7777
35 });
36 #[cfg(not(unix))]
37 let mode = None;
38
39 FileMetadata::new(SecureString::new(stored_name), mtime, mode)
40}
41
42#[derive(Debug)]
44pub struct WalkedEntry {
45 pub path: PathBuf,
47 pub rel: String,
49 pub is_dir: bool,
50 pub size: u64,
51}
52
53pub fn walk_directory(root: &Path) -> WorkflowResult<(Vec<WalkedEntry>, usize)> {
58 let mut entries = Vec::new();
59 let mut skipped = 0;
60 walk_into(root, "", &mut entries, &mut skipped)?;
61 Ok((entries, skipped))
62}
63
64fn walk_into(
65 dir: &Path,
66 prefix: &str,
67 entries: &mut Vec<WalkedEntry>,
68 skipped: &mut usize,
69) -> WorkflowResult<()> {
70 let mut children: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
71 children.sort_by_key(|e| e.file_name());
72
73 for child in children {
74 let Some(name) = child.file_name().to_str().map(str::to_string) else {
75 *skipped += 1;
76 continue;
77 };
78 if name.contains('\\') {
82 *skipped += 1;
83 continue;
84 }
85 let rel = if prefix.is_empty() {
86 name
87 } else {
88 format!("{prefix}/{name}")
89 };
90
91 let file_type = child.file_type()?;
94 if file_type.is_dir() {
95 entries.push(WalkedEntry {
96 path: child.path(),
97 rel: rel.clone(),
98 is_dir: true,
99 size: 0,
100 });
101 walk_into(&child.path(), &rel, entries, skipped)?;
102 } else if file_type.is_file() {
103 entries.push(WalkedEntry {
104 path: child.path(),
105 rel,
106 is_dir: false,
107 size: child.metadata()?.len(),
108 });
109 } else {
110 *skipped += 1;
111 }
112 }
113 Ok(())
114}
115
116struct ChunkPump<W: Write> {
120 sealer: StreamSealer,
121 writer: W,
122 buf: Zeroizing<Vec<u8>>,
123}
124
125impl<W: Write> ChunkPump<W> {
126 fn new(sealer: StreamSealer, writer: W) -> Self {
127 Self {
128 sealer,
129 writer,
130 buf: Zeroizing::new(Vec::new()),
131 }
132 }
133
134 fn feed(&mut self, bytes: &[u8]) -> WorkflowResult<()> {
135 self.buf.extend_from_slice(bytes);
136 let chunk_size = self.sealer.chunk_plaintext_len();
137 while self.buf.len() > chunk_size {
140 let sealed = self.sealer.seal_chunk(&self.buf[..chunk_size], false)?;
141 self.writer.write_all(&sealed)?;
142 self.buf.drain(..chunk_size);
143 }
144 Ok(())
145 }
146
147 fn finish(mut self) -> WorkflowResult<W> {
148 let sealed = self.sealer.seal_chunk(&self.buf, true)?;
149 self.writer.write_all(&sealed)?;
150 Ok(self.writer)
151 }
152}
153
154pub fn stream_encrypt_directory(
158 entries: &[WalkedEntry],
159 header: &FileHeader,
160 sealer: StreamSealer,
161 output_dir: &Path,
162) -> WorkflowResult<EncryptionOutputFile> {
163 let (claim, output_file) = create_encryption_output_file(output_dir)?;
164 drop(claim); let atomic = AtomicOutputFile::start(output_file.path.clone())?;
166
167 let result = (|| -> WorkflowResult<()> {
168 let mut writer = BufWriter::new(atomic);
169 writer.write_all(&header.serialize())?;
170
171 let mut pump = ChunkPump::new(sealer, writer);
172 pump.feed(&archive::MAGIC)?;
173
174 for entry in entries {
175 let entry_metadata = gather_path_metadata(&entry.path, entry.rel.clone());
176 if entry.is_dir {
177 pump.feed(&archive::encode_directory(&entry_metadata)?)?;
178 } else {
179 pump.feed(&archive::encode_file(&entry_metadata, entry.size)?)?;
180 feed_file_content(&mut pump, &entry.path, entry.size)?;
181 }
182 }
183 pump.feed(&archive::encode_end())?;
184
185 let mut writer = pump.finish()?;
186 writer.flush()?;
187 writer
188 .into_inner()
189 .map_err(|e| WorkflowError::Io(e.into_error()))?
190 .commit()?;
191 Ok(())
192 })();
193
194 match result {
195 Ok(()) => Ok(output_file),
196 Err(e) => {
197 let _ = std::fs::remove_file(&output_file.path);
198 Err(e)
199 }
200 }
201}
202
203fn feed_file_content<W: Write>(
207 pump: &mut ChunkPump<W>,
208 path: &Path,
209 declared_len: u64,
210) -> WorkflowResult<()> {
211 let mut reader = std::fs::File::open(path)?;
212 let mut buf = Zeroizing::new(vec![0u8; 64 * 1024]);
213 let mut remaining = declared_len;
214 while remaining > 0 {
215 let take = buf
216 .len()
217 .min(usize::try_from(remaining).unwrap_or(buf.len()));
218 let n = read_up_to(&mut reader, &mut buf[..take])?;
219 if n == 0 {
220 return Err(WorkflowError::File(format!(
221 "File changed while archiving: {}",
222 path.display()
223 )));
224 }
225 pump.feed(&buf[..n])?;
226 remaining -= n as u64;
227 }
228 if read_up_to(&mut reader, &mut buf[..1])? > 0 {
232 return Err(WorkflowError::File(format!(
233 "File changed while archiving: {}",
234 path.display()
235 )));
236 }
237 Ok(())
238}
239
240pub fn stream_encrypt_file(
244 file: &EncryptionInputFile,
245 header: &FileHeader,
246 mut sealer: StreamSealer,
247 output_dir: &std::path::Path,
248) -> WorkflowResult<EncryptionOutputFile> {
249 let (claim, output_file) = create_encryption_output_file(output_dir)?;
250 drop(claim); let atomic = AtomicOutputFile::start(output_file.path.clone())?;
252
253 let result = (|| -> WorkflowResult<()> {
254 let mut writer = BufWriter::new(atomic);
255 writer.write_all(&header.serialize())?;
256
257 let mut reader = std::fs::File::open(&file.path)?;
258 let chunk_size = sealer.chunk_plaintext_len();
259 let mut current = Zeroizing::new(vec![0u8; chunk_size]);
262 let mut next = Zeroizing::new(vec![0u8; chunk_size]);
263
264 let mut current_len = read_up_to(&mut reader, &mut current)?;
268 loop {
269 let next_len = read_up_to(&mut reader, &mut next)?;
270 let is_last = next_len == 0;
271 writer.write_all(&sealer.seal_chunk(¤t[..current_len], is_last)?)?;
272 if is_last {
273 break;
274 }
275 std::mem::swap(&mut current, &mut next);
276 current_len = next_len;
277 }
278
279 writer.flush()?;
280 writer
281 .into_inner()
282 .map_err(|e| WorkflowError::Io(e.into_error()))?
283 .commit()?;
284 Ok(())
285 })();
286
287 match result {
288 Ok(()) => Ok(output_file),
289 Err(e) => {
290 let _ = std::fs::remove_file(&output_file.path);
291 Err(e)
292 }
293 }
294}
295
296fn generate_output_filename() -> WorkflowResult<String> {
297 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
298 const NAME_LENGTH: usize = 16;
299 const REJECTION_BOUND: u8 = (u8::MAX / CHARSET.len() as u8) * CHARSET.len() as u8;
302
303 let mut name = String::with_capacity(NAME_LENGTH);
304 let mut bytes = [0u8; 2 * NAME_LENGTH];
305 while name.len() < NAME_LENGTH {
306 SysRng.try_fill_bytes(&mut bytes).map_err(|e| {
307 WorkflowError::File(format!("Failed to generate output filename: {}", e))
308 })?;
309 for byte in bytes {
310 if byte < REJECTION_BOUND && name.len() < NAME_LENGTH {
311 name.push(CHARSET[byte as usize % CHARSET.len()] as char);
312 }
313 }
314 }
315 Ok(name)
316}
317
318fn create_encryption_output_file(
319 output_dir: &std::path::Path,
320) -> WorkflowResult<(std::fs::File, EncryptionOutputFile)> {
321 for _ in 0..1000 {
324 let mut path = PathBuf::from(generate_output_filename()?);
325 path.set_extension("shadow");
326
327 let full_path = output_dir.join(&path);
328
329 match std::fs::File::create_new(&full_path) {
330 Ok(f) => {
331 let filename_str = path
332 .to_str()
333 .ok_or_else(|| WorkflowError::File("Invalid output filename".to_string()))?
334 .to_string();
335
336 return Ok((
337 f,
338 EncryptionOutputFile {
339 path: full_path,
340 filename: filename_str,
341 },
342 ));
343 }
344 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
345 Err(e) => return Err(e.into()),
346 }
347 }
348
349 Err(WorkflowError::File(
350 "Unable to generate a unique output filename after 1000 attempts".to_string(),
351 ))
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use shadow_crypt_core::{
358 memory::SecureKey, v3::file::EncryptedFile, v3::key::KeyDerivationParams,
359 };
360 use std::fs;
361 use tempfile::TempDir;
362
363 fn input_file(dir: &std::path::Path, name: &str, content: &[u8]) -> EncryptionInputFile {
364 let path = dir.join(name);
365 fs::write(&path, content).unwrap();
366 EncryptionInputFile {
367 path,
368 filename: name.to_string(),
369 size: content.len() as u64,
370 kind: crate::encryption::file::InputKind::File,
371 }
372 }
373
374 #[test]
375 fn test_generate_output_filename() {
376 let filename = generate_output_filename().unwrap();
377 assert_eq!(filename.len(), 16);
378 assert!(filename.chars().all(|c| c.is_ascii_alphabetic()));
379 }
380
381 #[test]
382 fn test_create_encryption_output_file() {
383 let temp_dir = TempDir::new().unwrap();
384
385 let (_, first) = create_encryption_output_file(temp_dir.path()).unwrap();
386 let (_, second) = create_encryption_output_file(temp_dir.path()).unwrap();
387
388 assert!(first.path.exists());
389 assert!(second.path.exists());
390 assert_ne!(first.filename, second.filename);
391 assert!(first.filename.ends_with(".shadow"));
392 }
393
394 #[test]
395 fn test_gather_metadata() {
396 let temp_dir = TempDir::new().unwrap();
397 let file = input_file(temp_dir.path(), "meta.txt", b"content");
398
399 let metadata = gather_metadata(&file);
400 assert_eq!(metadata.filename().as_str(), "meta.txt");
401 assert!(metadata.mtime().is_some());
402 #[cfg(unix)]
403 assert!(metadata.mode().is_some());
404 }
405
406 #[test]
407 fn test_stream_encrypt_file_round_trips() {
408 let temp_dir = TempDir::new().unwrap();
409 let content = b"stream me please".repeat(10);
410 let file = input_file(temp_dir.path(), "in.txt", &content);
411
412 let key = SecureKey::new([7u8; 32]);
413 let params = KeyDerivationParams::test_defaults();
414 let (header, sealer) = StreamSealer::begin(
415 &gather_metadata(&file),
416 &key,
417 params,
418 [1u8; 16],
419 [2u8; 16],
420 [3u8; 24],
421 )
422 .unwrap();
423
424 let output_file = stream_encrypt_file(&file, &header, sealer, temp_dir.path()).unwrap();
425 assert!(output_file.path.exists());
426 assert!(output_file.filename.ends_with(".shadow"));
427
428 let bytes = fs::read(&output_file.path).unwrap();
430 let decrypted = EncryptedFile::from_bytes(&bytes)
431 .unwrap()
432 .decrypt(&key)
433 .unwrap();
434 assert_eq!(decrypted.filename().as_str(), "in.txt");
435 assert_eq!(decrypted.content().as_slice(), content.as_slice());
436 }
437
438 #[test]
439 fn test_stream_encrypt_empty_file() {
440 let temp_dir = TempDir::new().unwrap();
441 let file = input_file(temp_dir.path(), "empty.txt", b"");
442
443 let key = SecureKey::new([7u8; 32]);
444 let (header, sealer) = StreamSealer::begin(
445 &gather_metadata(&file),
446 &key,
447 KeyDerivationParams::test_defaults(),
448 [1u8; 16],
449 [2u8; 16],
450 [3u8; 24],
451 )
452 .unwrap();
453
454 let output_file = stream_encrypt_file(&file, &header, sealer, temp_dir.path()).unwrap();
455 let bytes = fs::read(&output_file.path).unwrap();
456 let decrypted = EncryptedFile::from_bytes(&bytes)
457 .unwrap()
458 .decrypt(&key)
459 .unwrap();
460 assert!(decrypted.content().as_slice().is_empty());
461 }
462
463 #[cfg(unix)]
464 #[test]
465 fn test_walk_skips_backslash_names() {
466 let temp_dir = TempDir::new().unwrap();
467 fs::write(temp_dir.path().join("a\\b.txt"), b"x").unwrap();
468 fs::write(temp_dir.path().join("ok.txt"), b"x").unwrap();
469
470 let (entries, skipped) = walk_directory(temp_dir.path()).unwrap();
471 assert_eq!(skipped, 1);
472 assert_eq!(entries.len(), 1);
473 assert_eq!(entries[0].rel, "ok.txt");
474 }
475
476 #[test]
477 fn test_archive_errors_when_file_grew_since_walk() {
478 let temp_dir = TempDir::new().unwrap();
479 let path = temp_dir.path().join("grow.txt");
480 fs::write(&path, b"0123456789").unwrap();
481 let entries = vec![WalkedEntry {
483 path: path.clone(),
484 rel: "grow.txt".to_string(),
485 is_dir: false,
486 size: 5,
487 }];
488
489 let out_dir = TempDir::new().unwrap();
490 let key = SecureKey::new([7u8; 32]);
491 let metadata = gather_path_metadata(&path, "grow.txt".to_string()).into_archive();
492 let (header, sealer) = StreamSealer::begin(
493 &metadata,
494 &key,
495 KeyDerivationParams::test_defaults(),
496 [1u8; 16],
497 [2u8; 16],
498 [3u8; 24],
499 )
500 .unwrap();
501
502 assert!(stream_encrypt_directory(&entries, &header, sealer, out_dir.path()).is_err());
503 }
504
505 #[test]
506 fn test_stream_encrypt_missing_input_cleans_up_output() {
507 let temp_dir = TempDir::new().unwrap();
508 let file = EncryptionInputFile {
509 path: temp_dir.path().join("missing.txt"),
510 filename: "missing.txt".to_string(),
511 size: 0,
512 kind: crate::encryption::file::InputKind::File,
513 };
514
515 let key = SecureKey::new([7u8; 32]);
516 let (header, sealer) = StreamSealer::begin(
517 &gather_metadata(&file),
518 &key,
519 KeyDerivationParams::test_defaults(),
520 [1u8; 16],
521 [2u8; 16],
522 [3u8; 24],
523 )
524 .unwrap();
525
526 assert!(stream_encrypt_file(&file, &header, sealer, temp_dir.path()).is_err());
527 let leftovers: Vec<_> = fs::read_dir(temp_dir.path())
529 .unwrap()
530 .filter_map(|e| e.ok())
531 .filter(|e| e.path().extension().is_some_and(|ext| ext == "shadow"))
532 .collect();
533 assert!(leftovers.is_empty());
534 }
535}