// SPDX-License-Identifier: MIT-0 use std::{ fs::{self, File, OpenOptions}, io::{self, BufRead, BufReader, Seek, SeekFrom, Write}, path::{Path, PathBuf}, }; use crate::policy; /// Default plaintext chunk size: 1 MiB. /// /// Stored in the header per file, so callers may override via CLI without /// breaking older files (the decryptor reads the size from the header). pub const DEFAULT_CHUNK_SIZE: u32 = policy::DEFAULT_CHUNK_SIZE; /// Opened input. /// /// `length` is `Some(n)` only when the source is a regular file (we stat the /// open FD to avoid TOCTOU). For stdin, FIFOs, sockets, char devices, etc. /// it is `None` — those paths cannot commit a length in the header. pub(crate) struct Input { pub reader: Box, pub length: Option, } pub(crate) fn open_input(input_file: Option<&Path>) -> io::Result { match input_file { Some(f) => { let file = File::open(f)?; // Stat the open FD (not the path) so we can't be raced between // stat and open. let length = file .metadata() .ok() .filter(|m| m.is_file()) .map(|m| m.len()); Ok(Input { reader: Box::new(BufReader::new(file)), length, }) } None => Ok(Input { // `Stdin` is `Send` (unlike `StdinLock`), so wrap it in a // `BufReader` and box for cross-thread use in the parallel pipeline. reader: Box::new(BufReader::new(io::stdin())), length: None, }), } } #[derive(Clone, Debug, Default)] pub struct OutputOptions { pub force: bool, pub temp_dir: Option, pub buffer_verify_stdout: bool, } pub(crate) struct SecureTempFile { path: PathBuf, file: Option, remove_on_drop: bool, } impl SecureTempFile { fn create(dir: &Path, prefix: &str) -> io::Result { fs::create_dir_all(dir)?; for _ in 0..128 { let mut rand = [0u8; 16]; getrandom::fill(&mut rand).map_err(io::Error::other)?; let name = format!("{prefix}.{}.tmp", hex(&rand)); let path = dir.join(name); let mut opts = OpenOptions::new(); opts.read(true).write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } match opts.open(&path) { Ok(file) => { return Ok(Self { path, file: Some(file), remove_on_drop: true, }); } Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), } } Err(io::Error::new( io::ErrorKind::AlreadyExists, "could not create a unique temporary file after 128 attempts", )) } fn file_mut(&mut self) -> &mut File { self.file .as_mut() .expect("temporary file handle taken before commit") } fn sync_file(&mut self) -> io::Result<()> { let file = self.file_mut(); file.flush()?; file.sync_all() } fn persist(mut self, final_path: &Path) -> io::Result<()> { self.sync_file()?; self.file.take(); #[cfg(windows)] if final_path.exists() { fs::remove_file(final_path)?; } fs::rename(&self.path, final_path)?; self.remove_on_drop = false; best_effort_fsync_parent(final_path); Ok(()) } fn copy_to_stdout(mut self) -> io::Result<()> { self.sync_file()?; let mut file = self .file .take() .expect("temporary file handle taken before stdout commit"); file.seek(SeekFrom::Start(0))?; let mut stdout = io::stdout(); io::copy(&mut file, &mut stdout)?; stdout.flush()?; Ok(()) } } impl Drop for SecureTempFile { fn drop(&mut self) { self.file.take(); if self.remove_on_drop { let _ = fs::remove_file(&self.path); } } } fn hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { out.push(HEX[(b >> 4) as usize] as char); out.push(HEX[(b & 0x0f) as usize] as char); } out } fn best_effort_fsync_parent(path: &Path) { let Some(parent) = path.parent() else { return; }; if let Ok(dir) = File::open(parent) { let _ = dir.sync_all(); } } fn temp_dir_for_target(final_path: &Path, explicit: Option<&Path>) -> PathBuf { if let Some(dir) = explicit { return dir.to_path_buf(); } final_path .parent() .filter(|p| !p.as_os_str().is_empty()) .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")) } fn temp_dir_for_stdout(explicit: Option<&Path>) -> PathBuf { explicit .map(Path::to_path_buf) .unwrap_or_else(std::env::temp_dir) } fn file_name_prefix(path: &Path) -> String { path.file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .unwrap_or("fcry") .to_owned() } fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result { let Some(input) = input else { return Ok(false); }; match same_file::is_same_file(input, output) { Ok(same) => Ok(same), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), Err(e) => Err(e), } } /// Output sink that supports atomic file replacement. /// /// For file outputs: bytes are written to a private, randomly named temp file. /// On `commit()`, the temp file is fsynced and renamed into place. If dropped /// without commit (panic, error, process exit), the temp file is deleted so a /// partial/garbage file does not replace any existing target. /// /// For stdout: behaves as a passthrough; `commit()` is a no-op. pub(crate) enum OutSink { Stdout(io::Stdout), BufferVerify { temp: SecureTempFile, }, File { final_path: PathBuf, temp: SecureTempFile, }, } impl OutSink { pub(crate) fn open_with_options( output_file: Option<&Path>, input_file: Option<&Path>, options: &OutputOptions, ) -> io::Result { match output_file { None if options.buffer_verify_stdout => { let dir = temp_dir_for_stdout(options.temp_dir.as_deref()); Ok(Self::BufferVerify { temp: SecureTempFile::create(&dir, "fcry-buffer")?, }) } None => Ok(Self::Stdout(io::stdout())), Some(f) => { let final_path = f.to_path_buf(); if final_path.exists() && !options.force && !output_aliases_input(&final_path, input_file)? { return Err(io::Error::new( io::ErrorKind::AlreadyExists, format!( "output file {} already exists (use --force to replace it)", final_path.display() ), )); } let dir = temp_dir_for_target(&final_path, options.temp_dir.as_deref()); let prefix = file_name_prefix(&final_path); let temp = SecureTempFile::create(&dir, &prefix)?; Ok(Self::File { final_path, temp }) } } } pub(crate) fn commit(mut self) -> io::Result<()> { match &mut self { Self::Stdout(s) => s.flush()?, Self::BufferVerify { .. } => {} Self::File { .. } => {} } match self { Self::Stdout(_) => {} Self::BufferVerify { temp } => temp.copy_to_stdout()?, Self::File { final_path, temp } => temp.persist(&final_path)?, } Ok(()) } } impl Write for OutSink { fn write(&mut self, buf: &[u8]) -> io::Result { match self { Self::Stdout(s) => s.write(buf), Self::BufferVerify { temp } => temp.file_mut().write(buf), Self::File { temp, .. } => temp.file_mut().write(buf), } } fn flush(&mut self) -> io::Result<()> { match self { Self::Stdout(s) => s.flush(), Self::BufferVerify { temp } => temp.file_mut().flush(), Self::File { temp, .. } => temp.file_mut().flush(), } } }