use std::io::{Read, Write};
use shadow_crypt_core::memory::SecureBytes;
use crate::errors::{WorkflowError, WorkflowResult};
pub fn read_n_bytes_from_file(path: &std::path::Path, n: usize) -> WorkflowResult<SecureBytes> {
let f = std::fs::File::open(path)?;
let mut buffer = Vec::new();
f.take(n as u64).read_to_end(&mut buffer)?;
Ok(SecureBytes::new(buffer))
}
pub fn sanitize_relative_path(name: &str) -> WorkflowResult<std::path::PathBuf> {
if name.contains('\\') {
return Err(WorkflowError::File(
"Decrypted path contains unsupported separators".to_string(),
));
}
let mut out = std::path::PathBuf::new();
for component in name.split('/') {
match component {
"" | "." => continue,
".." => {
return Err(WorkflowError::File(
"Decrypted path contains unsafe components".to_string(),
));
}
component => {
if cfg!(windows) && component.contains(':') {
return Err(WorkflowError::File(
"Decrypted path contains unsafe components".to_string(),
));
}
out.push(component);
}
}
}
if out.as_os_str().is_empty() {
return Err(WorkflowError::File(
"Decrypted path contains no usable components".to_string(),
));
}
if !out
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
{
return Err(WorkflowError::File(
"Decrypted path contains unsafe components".to_string(),
));
}
Ok(out)
}
pub fn read_up_to(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
let n = reader.read(&mut buf[filled..])?;
if n == 0 {
break;
}
filled += n;
}
Ok(filled)
}
pub struct AtomicOutputFile {
tmp_path: std::path::PathBuf,
final_path: std::path::PathBuf,
file: Option<std::fs::File>,
}
impl AtomicOutputFile {
pub fn start(final_path: std::path::PathBuf) -> WorkflowResult<Self> {
let file_name = final_path
.file_name()
.ok_or_else(|| WorkflowError::File("Output path has no filename".to_string()))?
.to_string_lossy()
.into_owned();
for n in 0..1000u32 {
let tmp_path = final_path.with_file_name(format!(".{file_name}.tmp{n}"));
match std::fs::File::create_new(&tmp_path) {
Ok(file) => {
return Ok(Self {
tmp_path,
final_path,
file: Some(file),
});
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
}
Err(WorkflowError::File(
"Unable to create a temporary output file".to_string(),
))
}
pub fn as_file(&self) -> &std::fs::File {
self.file.as_ref().expect("not committed")
}
pub fn commit(&mut self) -> WorkflowResult<()> {
let file = self
.file
.take()
.ok_or_else(|| WorkflowError::File("Output already committed".to_string()))?;
file.sync_all()?;
drop(file);
#[cfg(windows)]
{
let _ = std::fs::remove_file(&self.final_path);
}
std::fs::rename(&self.tmp_path, &self.final_path)?;
#[cfg(unix)]
if let Some(dir) = self.final_path.parent()
&& let Ok(dir_handle) = std::fs::File::open(dir)
{
let _ = dir_handle.sync_all();
}
Ok(())
}
}
impl Write for AtomicOutputFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.file.as_ref().expect("not committed").write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.file.as_ref().expect("not committed").flush()
}
}
impl Drop for AtomicOutputFile {
fn drop(&mut self) {
if self.file.is_some() {
self.file = None;
let _ = std::fs::remove_file(&self.tmp_path);
}
}
}
pub fn resolve_output_dir(
output_dir: Option<std::path::PathBuf>,
) -> WorkflowResult<std::path::PathBuf> {
match output_dir {
Some(dir) => {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
None => Ok(std::env::current_dir()?),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::WorkflowError;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_read_n_bytes_from_file_exact() {
let mut temp_file = NamedTempFile::new().unwrap();
let data = b"hello world";
temp_file.write_all(data).unwrap();
let path = temp_file.path();
let result = read_n_bytes_from_file(path, 11).unwrap();
assert_eq!(result.as_slice(), data);
}
#[test]
fn test_read_n_bytes_from_file_more_than_available() {
let mut temp_file = NamedTempFile::new().unwrap();
let data = b"hello";
temp_file.write_all(data).unwrap();
let path = temp_file.path();
let result = read_n_bytes_from_file(path, 10).unwrap();
assert_eq!(result.as_slice(), data);
}
#[test]
fn test_read_n_bytes_from_file_less_than_requested() {
let mut temp_file = NamedTempFile::new().unwrap();
let data = b"hello world this is a test";
temp_file.write_all(data).unwrap();
let path = temp_file.path();
let result = read_n_bytes_from_file(path, 5).unwrap();
assert_eq!(result.as_slice(), b"hello");
}
#[test]
fn test_read_n_bytes_from_file_zero() {
let mut temp_file = NamedTempFile::new().unwrap();
let data = b"hello";
temp_file.write_all(data).unwrap();
let path = temp_file.path();
let result = read_n_bytes_from_file(path, 0).unwrap();
assert_eq!(result.as_slice(), b"");
}
#[test]
fn test_read_n_bytes_from_file_nonexistent() {
let path = std::path::Path::new("/nonexistent/file");
let result = read_n_bytes_from_file(path, 10);
assert!(result.is_err());
assert!(matches!(result, Err(WorkflowError::Io(_))));
}
#[test]
fn test_sanitize_relative_path() {
use std::path::PathBuf;
assert_eq!(
sanitize_relative_path("a/b/c.txt").unwrap(),
PathBuf::from("a/b/c.txt")
);
assert_eq!(
sanitize_relative_path("plain.txt").unwrap(),
PathBuf::from("plain.txt")
);
assert_eq!(
sanitize_relative_path("/abs/path").unwrap(),
PathBuf::from("abs/path")
);
assert_eq!(
sanitize_relative_path("./a//b/.").unwrap(),
PathBuf::from("a/b")
);
for evil in ["..", "../x", "a/../b", "a/..", "a\\b", "", ".", "//"] {
assert!(sanitize_relative_path(evil).is_err(), "accepted {evil:?}");
}
#[cfg(windows)]
for evil in ["C:evil", "C:/evil", "a/C:evil", "file:stream"] {
assert!(sanitize_relative_path(evil).is_err(), "accepted {evil:?}");
}
}
#[test]
fn test_resolve_output_dir_creates_missing_directory() {
let temp_dir = tempfile::TempDir::new().unwrap();
let nested = temp_dir.path().join("a").join("b");
let resolved = resolve_output_dir(Some(nested.clone())).unwrap();
assert_eq!(resolved, nested);
assert!(nested.is_dir());
}
#[test]
fn test_resolve_output_dir_defaults_to_current_dir() {
let resolved = resolve_output_dir(None).unwrap();
assert_eq!(resolved, std::env::current_dir().unwrap());
}
#[test]
fn test_atomic_output_file_commit() {
let temp_dir = tempfile::TempDir::new().unwrap();
let final_path = temp_dir.path().join("out.txt");
std::fs::write(&final_path, b"").unwrap();
let mut atomic = AtomicOutputFile::start(final_path.clone()).unwrap();
atomic.write_all(b"content").unwrap();
atomic.commit().unwrap();
drop(atomic);
assert_eq!(std::fs::read(&final_path).unwrap(), b"content");
let leftovers = std::fs::read_dir(temp_dir.path()).unwrap().count();
assert_eq!(leftovers, 1);
}
#[test]
fn test_atomic_output_file_drop_without_commit_keeps_placeholder() {
let temp_dir = tempfile::TempDir::new().unwrap();
let final_path = temp_dir.path().join("out.txt");
std::fs::write(&final_path, b"placeholder").unwrap();
{
let mut atomic = AtomicOutputFile::start(final_path.clone()).unwrap();
atomic.write_all(b"partial").unwrap();
}
assert_eq!(std::fs::read(&final_path).unwrap(), b"placeholder");
let leftovers = std::fs::read_dir(temp_dir.path()).unwrap().count();
assert_eq!(leftovers, 1, "temporary file must be cleaned up");
}
}