2f16e735c3
The crypto engine was only reachable through the fcry binary; embedding
it in another Rust project meant shelling out to the CLI. Restructure
the crate so the binary sits on top of a proper library API.
- Add src/lib.rs exposing encrypt/decrypt/decrypt_range/derive_key, the
header and policy types, and the secret-handling primitives.
- Replace the positional-argument wrapper ladder
(encrypt_with_output_options, decrypt_with_argon_cap, ...) with
options structs: EncryptOptions, DecryptOptions, DecryptRangeOptions
and HeaderReadOptions. OutSinkOptions becomes the public
OutputOptions and no longer carries the input path; the input is now
an explicit parameter to OutSink::open_with_options so the
same-file-aliasing guard's inputs are visible at each call site.
- File parameters take Option<PathBuf>/&Path instead of AsRef<str>, so
non-UTF-8 paths work.
- FcryError implements Display and std::error::Error so it composes
with anyhow/thiserror-style error handling in downstream crates.
- Move read_key_file and normalize_passphrase from main.rs into
secrets.rs so library users get the same strict 32-byte key-file
parsing and NFC passphrase normalization. The world-readable
key-file warning stays in the CLI wrapper (read_key_file_cli).
- Drop now-unneeded #[allow(dead_code)] markers; ReadInfoChunk::Normal
loses its unused byte-count payload.
- Add rustfmt.toml (StdExternalCrate grouping, crate-granularity
imports) and reformat imports accordingly.
- Add tests/library_api.rs covering a file round-trip and a range
decrypt through the public API with a raw key.
User-visible change: CLI behavior is unchanged except error output,
which is now human-readable Display text ("Error: wrong key or
passphrase") instead of the Rust Debug representation.
Test plan: cargo clippy (default, --tests, --benches) is clean;
cargo +nightly fmt produces no diff; cargo test passes 43 tests
including the new library_api integration tests.
288 lines
8.6 KiB
Rust
288 lines
8.6 KiB
Rust
// 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<dyn BufRead + Send>,
|
|
pub length: Option<u64>,
|
|
}
|
|
|
|
pub(crate) fn open_input(input_file: Option<&Path>) -> io::Result<Input> {
|
|
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<PathBuf>,
|
|
pub buffer_verify_stdout: bool,
|
|
}
|
|
|
|
pub(crate) struct SecureTempFile {
|
|
path: PathBuf,
|
|
file: Option<File>,
|
|
remove_on_drop: bool,
|
|
}
|
|
|
|
impl SecureTempFile {
|
|
fn create(dir: &Path, prefix: &str) -> io::Result<Self> {
|
|
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<bool> {
|
|
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<Self> {
|
|
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<usize> {
|
|
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(),
|
|
}
|
|
}
|
|
}
|