use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::io::Read;
use std::path::Path;
use crate::CryptoError;
use crate::error::{sanitize_for_display, sanitize_path_for_display};
pub const INCOMPLETE_SUFFIX: &str = ".incomplete";
pub(crate) const OUTPUT_LABEL: &str = "Output";
pub(crate) const KEY_FILE_LABEL: &str = "Key file";
pub(crate) fn input_name_not_utf8_error(path: &Path) -> CryptoError {
CryptoError::InvalidInput(format!(
"Input name is not valid UTF-8: {}",
sanitize_path_for_display(path)
))
}
pub(crate) fn unsupported_file_type_error(path: &Path) -> CryptoError {
CryptoError::InvalidInput(format!(
"Unsupported file type: {}",
sanitize_path_for_display(path)
))
}
pub(crate) fn open_input_file(path: &Path) -> Result<File, CryptoError> {
let mut options = File::options();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(path).map_err(map_user_path_io_error)?;
let file_type = file.metadata().map_err(CryptoError::Io)?.file_type();
if !file_type.is_file() && !file_type.is_dir() {
return Err(unsupported_file_type_error(path));
}
Ok(file)
}
pub(crate) fn read_file_capped(
path: &Path,
cap: usize,
over_cap_error: impl FnOnce() -> CryptoError,
) -> Result<Vec<u8>, CryptoError> {
let mut file = open_input_file(path)?;
let mut buf = Vec::with_capacity(cap.saturating_add(1).min(64 * 1024));
let read = file
.by_ref()
.take((cap as u64).saturating_add(1))
.read_to_end(&mut buf)
.map_err(CryptoError::Io)?;
if read > cap {
return Err(over_cap_error());
}
Ok(buf)
}
pub(crate) fn file_stem(filename: &Path) -> Result<&OsStr, CryptoError> {
filename
.file_stem()
.ok_or_else(|| CryptoError::InvalidInput("Cannot get file stem".to_string()))
}
pub(crate) fn encryption_base_name(path: impl AsRef<Path>) -> Result<String, CryptoError> {
let path = path.as_ref();
let is_real_dir = match std::fs::symlink_metadata(path) {
Ok(m) => m.file_type().is_dir(),
Err(e) if e.kind() == io::ErrorKind::NotFound => false,
Err(e) => return Err(CryptoError::Io(e)),
};
let name = if is_real_dir {
path.file_name()
.ok_or_else(|| CryptoError::InvalidInput("Cannot get directory name".to_string()))?
} else {
file_stem(path)?
};
name.to_str()
.map(str::to_owned)
.ok_or_else(|| input_name_not_utf8_error(path))
}
pub(crate) fn path_occupied(path: &Path) -> Result<bool, CryptoError> {
match std::fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(CryptoError::Io(e)),
}
}
pub(crate) fn already_exists_error(label: &str, path: &Path) -> CryptoError {
let full = path.display().to_string();
let rendered = match path.file_name().map(|n| n.to_string_lossy()) {
Some(name) if name.len() < full.len() && full.ends_with(name.as_ref()) => {
format!(
"{}{}",
&full[..full.len() - name.len()],
sanitize_for_display(&name)
)
}
_ => sanitize_for_display(&full),
};
CryptoError::InvalidInput(format!("{label} already exists: {rendered}"))
}
pub(crate) fn reject_occupied(path: &Path, label: &str) -> Result<(), CryptoError> {
if path_occupied(path)? {
return Err(already_exists_error(label, path));
}
Ok(())
}
pub(crate) fn parent_or_cwd(path: &Path) -> &Path {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
pub(crate) fn map_user_path_io_error(e: io::Error) -> CryptoError {
if e.kind() == io::ErrorKind::NotFound {
CryptoError::InputPath
} else {
CryptoError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encryption_base_name_file() {
let stem = encryption_base_name("path/to/file.txt").unwrap();
assert_eq!(stem, "file");
}
#[test]
fn test_encryption_base_name_no_extension() {
let stem = encryption_base_name("path/to/file").unwrap();
assert_eq!(stem, "file");
}
#[test]
fn test_encryption_base_name_dotted_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let dotted_dir = tmp.path().join("photos.v1");
std::fs::create_dir(&dotted_dir).unwrap();
let name = encryption_base_name(&dotted_dir).unwrap();
assert_eq!(name, "photos.v1");
}
#[cfg(unix)]
#[test]
fn encryption_base_name_rejects_non_utf8_name() {
use std::os::unix::ffi::OsStrExt;
let name = OsStr::from_bytes(b"bad\xFFname.txt");
match encryption_base_name(Path::new(name)) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(
msg.starts_with("Input name is not valid UTF-8: "),
"got: {msg}"
);
}
other => panic!("expected InvalidInput for non-UTF-8 name, got {other:?}"),
}
}
#[test]
fn already_exists_error_escapes_bare_hostile_name() {
match already_exists_error("Output", Path::new("evil\u{1b}.fcr")) {
CryptoError::InvalidInput(msg) => {
assert_eq!(msg, "Output already exists: evil\\u{1b}.fcr");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn already_exists_error_keeps_multibyte_dir_raw_escapes_name() {
match already_exists_error("Key file", Path::new("Données/evil\u{202e}.key")) {
CryptoError::InvalidInput(msg) => {
assert_eq!(msg, "Key file already exists: Données/evil\\u{202e}.key");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn reject_occupied_escapes_hostile_file_name() {
let tmp = tempfile::TempDir::new().unwrap();
let hostile = tmp.path().join("evil\u{1b}]0;pwned\u{7}.txt");
std::fs::write(&hostile, b"x").unwrap();
match reject_occupied(&hostile, "Output") {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.starts_with("Output already exists: "), "got: {msg}");
assert!(
msg.contains("evil\\u{1b}]0;pwned\\u{7}.txt"),
"escaped file name must appear in full: {msg}"
);
assert!(
!msg.chars().any(char::is_control),
"raw control character leaked: {msg:?}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn parent_or_cwd_returns_parent_when_present() {
assert_eq!(parent_or_cwd(Path::new("dir/file.txt")), Path::new("dir"));
assert_eq!(parent_or_cwd(Path::new("/abs/file.txt")), Path::new("/abs"));
}
#[test]
fn parent_or_cwd_falls_back_to_cwd() {
assert_eq!(parent_or_cwd(Path::new("file.txt")), Path::new("."));
assert_eq!(parent_or_cwd(Path::new("")), Path::new("."));
}
#[cfg(unix)]
#[test]
fn parent_or_cwd_root_path_falls_back_to_cwd() {
assert_eq!(parent_or_cwd(Path::new("/")), Path::new("."));
}
#[cfg(unix)]
mod special_file_inputs {
use super::*;
fn make_fifo(path: &Path) {
let status = std::process::Command::new("mkfifo")
.arg(path)
.status()
.expect("spawn mkfifo");
assert!(status.success(), "mkfifo failed for {}", path.display());
}
#[test]
fn open_input_file_rejects_fifo_without_blocking() {
let tmp = tempfile::TempDir::new().unwrap();
let fifo = tmp.path().join("pipe.fcr");
make_fifo(&fifo);
match open_input_file(&fifo) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.contains("Unsupported file type"), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn open_input_file_escapes_hostile_fifo_name() {
let tmp = tempfile::TempDir::new().unwrap();
let fifo = tmp.path().join("pipe\u{1b}[2K.fcr");
make_fifo(&fifo);
match open_input_file(&fifo) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.starts_with("Unsupported file type: "), "got: {msg}");
assert!(
!msg.chars().any(char::is_control),
"raw control character leaked: {msg:?}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn read_file_capped_rejects_fifo() {
let tmp = tempfile::TempDir::new().unwrap();
let fifo = tmp.path().join("private.key");
make_fifo(&fifo);
match read_file_capped(&fifo, 1024, || {
CryptoError::InvalidInput("over cap".to_string())
}) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.contains("Unsupported file type"), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn open_input_file_accepts_regular_file() {
let tmp = tempfile::TempDir::new().unwrap();
let file = tmp.path().join("regular");
std::fs::write(&file, b"bytes").unwrap();
open_input_file(&file).expect("regular file must open");
}
}
#[test]
fn open_input_file_maps_missing_path_to_input_path() {
let tmp = tempfile::TempDir::new().unwrap();
let missing = tmp.path().join("absent.fcr");
match open_input_file(&missing) {
Err(CryptoError::InputPath) => {}
other => panic!("expected InputPath, got {other:?}"),
}
}
#[test]
fn path_occupied_returns_false_for_missing_path() {
let tmp = tempfile::TempDir::new().unwrap();
let absent = tmp.path().join("does-not-exist");
assert!(!path_occupied(&absent).unwrap());
}
#[test]
fn path_occupied_returns_true_for_real_file() {
let tmp = tempfile::TempDir::new().unwrap();
let file = tmp.path().join("real");
std::fs::write(&file, b"x").unwrap();
assert!(path_occupied(&file).unwrap());
}
#[cfg(unix)]
#[test]
fn path_occupied_returns_true_for_dangling_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let link = tmp.path().join("dangling");
symlink(tmp.path().join("absent-target"), &link).unwrap();
assert!(!link.exists(), "sanity: target really is missing");
assert!(path_occupied(&link).unwrap());
}
}