#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
#![deny(missing_docs)]
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::aot::{generate, Shell};
use zeroize::Zeroizing;
use stenoxide_core::cost::hill::HillCostProvider;
use stenoxide_core::cost::CostProvider;
use stenoxide_core::generate::{
generate_container, ContainerDimensions, GenerateError, GenerateReport, DEFAULT_CONTAINER_SIDE,
MIN_CONTAINER_SIDE,
};
use stenoxide_core::image_io::buffer::ImageBuffer;
use stenoxide_core::image_io::phash::{compute_stable_phash, PHashError};
use stenoxide_core::image_io::validate::{load_and_validate, ValidationError};
use stenoxide_core::pipeline::{EmbedPipeline, EmbedReport, PipelineError};
use stenoxide_core::stego::sizer::{compute_capacity, EmbeddingMode, SizerError};
mod payload;
mod progress;
mod scan;
const EXTRACTION_FAILED: &str = "Could not extract the payload.";
const PASSWORD_PROMPT: &str = "Password: ";
const END_OF_MESSAGE: &str = ".";
const GENERATE_LONG_ABOUT: &str = "\
Build a container around a message instead of hiding it inside an existing image.
For when there is no usable photograph — a camera that only writes JPEG, no way
to move pictures across from a phone. The container is drawn sample by sample,
each one conditioned on the ciphertext bit it carries, so a container holding a
message and one holding nothing are draws from the same distribution and no
detector can separate them. It carries about 1.4 MB, against the 8 KB an image
of the same size admits by embedding.
The default container is 2000x2000, the smallest and least conspicuous the mode
draws. A payload that does not fit needs a larger one: raise --width and
--height together (each at least 2000). Capacity grows with the pixel count, so
the error printed when a payload overflows names a size that would hold it.
It does not hide that the container was generated. It looks like a synthetic
texture, and a folder full of them is itself the thing worth explaining. Prefer
a photograph of your own that has never been published, whenever you have one.";
const ANOTHER_CONTAINER_HINT: &str = "\n \
stenoxide scan <folder> reports which of your other photos can be used.";
const TYPING_GUIDANCE: &str = "\
Message to hide. It may span as many lines as you need.
Finish with a line containing a single dot: .
A line you have sent cannot be edited; to revise one first, put the message in
a file and pass it with -p.
";
const CLI_LONG_ABOUT: &str = concat!(
env!("CARGO_PKG_DESCRIPTION"),
"\n\n",
"Find a photograph that can hold a message, hide one in it, read it back:\n\n",
" stenoxide scan ./photos\n",
" stenoxide embed --input photo.png --output stego.png\n",
" stenoxide extract --input stego.png\n\n",
"When none of the photographs can be used, stenoxide generate builds a\n",
"container around the message instead."
);
#[derive(Parser)]
#[command(name = "stenoxide", version, about, long_about = CLI_LONG_ABOUT)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Scan(ScanArgs),
Embed {
#[arg(long, short = 'i', value_name = "PATH")]
input: PathBuf,
#[arg(long, short = 'o', value_name = "PATH")]
output: PathBuf,
#[arg(long, short = 'p', value_name = "PATH")]
payload: Option<PathBuf>,
#[arg(long, short = 'f')]
force: bool,
},
#[command(long_about = GENERATE_LONG_ABOUT)]
Generate {
#[arg(long, short = 'o', value_name = "PATH")]
output: PathBuf,
#[arg(long, short = 'p', value_name = "PATH")]
input: Option<PathBuf>,
#[arg(long, value_name = "PIXELS", default_value_t = DEFAULT_CONTAINER_SIDE,
value_parser = clap::value_parser!(u32).range(i64::from(MIN_CONTAINER_SIDE)..))]
width: u32,
#[arg(long, value_name = "PIXELS", default_value_t = DEFAULT_CONTAINER_SIDE,
value_parser = clap::value_parser!(u32).range(i64::from(MIN_CONTAINER_SIDE)..))]
height: u32,
#[arg(long, short = 'f')]
force: bool,
},
Extract {
#[arg(long, short = 'i', value_name = "PATH")]
input: PathBuf,
#[arg(long, short = 'o', value_name = "PATH")]
payload_out: Option<PathBuf>,
#[arg(long, short = 'f', requires = "payload_out")]
force: bool,
},
Completions {
#[arg(value_name = "SHELL", value_enum)]
shell: Shell,
},
Man,
}
#[derive(Args)]
struct ScanArgs {
#[arg(value_name = "PATH", default_value = ".")]
path: String,
#[arg(long, short = 'a')]
all: bool,
#[arg(long, short = 'r')]
recursive: bool,
#[arg(long)]
json: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let outcome = match &cli.command {
Command::Scan(args) => scan::run(args),
Command::Embed {
input,
output,
payload,
force,
} => run_embed(input, output, payload.as_deref(), *force),
Command::Generate {
output,
input,
width,
height,
force,
} => run_generate(output, input.as_deref(), *width, *height, *force),
Command::Extract {
input,
payload_out,
force,
} => run_extract(input, payload_out.as_deref(), *force),
Command::Completions { shell } => run_completions(*shell),
Command::Man => run_man(),
};
match outcome {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("{message}");
ExitCode::FAILURE
}
}
}
fn read_password() -> Result<Zeroizing<Vec<u8>>, String> {
rpassword::prompt_password(PASSWORD_PROMPT)
.map(|password| Zeroizing::new(password.into_bytes()))
.map_err(|err| format!("Error: could not read the password: {err}"))
}
fn read_plaintext() -> Result<Zeroizing<Vec<u8>>, String> {
let stdin = io::stdin();
if stdin.is_terminal() {
eprint!("{TYPING_GUIDANCE}");
let message = collect_typed_lines(&mut stdin.lock())
.map_err(|err| format!("Error: could not read the message you typed: {err}"))?;
eprintln!("Read {} bytes.", message.len());
return Ok(message);
}
let mut plaintext = Zeroizing::new(Vec::new());
stdin
.lock()
.read_to_end(&mut plaintext)
.map_err(|err| format!("Error: could not read the message from standard input: {err}"))?;
Ok(plaintext)
}
fn collect_typed_lines(input: &mut impl BufRead) -> io::Result<Zeroizing<Vec<u8>>> {
let mut message = Zeroizing::new(Vec::new());
let mut line = Zeroizing::new(Vec::new());
loop {
line.clear();
if input.read_until(b'\n', &mut line)? == 0 {
break;
}
if strip_line_ending(&line) == END_OF_MESSAGE.as_bytes() {
break;
}
message.extend_from_slice(&line);
}
let without_trailing_newline = strip_line_ending(&message).len();
message.truncate(without_trailing_newline);
Ok(message)
}
fn strip_line_ending(line: &[u8]) -> &[u8] {
let line = match line.strip_suffix(b"\n") {
Some(rest) => rest,
None => line,
};
match line.strip_suffix(b"\r") {
Some(rest) => rest,
None => line,
}
}
fn load_container(path: &Path) -> Result<ImageBuffer, String> {
load_and_validate(path).map_err(|error| describe_rejection(path, &error))
}
fn load_container_for_embed(path: &Path) -> Result<ImageBuffer, String> {
load_and_validate(path).map_err(|error| describe_embed_rejection(path, &error))
}
fn describe_rejection(path: &Path, error: &ValidationError) -> String {
let file = path.display();
match error {
ValidationError::JpegDetected => format!(
"Error: {file} is a JPEG and cannot be used as a container.\n \
Convert it to PNG first: magick input.jpg output.png\n \
Note that a PNG converted from a JPEG is refused as well; the \
container must never have been JPEG-compressed."
),
ValidationError::WebpDetected => format!(
"Error: {file} is a WebP and cannot be used as a container.\n \
Only PNG containers that have never been through a lossy codec are \
supported."
),
ValidationError::NotPng => format!(
"Error: {file} is not a PNG image.\n \
Containers must be PNG files of at least 2000x2000 pixels."
),
ValidationError::ImageTooSmall { width, height, min } => format!(
"Error: {file} is {width}x{height}, which is too small.\n \
Both sides must be at least {min} pixels."
),
ValidationError::ImageTooLarge {
width,
height,
pixels,
max,
} => format!(
"Error: {file} is {width}x{height}, which is {} megapixels.\n \
Analysing an image that size needs more memory than this limit \
allows,\n \
so it is refused immediately rather than left to exhaust the \
machine.\n \
The maximum is {} megapixels; scale it down or use another photo.",
pixels / (1024 * 1024),
max / (1024 * 1024)
),
ValidationError::UnsupportedColorSpace { .. } => format!(
"Error: the pixel layout of {file} is not supported.\n \
Use an 8-bit or 16-bit RGB, RGBA or grayscale PNG."
),
ValidationError::JpegArtifactsDetected { .. } => format!(
"Error: {file} was JPEG-compressed at some point and re-saved as a \
PNG.\n \
The 8x8 block grid it left behind is exactly what a steganalyst \
looks for.\n \
Use a photo straight from a camera that was never saved as a JPEG."
),
ValidationError::IoError(_) | ValidationError::DecodingError(_) => {
format!("Error: {file}: {error}")
}
}
}
fn describe_embed_rejection(path: &Path, error: &ValidationError) -> String {
let message = describe_rejection(path, error);
match error {
ValidationError::JpegDetected
| ValidationError::WebpDetected
| ValidationError::NotPng
| ValidationError::ImageTooSmall { .. }
| ValidationError::ImageTooLarge { .. }
| ValidationError::UnsupportedColorSpace { .. }
| ValidationError::JpegArtifactsDetected { .. } => {
format!("{message}{ANOTHER_CONTAINER_HINT}")
}
ValidationError::IoError(_) | ValidationError::DecodingError(_) => message,
}
}
fn run_embed(
input: &Path,
output: &Path,
payload: Option<&Path>,
force: bool,
) -> Result<(), String> {
drop(load_container_for_embed(input)?);
refuse_unwritable_output(output, force)?;
let source = match payload {
Some(path) => Some((payload::open_payload_file(path)?, path)),
None => None,
};
let password = read_password()?;
let plaintext = match source {
Some((handle, path)) => payload::read_payload_file(handle, path)?,
None => read_plaintext()?,
};
if plaintext.is_empty() {
return Err("Error: the message is empty; nothing to hide.".to_string());
}
let activity = progress::Activity::start();
let outcome = EmbedPipeline::default_secure().embed(input, plaintext, password, output);
activity.finish();
let report = outcome.map_err(|err| describe_embed_failure(&err))?;
print_report(&report, output);
Ok(())
}
fn describe_embed_failure(error: &PipelineError) -> String {
match error {
PipelineError::Sizer(SizerError::PayloadTooLarge {
payload,
available,
deficit,
}) => format!(
"Error: the payload does not fit in this container.\n \
Compressed and encrypted it is {payload} bytes; the container \
admits {available}.\n \
It is {deficit} bytes over.\n \
That first figure is the payload after compression, not the size \
of the file:\n \
text shrinks a great deal, so a much larger file may still fit and \
a smaller one may not.\n \
Use a container of higher resolution; stenoxide scan reports what \
each one can carry."
),
PipelineError::Cost(_)
| PipelineError::PHash(PHashError::InsufficientStability { .. }) => {
format!("Error: {error}.{ANOTHER_CONTAINER_HINT}")
}
other => format!("Error: {other}"),
}
}
fn run_generate(
output: &Path,
input: Option<&Path>,
width: u32,
height: u32,
force: bool,
) -> Result<(), String> {
let dimensions =
ContainerDimensions::new(width, height).map_err(|err| describe_generate_failure(&err))?;
refuse_unwritable_output(output, force)?;
let source = match input {
Some(path) => Some((payload::open_payload_file(path)?, path)),
None => None,
};
let password = read_password()?;
let plaintext = match source {
Some((handle, path)) => payload::read_payload_file(handle, path)?,
None => read_plaintext()?,
};
if plaintext.is_empty() {
return Err("Error: the message is empty; nothing to hide.".to_string());
}
let activity = progress::Activity::start();
let outcome = generate_container(plaintext, password, dimensions, output);
activity.finish();
let report = outcome.map_err(|err| describe_generate_failure(&err))?;
print_generate_report(&report, output);
Ok(())
}
fn describe_generate_failure(error: &GenerateError) -> String {
match error {
GenerateError::PayloadTooLarge {
payload,
available,
deficit,
recommended_side,
} => describe_payload_too_large(*payload, *available, *deficit, *recommended_side),
GenerateError::DimensionsOutOfRange { .. } => format!(
"Error: {error}.\n \
Set the size with --width and --height; both must be at least 2000 \
pixels."
),
other => format!("Error: {other}"),
}
}
fn describe_payload_too_large(
payload: usize,
available: usize,
deficit: usize,
recommended_side: Option<u32>,
) -> String {
let advice = match recommended_side {
Some(side) => format!(
"A larger container would hold it: at about {side}x{side} it fits.\n \
Ask for one with --width {side} --height {side} (both must be at \
least 2000).\n \
Any width and height whose area is at least that will do; the \
suggestion is square only because one number is easier to quote."
),
None => "No permitted container is large enough for a payload this size; \
a generated container is capped at 128 megapixels.\n \
Split the payload, or compress it further before hiding it."
.to_owned(),
};
format!(
"Error: the payload does not fit in the requested container.\n \
Compressed and encrypted it is {payload} bytes; the container admits \
{available}.\n \
It is {deficit} bytes over.\n \
That first figure is the payload after compression, not the size of \
the file.\n \
{advice}"
)
}
fn print_generate_report(report: &GenerateReport, output: &Path) {
let (width, height) = report.image_dimensions;
println!("Container generated at {}", output.display());
println!(" Image dimensions: {width}x{height}");
println!(
" Payload carried: {} of {} bytes, compressed",
report.payload_bytes, report.capacity_bytes
);
println!(
"\nThis container does not hide that it was generated. It hides which of \
several\ngenerated containers carries a message. Prefer an unpublished \
photograph of your\nown whenever you have one."
);
}
fn print_report(report: &EmbedReport, output: &Path) {
let (width, height) = report.image_dimensions;
println!("Stego image written to {}", output.display());
println!(" Image dimensions: {width}x{height}");
println!(" Pixels modified: {}", report.pixels_modified);
println!(" Payload embedded: {} bytes", report.payload_bytes);
println!(" Effective rate: {:.6} bpp", report.effective_bpp);
}
fn run_extract(input: &Path, payload_out: Option<&Path>, force: bool) -> Result<(), String> {
drop(load_container(input)?);
if let Some(path) = payload_out {
if !force {
refuse_existing_destination(path)?;
}
}
let password = read_password()?;
let activity = progress::Activity::start();
let outcome = EmbedPipeline::default_secure()
.extract(input, password)
.map_err(|_| EXTRACTION_FAILED.to_string());
activity.finish();
let (plaintext, _report) = outcome?;
match payload_out {
Some(path) => write_recovered_file(path, plaintext.as_slice(), force),
None => write_recovered_to_stdout(plaintext.as_slice()),
}
}
fn refuse_existing_destination(path: &Path) -> Result<(), String> {
if path.is_dir() || !path.exists() {
return Ok(());
}
Err(format!(
"Error: {} already exists and would be overwritten.\n \
Choose another path, or pass --force to replace it.",
path.display()
))
}
fn refuse_unwritable_output(path: &Path, force: bool) -> Result<(), String> {
if path.is_dir() {
return Err(format!(
"Error: {} is a directory, and --output names the file to write.\n \
Give the whole path, file name included: --output {}",
path.display(),
path.join("stego.png").display()
));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.is_dir() {
return Err(format!(
"Error: the folder {} does not exist.\n \
Create it first, or choose a path inside a folder that is \
already there.",
parent.display()
));
}
}
if !force && path.exists() {
return Err(format!(
"Error: {} already exists and would be overwritten.\n \
Choose another path, or pass --force to replace it.",
path.display()
));
}
Ok(())
}
fn write_recovered_file(requested: &Path, plaintext: &[u8], force: bool) -> Result<(), String> {
let destination = payload::resolve_output_path(requested, plaintext);
payload::write_payload_file(&destination, plaintext, force)
.map_err(|_| EXTRACTION_FAILED.to_string())
}
#[derive(Debug, PartialEq, Eq)]
enum StdoutDelivery {
Raw,
RefuseBinary,
}
fn stdout_delivery(stdout_is_terminal: bool, plaintext: &[u8]) -> StdoutDelivery {
if stdout_is_terminal && std::str::from_utf8(plaintext).is_err() {
StdoutDelivery::RefuseBinary
} else {
StdoutDelivery::Raw
}
}
fn write_recovered_to_stdout(plaintext: &[u8]) -> Result<(), String> {
match stdout_delivery(io::stdout().is_terminal(), plaintext) {
StdoutDelivery::Raw => io::stdout()
.write_all(plaintext)
.and_then(|()| io::stdout().flush())
.map_err(|_| EXTRACTION_FAILED.to_string()),
StdoutDelivery::RefuseBinary => Err(binary_payload_notice(plaintext)),
}
}
fn binary_payload_notice(plaintext: &[u8]) -> String {
let extension = payload::detect_extension(plaintext);
let described = if extension == payload::BINARY_EXTENSION {
"binary data".to_owned()
} else {
format!("{} data", extension.to_uppercase())
};
format!(
"The payload is {} bytes of {described} and will not be written to the terminal.\n \
Redirect it to a file (for example: > payload.{extension}) or pass --payload-out PATH.",
plaintext.len()
)
}
fn run_completions(shell: Shell) -> Result<(), String> {
write_artifact(&completion_script(shell), "completion script")
}
fn completion_script(shell: Shell) -> Vec<u8> {
let mut command = Cli::command();
let name = command.get_name().to_string();
let mut script = Vec::new();
generate(shell, &mut command, name, &mut script);
script
}
fn run_man() -> Result<(), String> {
write_artifact(&manual_page()?, "manual page")
}
fn manual_page() -> Result<Vec<u8>, String> {
let mut page = Vec::new();
clap_mangen::Man::new(Cli::command())
.render(&mut page)
.map_err(|err| format!("Error: could not render the manual page: {err}"))?;
Ok(page)
}
fn write_artifact(bytes: &[u8], what: &str) -> Result<(), String> {
io::stdout()
.write_all(bytes)
.and_then(|()| io::stdout().flush())
.map_err(|err| format!("Error: could not write the {what}: {err}"))
}
fn container_capacity(image: &ImageBuffer) -> Option<usize> {
compute_stable_phash(image).ok()?;
let cost_map = HillCostProvider::new().compute(image).ok()?;
Some(compute_capacity(&cost_map, EmbeddingMode::Symmetric).available_bytes())
}
fn terminal_renders_unicode() -> bool {
#[cfg(windows)]
{
if io::stdout().is_terminal() {
return console_output_code_page() == 65_001;
}
}
true
}
#[cfg(windows)]
fn console_output_code_page() -> u32 {
extern "system" {
fn GetConsoleOutputCP() -> u32;
}
#[allow(unsafe_code)]
unsafe {
GetConsoleOutputCP()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use clap::ValueEnum;
use stenoxide_core::cost::hill::CostError;
use super::*;
fn typed(keystrokes: &str) -> String {
let mut input = io::Cursor::new(keystrokes.as_bytes().to_vec());
let message = collect_typed_lines(&mut input).expect("a cursor cannot fail to read");
String::from_utf8(message.to_vec()).expect("the fixtures are all UTF-8")
}
#[test]
fn a_lone_dot_ends_the_message() {
assert_eq!(typed("a secret\n.\n"), "a secret");
assert_eq!(typed("a secret\r\n.\r\n"), "a secret");
}
#[test]
fn nothing_after_the_terminator_is_taken() {
assert_eq!(typed("kept\n.\nnot this\n"), "kept");
}
#[test]
fn the_message_may_span_several_lines() {
assert_eq!(typed("one\ntwo\n\nfour\n.\n"), "one\ntwo\n\nfour");
}
#[test]
fn end_of_file_still_ends_the_message() {
assert_eq!(typed("a secret\n"), "a secret");
assert_eq!(typed("no newline at all"), "no newline at all");
assert_eq!(typed(""), "");
}
#[test]
fn a_dot_within_a_line_is_text() {
assert_eq!(
typed("Meet me at six. Bring it.\n.\n"),
"Meet me at six. Bring it."
);
assert_eq!(typed("..\n.\n"), "..");
assert_eq!(typed(" .\n.\n"), " .");
}
#[test]
fn a_message_that_is_only_the_terminator_is_empty() {
assert!(typed(".\n").is_empty());
}
#[test]
fn invalid_utf8_is_carried_through_unchanged() {
let mut input = io::Cursor::new(b"caf\xe9\n.\n".to_vec());
let message = collect_typed_lines(&mut input).expect("a cursor cannot fail to read");
assert_eq!(message.as_slice(), b"caf\xe9");
}
#[test]
fn a_payload_that_does_not_fit_is_told_by_how_much() {
let message = describe_embed_failure(&PipelineError::Sizer(SizerError::PayloadTooLarge {
payload: 30_000,
available: 22_016,
deficit: 7_984,
}));
assert!(message.contains("30000"), "got: {message}");
assert!(message.contains("22016"), "got: {message}");
assert!(message.contains("7984"), "got: {message}");
assert!(
message.contains("after compression"),
"the message must not read as a claim about the file's size: {message}"
);
assert!(message.contains("stenoxide scan"), "got: {message}");
}
#[test]
fn other_failures_are_printed_as_the_layer_phrased_them() {
let error = PipelineError::Validation(ValidationError::NotPng);
let message = describe_embed_failure(&error);
assert_eq!(message, format!("Error: {error}"));
}
#[test]
fn a_rejected_photograph_is_pointed_at_scan() {
let path = Path::new("photo.png");
let rejections = [
ValidationError::JpegDetected,
ValidationError::WebpDetected,
ValidationError::NotPng,
ValidationError::ImageTooSmall {
width: 800,
height: 600,
min: 2_000,
},
ValidationError::ImageTooLarge {
width: 20_000,
height: 20_000,
pixels: 400_000_000,
max: 134_217_728,
},
ValidationError::UnsupportedColorSpace {
found: "Rgb32F".to_owned(),
},
ValidationError::JpegArtifactsDetected { ratio: 1.8 },
];
for error in &rejections {
let message = describe_embed_rejection(path, error);
assert!(
message.contains("stenoxide scan"),
"{error:?} must point at scan, got: {message}"
);
assert!(
message.starts_with(&describe_rejection(path, error)),
"the original explanation must survive, got: {message}"
);
}
}
#[test]
fn a_broken_file_is_not_pointed_at_scan() {
let path = Path::new("photo.png");
let broken = [
ValidationError::IoError(io::Error::new(io::ErrorKind::NotFound, "no such file")),
ValidationError::DecodingError("truncated stream".to_owned()),
];
for error in &broken {
let message = describe_embed_rejection(path, error);
assert_eq!(
message,
describe_rejection(path, error),
"{error:?} must be reported exactly as it always was"
);
}
}
#[test]
fn extraction_keeps_the_plain_rejection() {
let path = Path::new("stego.png");
let message = describe_rejection(path, &ValidationError::NotPng);
assert!(
!message.contains("stenoxide scan"),
"the shared description must stay free of the embed hint, got: {message}"
);
}
#[test]
fn an_untextured_container_is_pointed_at_scan() {
let smooth = PipelineError::Cost(CostError::ExcessiveSmoothRegions { ratio: 0.42 });
let message = describe_embed_failure(&smooth);
assert!(message.contains("too smooth"), "got: {message}");
assert!(message.contains("stenoxide scan"), "got: {message}");
let unstable = PipelineError::PHash(PHashError::InsufficientStability {
unstable_bits: 3,
threshold: 0.05,
});
let message = describe_embed_failure(&unstable);
assert!(message.contains("perceptually unstable"), "got: {message}");
assert!(message.contains("stenoxide scan"), "got: {message}");
}
#[test]
fn an_oversized_generated_payload_names_a_size_and_the_flags() {
let message = describe_generate_failure(&GenerateError::PayloadTooLarge {
payload: 1_782_778,
available: 1_499_980,
deficit: 282_798,
recommended_side: Some(2_200),
});
assert!(message.contains("1782778"), "got: {message}");
assert!(message.contains("1499980"), "got: {message}");
assert!(message.contains("282798"), "got: {message}");
assert!(
message.contains("after compression"),
"the message must not read as a claim about the file's size: {message}"
);
assert!(message.contains("2200x2200"), "got: {message}");
assert!(message.contains("--width 2200"), "got: {message}");
assert!(message.contains("--height 2200"), "got: {message}");
}
#[test]
fn a_payload_beyond_every_container_is_told_plainly() {
let message = describe_generate_failure(&GenerateError::PayloadTooLarge {
payload: 60_000_000,
available: 1_499_980,
deficit: 58_500_020,
recommended_side: None,
});
assert!(message.contains("No permitted container"), "got: {message}");
assert!(message.contains("128 megapixels"), "got: {message}");
assert!(!message.contains("--width"), "no size to reach: {message}");
}
#[test]
fn an_out_of_range_size_names_the_flags() {
let message = describe_generate_failure(&GenerateError::DimensionsOutOfRange {
width: 1_500,
height: 3_000,
min_side: MIN_CONTAINER_SIDE,
max_pixels: stenoxide_core::generate::MAX_CONTAINER_PIXELS,
});
assert!(message.starts_with("Error:"), "got: {message}");
assert!(message.contains("1500x3000"), "got: {message}");
assert!(message.contains("--width"), "got: {message}");
assert!(message.contains("--height"), "got: {message}");
}
#[test]
fn only_an_existing_file_blocks_the_destination() {
let directory = tempfile::TempDir::new().expect("temporary directory");
assert!(refuse_existing_destination(directory.path()).is_ok());
assert!(refuse_existing_destination(&directory.path().join("new.zip")).is_ok());
let taken = directory.path().join("taken.zip");
std::fs::write(&taken, b"already here").expect("fixture write");
let message =
refuse_existing_destination(&taken).expect_err("an existing file must be refused");
assert!(message.contains("taken.zip"), "got: {message}");
assert!(message.contains("--force"), "got: {message}");
}
#[test]
fn a_redirection_takes_binary_and_text_alike() {
assert_eq!(stdout_delivery(false, b"plain text"), StdoutDelivery::Raw);
assert_eq!(
stdout_delivery(false, &[0xFF, 0xD8, 0xFF, 0xE0]),
StdoutDelivery::Raw,
"a redirection must take a JPEG's bytes unchanged"
);
}
#[test]
fn a_terminal_shows_text_but_refuses_binary() {
assert_eq!(
stdout_delivery(true, b"a readable message"),
StdoutDelivery::Raw
);
assert_eq!(
stdout_delivery(true, &[0xFF, 0xD8, 0xFF, 0xE0]),
StdoutDelivery::RefuseBinary
);
}
#[test]
fn accented_text_is_shown_on_a_terminal() {
assert_eq!(
stdout_delivery(true, "café — ñandú".as_bytes()),
StdoutDelivery::Raw
);
}
#[test]
fn every_supported_shell_gets_a_script() {
for shell in Shell::value_variants() {
let script = completion_script(*shell);
assert!(!script.is_empty(), "{shell} produced no completion script");
}
}
#[test]
fn the_manual_page_is_roff() {
let page = manual_page().expect("a page rendered into memory cannot fail");
let page = String::from_utf8(page).expect("the page is UTF-8");
assert!(page.contains(".TH stenoxide 1"), "no title macro in: {page}");
assert!(page.contains(".SH NAME"), "no name section in: {page}");
}
#[test]
fn the_first_line_is_already_the_artifact() {
let script = completion_script(Shell::Bash);
let script = String::from_utf8(script).expect("the script is UTF-8");
let first = script.lines().next().unwrap_or_default();
assert!(
first.starts_with('#') || first.starts_with('_'),
"a bash script opens with a comment or a function, got: {first:?}"
);
let page = manual_page().expect("a page rendered into memory cannot fail");
let page = String::from_utf8(page).expect("the page is UTF-8");
let first = page.lines().next().unwrap_or_default();
assert!(
first.starts_with('.'),
"a roff page opens with a control line, got: {first:?}"
);
}
#[test]
fn the_existing_arguments_are_untouched() {
Cli::command().debug_assert();
let cli = Cli::try_parse_from([
"stenoxide",
"embed",
"--input",
"cover.png",
"--output",
"stego.png",
"--payload",
"secret.zip",
])
.expect("the embed arguments must keep parsing");
assert!(
matches!(
cli.command,
Command::Embed { input, output, payload, force }
if input == Path::new("cover.png")
&& output == Path::new("stego.png")
&& payload.as_deref() == Some(Path::new("secret.zip"))
&& !force
),
"embed must still parse into the same three paths"
);
}
#[test]
fn every_short_flag_means_what_its_long_form_means() {
let cases: &[(&[&str], &[&str])] = &[
(
&[
"stenoxide",
"embed",
"--input",
"cover.png",
"--output",
"stego.png",
"--payload",
"secret.zip",
"--force",
],
&[
"stenoxide", "embed", "-i", "cover.png", "-o", "stego.png", "-p", "secret.zip",
"-f",
],
),
(
&[
"stenoxide",
"extract",
"--input",
"stego.png",
"--payload-out",
"secret.zip",
"--force",
],
&[
"stenoxide",
"extract",
"-i",
"stego.png",
"-o",
"secret.zip",
"-f",
],
),
(
&[
"stenoxide",
"generate",
"--output",
"container.png",
"--input",
"message.txt",
"--force",
],
&[
"stenoxide",
"generate",
"-o",
"container.png",
"-p",
"message.txt",
"-f",
],
),
(
&["stenoxide", "scan", "./photos", "--all", "--recursive"],
&["stenoxide", "scan", "./photos", "-a", "-r"],
),
];
for (long, short) in cases {
let spelled_out = Cli::try_parse_from(*long).map(|cli| format!("{:?}", Rendered(&cli)));
let abbreviated = Cli::try_parse_from(*short).map(|cli| format!("{:?}", Rendered(&cli)));
assert_eq!(
spelled_out.as_deref().ok(),
abbreviated.as_deref().ok(),
"{short:?} must parse to what {long:?} parses to"
);
assert!(spelled_out.is_ok(), "{long:?} stopped parsing");
}
}
struct Rendered<'cli>(&'cli Cli);
impl std::fmt::Debug for Rendered<'_> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0.command {
Command::Scan(args) => write!(
formatter,
"scan {} all={} recursive={} json={}",
args.path, args.all, args.recursive, args.json
),
Command::Embed {
input,
output,
payload,
force,
} => write!(
formatter,
"embed {} {} {payload:?} force={force}",
input.display(),
output.display()
),
Command::Generate {
output,
input,
width,
height,
force,
} => write!(
formatter,
"generate {} {input:?} {width}x{height} force={force}",
output.display()
),
Command::Extract {
input,
payload_out,
force,
} => write!(
formatter,
"extract {} {payload_out:?} force={force}",
input.display()
),
Command::Completions { shell } => write!(formatter, "completions {shell}"),
Command::Man => write!(formatter, "man"),
}
}
}
#[test]
fn the_help_letter_is_never_reassigned() {
for subcommand in ["scan", "embed", "extract", "generate"] {
let outcome = Cli::try_parse_from(["stenoxide", subcommand, "-h"])
.err()
.map(|error| error.kind());
assert_eq!(
outcome,
Some(clap::error::ErrorKind::DisplayHelp),
"-h must print the help of {subcommand}"
);
}
}
#[test]
fn a_directory_is_not_an_output_path() {
let directory = tempfile::TempDir::new().expect("temporary directory");
for force in [false, true] {
let message = refuse_unwritable_output(directory.path(), force)
.expect_err("a directory cannot receive the image");
assert!(message.contains("is a directory"), "got: {message}");
assert!(message.contains("--output"), "got: {message}");
assert!(message.contains("stego.png"), "got: {message}");
}
}
#[test]
fn an_existing_output_is_refused_unless_forced() {
let directory = tempfile::TempDir::new().expect("temporary directory");
let taken = directory.path().join("stego.png");
std::fs::write(&taken, b"an earlier stego image").expect("fixture write");
let message =
refuse_unwritable_output(&taken, false).expect_err("an existing file must be refused");
assert!(message.contains("stego.png"), "got: {message}");
assert!(message.contains("already exists"), "got: {message}");
assert!(message.contains("--force"), "got: {message}");
assert!(
refuse_unwritable_output(&taken, true).is_ok(),
"--force is what replaces a file that is already there"
);
}
#[test]
fn a_missing_folder_is_named_and_left_alone() {
let directory = tempfile::TempDir::new().expect("temporary directory");
let missing = directory.path().join("nowhere");
let destination = missing.join("stego.png");
for force in [false, true] {
let message = refuse_unwritable_output(&destination, force)
.expect_err("a missing folder must be refused");
assert!(message.contains("nowhere"), "got: {message}");
assert!(message.contains("does not exist"), "got: {message}");
}
assert!(!missing.exists(), "the folder must not have been created");
}
#[test]
fn a_free_destination_is_accepted() {
let directory = tempfile::TempDir::new().expect("temporary directory");
assert!(refuse_unwritable_output(&directory.path().join("stego.png"), false).is_ok());
assert!(refuse_unwritable_output(Path::new("stego.png"), false).is_ok());
}
#[test]
fn extraction_still_accepts_a_directory_as_a_destination() {
let directory = tempfile::TempDir::new().expect("temporary directory");
assert!(refuse_existing_destination(directory.path()).is_ok());
assert!(
refuse_unwritable_output(directory.path(), false).is_err(),
"the two must not have been collapsed into one"
);
}
#[test]
fn the_long_help_opens_with_the_short_one() {
assert!(
CLI_LONG_ABOUT.starts_with(env!("CARGO_PKG_DESCRIPTION")),
"the long help must open with the sentence -h prints, got: {CLI_LONG_ABOUT}"
);
for verb in ["scan", "embed", "extract", "generate"] {
assert!(
CLI_LONG_ABOUT.contains(&format!("stenoxide {verb}")),
"the flow must still name {verb}, got: {CLI_LONG_ABOUT}"
);
}
assert!(
!CLI_LONG_ABOUT.contains("completions") && !CLI_LONG_ABOUT.contains("stenoxide man"),
"the quickstart is about the four verbs only, got: {CLI_LONG_ABOUT}"
);
}
#[test]
fn the_binary_notice_names_the_type_it_recognised() {
let mut archive = vec![0x50, 0x4B, 0x03, 0x04, 0x14, 0x00];
archive.resize(267, 0x00);
let notice = binary_payload_notice(&archive);
assert!(notice.contains("267 bytes"), "got: {notice}");
assert!(notice.contains("ZIP data"), "got: {notice}");
assert!(notice.contains("> payload.zip"), "got: {notice}");
assert!(notice.contains("--payload-out"), "got: {notice}");
}
#[test]
fn an_unrecognised_payload_is_still_called_binary_data() {
let notice = binary_payload_notice(&[0x80, 0x91, 0xA2, 0xB3]);
assert!(notice.contains("binary data"), "got: {notice}");
assert!(notice.contains("> payload.bin"), "got: {notice}");
assert!(!notice.contains("BIN data"), "got: {notice}");
}
#[test]
fn the_guidance_says_a_sent_line_cannot_be_taken_back() {
assert!(
TYPING_GUIDANCE.contains("cannot be edited"),
"got: {TYPING_GUIDANCE:?}"
);
assert!(
TYPING_GUIDANCE.contains("-p"),
"the way out must be named: {TYPING_GUIDANCE:?}"
);
}
#[test]
fn the_guidance_states_how_to_finish() {
assert!(
TYPING_GUIDANCE.contains(END_OF_MESSAGE),
"the guidance must name the terminator, got: {TYPING_GUIDANCE:?}"
);
assert!(
TYPING_GUIDANCE.is_ascii(),
"the guidance is printed before the console's code page is known"
);
}
}