use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
const MAX_PAYLOAD_FILE_BYTES: u64 = 64 * 1024 * 1024;
const TEXT_EXTENSION: &str = "txt";
const BINARY_EXTENSION: &str = "bin";
const DEFAULT_STEM: &str = "payload";
struct Signature {
offset: usize,
magic: &'static [u8],
second: &'static [u8],
second_offset: usize,
extension: &'static str,
}
const SIGNATURES: &[Signature] = &[
signature(0, &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], "png"),
signature(0, &[0xFF, 0xD8, 0xFF], "jpg"),
signature(0, &[0x47, 0x49, 0x46, 0x38], "gif"),
Signature {
offset: 0,
magic: &[0x52, 0x49, 0x46, 0x46],
second: &[0x57, 0x45, 0x42, 0x50],
second_offset: 8,
extension: "webp",
},
signature(0, &[0x25, 0x50, 0x44, 0x46], "pdf"),
signature(0, &[0x50, 0x4B, 0x03, 0x04], "zip"),
signature(0, &[0x1F, 0x8B], "gz"),
signature(0, &[0x28, 0xB5, 0x2F, 0xFD], "zst"),
signature(0, &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], "7z"),
signature(0, &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], "rar"),
signature(0, &[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], "xz"),
signature(
0,
&[0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66],
"sqlite",
),
signature(0, &[0x4F, 0x67, 0x67, 0x53], "ogg"),
signature(0, &[0x66, 0x4C, 0x61, 0x43], "flac"),
signature(0, &[0x49, 0x44, 0x33], "mp3"),
signature(4, &[0x66, 0x74, 0x79, 0x70], "mp4"),
];
const fn signature(offset: usize, magic: &'static [u8], extension: &'static str) -> Signature {
Signature {
offset,
magic,
second: &[],
second_offset: 0,
extension,
}
}
#[derive(Debug)]
pub enum PayloadWriteError {
AlreadyExists,
Io(std::io::Error),
}
impl std::fmt::Display for PayloadWriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PayloadWriteError::AlreadyExists => write!(f, "the file already exists"),
PayloadWriteError::Io(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for PayloadWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
PayloadWriteError::AlreadyExists => None,
PayloadWriteError::Io(err) => Some(err),
}
}
}
pub fn open_payload_file(path: &Path) -> Result<File, String> {
let file = path.display();
let handle = File::open(path).map_err(|err| describe_open_failure(path, &err))?;
let metadata = handle
.metadata()
.map_err(|err| format!("Error: {file} cannot be read: {err}"))?;
if metadata.is_dir() {
return Err(format!(
"Error: {file} is a directory.\n \
Name the file to hide, not the folder it is in."
));
}
if metadata.len() == 0 {
return Err(format!("Error: {file} is empty; there is nothing to hide."));
}
if metadata.len() > MAX_PAYLOAD_FILE_BYTES {
return Err(format!(
"Error: {file} is {} bytes, and the limit is {} bytes ({} MiB).\n \
This is a ceiling on what is read into memory, not the capacity of \
a container:\n \
the payload is compressed before it is measured, so a much smaller \
file may still\n \
be refused for not fitting. Run stenoxide scan to see what a \
container admits.",
metadata.len(),
MAX_PAYLOAD_FILE_BYTES,
MAX_PAYLOAD_FILE_BYTES / (1024 * 1024)
));
}
Ok(handle)
}
fn describe_open_failure(path: &Path, err: &std::io::Error) -> String {
let file = path.display();
if path.is_dir() {
return format!(
"Error: {file} is a directory.\n \
Name the file to hide, not the folder it is in."
);
}
match err.kind() {
std::io::ErrorKind::NotFound => format!("Error: {file} does not exist."),
std::io::ErrorKind::PermissionDenied => {
format!("Error: {file} cannot be read: permission denied.")
}
_ => format!("Error: {file} cannot be read: {err}"),
}
}
pub fn read_payload_file(mut handle: File, path: &Path) -> Result<Zeroizing<Vec<u8>>, String> {
let mut payload = Zeroizing::new(Vec::new());
handle
.read_to_end(&mut payload)
.map_err(|err| format!("Error: {} could not be read: {err}", path.display()))?;
Ok(payload)
}
pub fn resolve_output_path(requested: &Path, payload: &[u8]) -> PathBuf {
if requested.is_dir() {
return requested.join(format!("{DEFAULT_STEM}.{}", detect_extension(payload)));
}
if requested.extension().is_some() {
return requested.to_path_buf();
}
let mut named = requested.as_os_str().to_os_string();
named.push(".");
named.push(detect_extension(payload));
PathBuf::from(named)
}
pub fn detect_extension(payload: &[u8]) -> &'static str {
for signature in SIGNATURES {
if matches(payload, signature) {
return signature.extension;
}
}
if std::str::from_utf8(payload).is_ok() {
TEXT_EXTENSION
} else {
BINARY_EXTENSION
}
}
fn matches(payload: &[u8], signature: &Signature) -> bool {
run_matches(payload, signature.offset, signature.magic)
&& run_matches(payload, signature.second_offset, signature.second)
}
fn run_matches(payload: &[u8], offset: usize, magic: &[u8]) -> bool {
let Some(end) = offset.checked_add(magic.len()) else {
return false;
};
match payload.get(offset..end) {
Some(window) => window == magic,
None => false,
}
}
pub fn write_payload_file(
path: &Path,
payload: &[u8],
force: bool,
) -> Result<(), PayloadWriteError> {
let mut options = OpenOptions::new();
options.write(true);
if force {
options.create(true).truncate(true);
} else {
options.create_new(true);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(path).map_err(|err| {
if err.kind() == std::io::ErrorKind::AlreadyExists {
PayloadWriteError::AlreadyExists
} else {
PayloadWriteError::Io(err)
}
})?;
if let Err(err) = file.write_all(payload).and_then(|()| file.sync_all()) {
drop(file);
let _ = std::fs::remove_file(path);
return Err(PayloadWriteError::Io(err));
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
use tempfile::TempDir;
fn read_from_path(path: &Path) -> Result<Zeroizing<Vec<u8>>, String> {
let handle = open_payload_file(path)?;
read_payload_file(handle, path)
}
fn padded(prefix: &[u8], length: usize) -> Vec<u8> {
let mut payload = prefix.to_vec();
payload.resize(length.max(prefix.len()), 0x00);
payload
}
#[test]
fn every_signature_in_the_table_is_recognised() {
let cases: &[(&[u8], &str)] = &[
(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], "png"),
(&[0xFF, 0xD8, 0xFF, 0xE0], "jpg"),
(b"GIF89a", "gif"),
(b"RIFF\x24\x00\x00\x00WEBPVP8 ", "webp"),
(b"%PDF-1.7", "pdf"),
(&[0x50, 0x4B, 0x03, 0x04, 0x14, 0x00], "zip"),
(&[0x1F, 0x8B, 0x08, 0x00], "gz"),
(&[0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00], "zst"),
(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00], "7z"),
(b"Rar!\x1A\x07\x00", "rar"),
(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00], "xz"),
(b"SQLite format 3\x00", "sqlite"),
(b"OggS\x00\x02", "ogg"),
(b"fLaC\x00\x00", "flac"),
(b"ID3\x03\x00", "mp3"),
(b"\x00\x00\x00\x20ftypisom", "mp4"),
];
for (sample, expected) in cases {
let payload = padded(sample, 64);
assert_eq!(
detect_extension(&payload),
*expected,
"sample {sample:02X?} should be {expected}"
);
}
}
#[test]
fn text_payloads_are_named_as_text() {
assert_eq!(detect_extension(b"Meet me at six.\n"), "txt");
assert_eq!(
detect_extension("una nota con acentos: ñ, á, ü".as_bytes()),
"txt"
);
}
#[test]
fn unrecognised_binary_payloads_are_named_as_binary() {
assert_eq!(detect_extension(&[0x80, 0x91, 0xA2, 0xB3]), "bin");
assert_eq!(detect_extension(&[0xC3, 0x28, 0x00, 0xFF]), "bin");
}
#[test]
fn very_short_payloads_do_not_read_out_of_bounds() {
assert_eq!(detect_extension(&[]), "txt");
assert_eq!(detect_extension(&[0x89]), "bin");
assert_eq!(detect_extension(&[0x89, 0x50]), "bin");
assert_eq!(detect_extension(&[0x89, 0x50, 0x4E]), "bin");
assert_eq!(detect_extension(b"a"), "txt");
assert_eq!(detect_extension(b"ab"), "txt");
}
#[test]
fn a_truncated_signature_does_not_match() {
assert_eq!(detect_extension(&[0x89, 0x50, 0x4E, 0x47]), "bin");
assert_eq!(
detect_extension(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A]),
"bin"
);
assert_eq!(
detect_extension(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
"png"
);
}
#[test]
fn a_riff_that_is_not_a_webp_is_not_a_webp() {
assert_ne!(detect_extension(b"RIFF\x24\x00\x00\x00WAVEfmt "), "webp");
}
#[test]
fn a_directory_receives_a_named_file_inside_it() {
let directory = TempDir::new().expect("temporary directory");
let resolved = resolve_output_path(directory.path(), b"%PDF-1.7");
assert_eq!(resolved, directory.path().join("payload.pdf"));
let resolved = resolve_output_path(directory.path(), b"plain text");
assert_eq!(resolved, directory.path().join("payload.txt"));
}
#[test]
fn a_path_without_an_extension_gets_the_detected_one() {
let directory = TempDir::new().expect("temporary directory");
let requested = directory.path().join("recovered");
assert_eq!(
resolve_output_path(&requested, b"%PDF-1.7"),
directory.path().join("recovered.pdf")
);
assert_eq!(
resolve_output_path(&requested, &[0x80, 0x81]),
directory.path().join("recovered.bin")
);
}
#[test]
fn a_path_with_an_extension_is_left_alone() {
let directory = TempDir::new().expect("temporary directory");
let requested = directory.path().join("recovered.zip");
assert_eq!(resolve_output_path(&requested, b"%PDF-1.7"), requested);
let requested = directory.path().join("notes.txt");
assert_eq!(
resolve_output_path(
&requested,
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
),
requested
);
}
#[test]
fn an_existing_file_is_not_overwritten() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("recovered.txt");
std::fs::write(&path, b"the file that was already there").expect("fixture write");
let outcome = write_payload_file(&path, b"the new payload", false);
assert!(
matches!(outcome, Err(PayloadWriteError::AlreadyExists)),
"got: {outcome:?}"
);
let kept = std::fs::read(&path).expect("the file must still be readable");
assert_eq!(kept, b"the file that was already there");
}
#[test]
fn force_overwrites_an_existing_file() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("recovered.txt");
std::fs::write(&path, b"a much longer previous content").expect("fixture write");
write_payload_file(&path, b"short", true).expect("force must overwrite");
let written = std::fs::read(&path).expect("the file must be readable");
assert_eq!(written, b"short", "the previous content must be truncated");
}
#[test]
fn a_new_file_holds_exactly_the_payload() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("recovered.bin");
let payload = [0x00, 0xFF, 0x80, 0x0A, 0x0D, 0x1A];
write_payload_file(&path, &payload, false).expect("a new file must be writable");
let written = std::fs::read(&path).expect("the file must be readable");
assert_eq!(written, payload);
}
#[cfg(unix)]
#[test]
fn the_written_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("recovered.txt");
write_payload_file(&path, b"plaintext on a disk", false).expect("write");
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "got mode {:o}", mode & 0o777);
}
#[test]
fn an_empty_payload_file_is_refused() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("empty.txt");
std::fs::write(&path, b"").expect("fixture write");
let message = read_from_path(&path).expect_err("an empty file must be refused");
assert!(message.contains("empty"), "got: {message}");
}
#[test]
fn a_directory_is_refused_as_a_payload() {
let directory = TempDir::new().expect("temporary directory");
let message = read_from_path(directory.path()).expect_err("a directory must be refused");
assert!(message.contains("directory"), "got: {message}");
}
#[test]
fn a_missing_payload_file_is_named() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("nowhere.txt");
let message = read_from_path(&path).expect_err("a missing file must be refused");
assert!(message.contains("nowhere.txt"), "got: {message}");
assert!(message.contains("does not exist"), "got: {message}");
}
#[test]
fn a_file_over_the_ceiling_is_refused_before_it_is_read() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("huge.bin");
let oversized = MAX_PAYLOAD_FILE_BYTES + 1;
let file = File::create(&path).expect("fixture create");
file.set_len(oversized).expect("fixture size");
drop(file);
let started = std::time::Instant::now();
let message = read_from_path(&path).expect_err("an oversized file must be refused");
let elapsed = started.elapsed();
assert!(
message.contains(&oversized.to_string()),
"the message must state the real size, got: {message}"
);
assert!(
message.contains(&MAX_PAYLOAD_FILE_BYTES.to_string()),
"the message must state the limit, got: {message}"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"the refusal took {elapsed:?}, which means it read the file"
);
}
#[test]
fn a_file_at_the_ceiling_is_accepted() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("borderline.bin");
let file = File::create(&path).expect("fixture create");
file.set_len(MAX_PAYLOAD_FILE_BYTES).expect("fixture size");
drop(file);
assert!(
open_payload_file(&path).is_ok(),
"the ceiling itself must be admissible"
);
}
#[test]
fn write_failures_explain_themselves() {
let existing = PayloadWriteError::AlreadyExists;
assert!(existing.to_string().contains("already exists"));
assert!(std::error::Error::source(&existing).is_none());
let refused = PayloadWriteError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"the directory is read-only",
));
assert!(refused.to_string().contains("read-only"));
assert!(std::error::Error::source(&refused).is_some());
}
#[test]
fn a_payload_file_is_read_whole() {
let directory = TempDir::new().expect("temporary directory");
let path = directory.path().join("secret.bin");
let contents: Vec<u8> = (0..=255u8).cycle().take(4096).collect();
std::fs::write(&path, &contents).expect("fixture write");
let payload = read_from_path(&path).expect("a readable file must be read");
assert_eq!(payload.as_slice(), contents.as_slice());
}
}