use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use shadow_crypt_core::{
archive::{ArchiveEvent, ArchiveParser},
file::{ContentKind, FileMetadata},
memory::{SecureKey, SecureString},
vault::{ContentDecryptor, ParsedFile},
};
use crate::{
decryption::file::{DecryptionInputFile, DecryptionOutputFile},
errors::{WorkflowError, WorkflowResult},
utils::{AtomicOutputFile, read_up_to, sanitize_relative_path},
};
fn claim_output_path(path: &Path, display_name: &str, force: bool) -> WorkflowResult<bool> {
match std::fs::File::create_new(path) {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if force {
Ok(false)
} else {
Err(WorkflowError::File(format!(
"Output file '{}' already exists (use --force to overwrite)",
display_name
)))
}
}
Err(e) => Err(WorkflowError::Io(e)),
}
}
fn create_output_file(
filename: &SecureString,
output_dir: &Path,
force: bool,
) -> WorkflowResult<(AtomicOutputFile, DecryptionOutputFile, bool)> {
let safe_rel = sanitize_relative_path(filename.as_str())?;
let path = output_dir.join(&safe_rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let display_name = safe_rel.to_string_lossy().into_owned();
let claimed = claim_output_path(&path, &display_name, force)?;
let out = AtomicOutputFile::start(path.clone())?;
Ok((
out,
DecryptionOutputFile {
path,
filename: display_name,
},
claimed,
))
}
fn apply_metadata(f: &std::fs::File, metadata: &FileMetadata) -> WorkflowResult<()> {
#[cfg(unix)]
if let Some(mode) = metadata.mode() {
use std::os::unix::fs::PermissionsExt;
f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777))?;
}
if let Some(mtime) = metadata.mtime() {
f.set_modified(mtime)?;
}
Ok(())
}
fn pump_content(
file: &DecryptionInputFile,
parsed: &ParsedFile,
decryptor: &mut ContentDecryptor<'_>,
mut sink: impl FnMut(&[u8]) -> WorkflowResult<()>,
) -> WorkflowResult<()> {
let mut reader = std::fs::File::open(&file.path)?;
reader.seek(SeekFrom::Start(parsed.header_length() as u64))?;
match decryptor.chunk_len() {
None => {
let mut content = Vec::new();
reader.read_to_end(&mut content)?;
sink(decryptor.decrypt_chunk(&content, true)?.as_slice())?;
}
Some(chunk_len) => {
let mut current = vec![0u8; chunk_len];
let mut next = vec![0u8; chunk_len];
let mut current_len = read_up_to(&mut reader, &mut current)?;
loop {
let next_len = read_up_to(&mut reader, &mut next)?;
let is_last = next_len == 0;
sink(
decryptor
.decrypt_chunk(¤t[..current_len], is_last)?
.as_slice(),
)?;
if is_last {
break;
}
std::mem::swap(&mut current, &mut next);
current_len = next_len;
}
}
}
Ok(())
}
pub fn stream_decrypt_file(
file: &DecryptionInputFile,
parsed: &ParsedFile,
key: &SecureKey,
metadata: &FileMetadata,
output_dir: &std::path::Path,
force: bool,
) -> WorkflowResult<DecryptionOutputFile> {
match metadata.kind() {
ContentKind::File => {
stream_decrypt_single_file(file, parsed, key, metadata, output_dir, force)
}
ContentKind::Archive => extract_archive(file, parsed, key, metadata, output_dir, force),
}
}
fn stream_decrypt_single_file(
file: &DecryptionInputFile,
parsed: &ParsedFile,
key: &SecureKey,
metadata: &FileMetadata,
output_dir: &std::path::Path,
force: bool,
) -> WorkflowResult<DecryptionOutputFile> {
let (mut out, output_file, claimed) =
create_output_file(metadata.filename(), output_dir, force)?;
let result = (|| -> WorkflowResult<()> {
let mut decryptor = parsed.content_decryptor(key);
pump_content(file, parsed, &mut decryptor, |plaintext| {
out.write_all(plaintext)?;
Ok(())
})?;
apply_metadata(out.as_file(), metadata)?;
out.commit()?;
Ok(())
})();
match result {
Ok(()) => Ok(output_file),
Err(e) => {
drop(out); if claimed {
let _ = std::fs::remove_file(&output_file.path);
}
Err(e)
}
}
}
fn extract_archive(
file: &DecryptionInputFile,
parsed: &ParsedFile,
key: &SecureKey,
metadata: &FileMetadata,
output_dir: &std::path::Path,
force: bool,
) -> WorkflowResult<DecryptionOutputFile> {
let root_rel = sanitize_relative_path(metadata.filename().as_str())?;
let root = output_dir.join(&root_rel);
let display_name = root_rel.to_string_lossy().into_owned();
match root.symlink_metadata() {
Ok(meta) => {
if !force {
return Err(WorkflowError::File(format!(
"Output directory '{}' already exists (use --force to extract into it)",
display_name
)));
}
if !meta.file_type().is_dir() {
return Err(WorkflowError::File(format!(
"Output path '{}' exists and is not a directory",
display_name
)));
}
}
Err(_) => std::fs::create_dir_all(&root)?,
}
let mut parser = ArchiveParser::new();
let mut current: Option<(AtomicOutputFile, PathBuf, FileMetadata, bool)> = None;
let mut directory_metas: Vec<(PathBuf, FileMetadata)> = Vec::new();
let result = (|| -> WorkflowResult<()> {
let mut decryptor = parsed.content_decryptor(key);
pump_content(file, parsed, &mut decryptor, |plaintext| {
parser.feed(plaintext);
while let Some(event) = parser.next_event()? {
match event {
ArchiveEvent::Directory { metadata } => {
let path = root.join(sanitize_relative_path(metadata.filename().as_str())?);
std::fs::create_dir_all(&path)?;
directory_metas.push((path, metadata));
}
ArchiveEvent::FileStart { metadata, .. } => {
let rel = sanitize_relative_path(metadata.filename().as_str())?;
let path = root.join(&rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let claimed = claim_output_path(&path, &rel.to_string_lossy(), force)?;
let out = AtomicOutputFile::start(path.clone())?;
current = Some((out, path, metadata, claimed));
}
ArchiveEvent::FileData(data) => {
let (out, _, _, _) = current.as_mut().ok_or_else(|| {
WorkflowError::File("Archive stream out of order".to_string())
})?;
out.write_all(data.as_slice())?;
}
ArchiveEvent::FileEnd => {
let (mut out, _, entry_metadata, _) = current.take().ok_or_else(|| {
WorkflowError::File("Archive stream out of order".to_string())
})?;
apply_metadata(out.as_file(), &entry_metadata)?;
out.commit()?;
}
ArchiveEvent::End => {}
}
}
Ok(())
})?;
parser.finish()?;
directory_metas.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count()));
for (path, dir_metadata) in &directory_metas {
apply_path_metadata(path, dir_metadata)?;
}
apply_path_metadata(&root, metadata)?;
Ok(())
})();
match result {
Ok(()) => Ok(DecryptionOutputFile {
path: root,
filename: display_name,
}),
Err(e) => {
if let Some((out, path, _, claimed)) = current {
drop(out); if claimed {
let _ = std::fs::remove_file(path);
}
}
Err(e)
}
}
}
fn apply_path_metadata(path: &Path, metadata: &FileMetadata) -> WorkflowResult<()> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.write(true).custom_flags(0x0200_0000); }
let f = options.open(path)?;
apply_metadata(&f, metadata)
}
#[cfg(test)]
mod tests {
use super::*;
use shadow_crypt_core::memory::SecureBytes;
use std::fs;
fn write_output(
filename: &str,
content: &[u8],
output_dir: &std::path::Path,
force: bool,
) -> WorkflowResult<DecryptionOutputFile> {
let filename = SecureString::new(filename.to_string());
let content = SecureBytes::new(content.to_vec());
let (mut f, output_file, _) = create_output_file(&filename, output_dir, force)?;
f.write_all(content.as_slice())?;
f.commit()?;
Ok(output_file)
}
#[test]
fn test_create_and_write_output_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output = write_output("test.txt", b"test content", temp_dir.path(), false).unwrap();
assert_eq!(output.filename, "test.txt");
assert_eq!(fs::read(&output.path).unwrap(), b"test content");
}
#[test]
fn test_multi_component_name_recreates_directories() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output = write_output("photos/sub/b.txt", b"bravo", temp_dir.path(), false).unwrap();
assert_eq!(output.filename, "photos/sub/b.txt");
assert_eq!(
fs::read(temp_dir.path().join("photos/sub/b.txt")).unwrap(),
b"bravo"
);
}
#[test]
fn test_output_file_path_traversal_rejected() {
let temp_dir = tempfile::TempDir::new().unwrap();
for malicious_name in &["../../etc/passwd", "../sibling", "/abs/path", ".."] {
let result = write_output(malicious_name, b"evil", temp_dir.path(), false);
match malicious_name {
&".." => {
assert!(
result.is_err(),
"Expected error for filename '{malicious_name}'"
);
}
_ => {
if let Ok(output) = result {
assert!(
output.path.starts_with(temp_dir.path()),
"Output escaped temp_dir for '{malicious_name}'"
);
}
}
}
}
}
#[test]
fn test_output_file_no_overwrite() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output_path = temp_dir.path().join("test.txt");
let existing_content = b"existing content";
fs::write(&output_path, existing_content).unwrap();
let result = write_output("test.txt", b"new content", temp_dir.path(), false);
assert!(result.is_err());
if let Err(WorkflowError::File(msg)) = result {
assert!(msg.contains("already exists"));
} else {
panic!("Expected File error");
}
assert_eq!(fs::read(&output_path).unwrap(), existing_content);
}
#[test]
fn test_force_keeps_existing_file_when_not_committed() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output_path = temp_dir.path().join("test.txt");
fs::write(&output_path, b"precious").unwrap();
let filename = SecureString::new("test.txt".to_string());
let (mut f, _, claimed) = create_output_file(&filename, temp_dir.path(), true).unwrap();
assert!(
!claimed,
"existing file must not be claimed as a placeholder"
);
f.write_all(b"partial").unwrap();
drop(f);
assert_eq!(fs::read(&output_path).unwrap(), b"precious");
}
#[test]
fn test_output_file_force_overwrites() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output_path = temp_dir.path().join("test.txt");
fs::write(&output_path, b"existing content").unwrap();
let output = write_output("test.txt", b"new content", temp_dir.path(), true).unwrap();
assert_eq!(fs::read(&output.path).unwrap(), b"new content");
}
#[test]
fn test_output_file_force_without_existing_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output = write_output("test.txt", b"content", temp_dir.path(), true).unwrap();
assert_eq!(fs::read(&output.path).unwrap(), b"content");
}
}