#![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;
#[cfg(feature = "pqc")]
use stenoxide_core::crypto::aead::XChaCha20Poly1305Cipher;
#[cfg(feature = "pqc")]
use stenoxide_core::crypto::kdf::Argon2Kdf;
#[cfg(feature = "pqc")]
use stenoxide_core::crypto::kem::{Identity, KemError, RecipientKey};
#[cfg(feature = "pqc")]
use stenoxide_core::generate::generate_container_for_recipient;
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, PHashSalt};
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: ";
#[cfg(feature = "pqc")]
const KEY_PASSPHRASE_PROMPT: &str = "Passphrase for the private key file: ";
#[cfg(feature = "pqc")]
const KEY_PASSPHRASE_REPEAT_PROMPT: &str = "Repeat it: ";
#[cfg(feature = "pqc")]
const KEY_PASSPHRASE_GUIDANCE: &str = "\
This passphrase protects the private key file on this machine and nothing else.
It is not the password used to hide a message, it never travels, and nobody you
send the public key to is ever asked for it.
There is no recovery: lose it and everything sent to this key is unreadable.
";
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.";
#[cfg(feature = "pqc")]
const KEYGEN_LONG_ABOUT: &str = "\
Generate a key pair, so that people can hide messages for you without agreeing
on a password with you first.
The public file is meant to be published: put it in a profile, paste it into a
chat, send it wherever. Anyone holding it can run
stenoxide generate --recipient <that file> --output container.png
and only the holder of the private file can read the result. The message key is
drawn fresh for every container and owes nothing to the image, so reusing a
picture is harmless here rather than merely discouraged.
The private file is protected by a passphrase that is local to this machine. It
is not the password used to hide a message, it never travels, and nobody you
send the public key to is ever asked for it. There is no recovery.
EXPERIMENTAL. The file format is not settled and a later release may not read
keys generated today. Do not build anything on it yet.";
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,
#[cfg(feature = "pqc")]
#[arg(long, value_name = "PATH")]
recipient: Option<PathBuf>,
#[arg(long, short = 'f')]
force: bool,
},
#[cfg(feature = "pqc")]
#[command(long_about = KEYGEN_LONG_ABOUT)]
Keygen {
#[arg(long, value_name = "PATH")]
public: PathBuf,
#[arg(long, value_name = "PATH")]
private: PathBuf,
#[arg(long, short = 'f')]
force: bool,
},
Extract {
#[arg(long, short = 'i', value_name = "PATH")]
input: PathBuf,
#[cfg(feature = "pqc")]
#[arg(long, value_name = "PATH")]
identity: Option<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,
}
enum GenerateKeySource {
Password,
#[cfg(feature = "pqc")]
Recipient(PathBuf),
}
enum ExtractKeySource {
Password,
#[cfg(feature = "pqc")]
Identity(PathBuf),
}
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,
#[cfg(feature = "pqc")]
recipient,
force,
} => {
#[cfg(feature = "pqc")]
let source = match recipient {
Some(path) => GenerateKeySource::Recipient(path.clone()),
None => GenerateKeySource::Password,
};
#[cfg(not(feature = "pqc"))]
let source = GenerateKeySource::Password;
run_generate(output, input.as_deref(), *width, *height, *force, &source)
}
#[cfg(feature = "pqc")]
Command::Keygen {
public,
private,
force,
} => run_keygen(public, private, *force),
Command::Extract {
input,
#[cfg(feature = "pqc")]
identity,
payload_out,
force,
} => {
#[cfg(feature = "pqc")]
let source = match identity {
Some(path) => ExtractKeySource::Identity(path.clone()),
None => ExtractKeySource::Password,
};
#[cfg(not(feature = "pqc"))]
let source = ExtractKeySource::Password;
run_extract(input, payload_out.as_deref(), *force, &source)
}
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}"))
}
#[cfg(feature = "pqc")]
fn read_local_passphrase(repeat: bool) -> Result<Zeroizing<Vec<u8>>, String> {
let passphrase = rpassword::prompt_password(KEY_PASSPHRASE_PROMPT)
.map(|passphrase| Zeroizing::new(passphrase.into_bytes()))
.map_err(|err| format!("Error: could not read the passphrase: {err}"))?;
if repeat {
let repeated = rpassword::prompt_password(KEY_PASSPHRASE_REPEAT_PROMPT)
.map(|passphrase| Zeroizing::new(passphrase.into_bytes()))
.map_err(|err| format!("Error: could not read the passphrase: {err}"))?;
if *passphrase != *repeated {
return Err(
"Error: passphrases do not match. The key file was not created.".to_string(),
);
}
}
Ok(passphrase)
}
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,
key_source: &GenerateKeySource,
) -> Result<(), String> {
#[cfg(not(feature = "pqc"))]
let _ = key_source;
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 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();
#[cfg(feature = "pqc")]
let outcome = match key_source {
GenerateKeySource::Password => {
let password = read_password()?;
generate_container(plaintext, password, dimensions, output)
}
GenerateKeySource::Recipient(path) => {
let recipient = read_recipient_key(path)?;
generate_container_for_recipient(plaintext, &recipient, dimensions, output)
}
};
#[cfg(not(feature = "pqc"))]
let outcome = {
let password = read_password()?;
generate_container(plaintext, password, dimensions, output)
};
activity.finish();
let report = outcome.map_err(|err| describe_generate_failure(&err))?;
print_generate_report(&report, output);
Ok(())
}
#[cfg(feature = "pqc")]
fn read_recipient_key(path: &Path) -> Result<RecipientKey, String> {
let text = std::fs::read_to_string(path)
.map_err(|err| format!("Error: could not read {}: {err}", path.display()))?;
RecipientKey::from_public_file(&text)
.map_err(|err| format!("Error: {} is not a usable recipient key: {err}", path.display()))
}
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,
key_source: &ExtractKeySource,
) -> Result<(), String> {
#[cfg(not(feature = "pqc"))]
let _ = key_source;
drop(load_container(input)?);
if let Some(path) = payload_out {
if !force {
refuse_existing_destination(path)?;
}
}
let activity = progress::Activity::start();
#[cfg(feature = "pqc")]
let outcome = match key_source {
ExtractKeySource::Password => {
let password = read_password()?;
EmbedPipeline::default_secure().extract(input, password)
}
ExtractKeySource::Identity(path) => {
let identity = read_identity(path)?;
EmbedPipeline::default_secure().extract_with_identity(input, &identity)
}
};
#[cfg(not(feature = "pqc"))]
let outcome = {
let password = read_password()?;
EmbedPipeline::default_secure().extract(input, password)
};
let outcome = outcome.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()),
}
}
#[cfg(feature = "pqc")]
fn run_keygen(public_path: &Path, private_path: &Path, force: bool) -> Result<(), String> {
refuse_unwritable_output(public_path, force)?;
refuse_unwritable_output(private_path, force)?;
eprint!("{KEY_PASSPHRASE_GUIDANCE}");
let passphrase = read_local_passphrase(true)?;
let activity = progress::Activity::start();
let identity = Identity::generate().map_err(|err| format!("Error: {err}"))?;
let public = identity.recipient().to_public_file();
let private = identity
.seal(
&passphrase,
&Argon2Kdf::default_secure(),
&XChaCha20Poly1305Cipher::new(),
)
.map_err(|err| format!("Error: {err}"))?;
drop(identity);
activity.finish();
payload::write_payload_file(public_path, public.as_bytes(), force)
.map_err(|err| format!("Error: could not write {}: {err}", public_path.display()))?;
payload::write_payload_file(private_path, private.as_bytes(), force)
.map_err(|err| format!("Error: could not write {}: {err}", private_path.display()))?;
eprintln!(
"\nThe public key is in {}.",
public_path.display()
);
eprintln!(
"Send that file to anyone who wants to hide a message for you; it is meant to be published.",
);
eprintln!(
"The private key is in {}. It stays on this machine.",
private_path.display()
);
Ok(())
}
#[cfg(feature = "pqc")]
fn read_identity(path: &Path) -> Result<Identity, String> {
let text = std::fs::read_to_string(path)
.map_err(|err| format!("Error: could not read {}: {err}", path.display()))?;
let passphrase = read_local_passphrase(false)?;
Identity::open(
&text,
&passphrase,
&Argon2Kdf::default_secure(),
&XChaCha20Poly1305Cipher::new(),
)
.map_err(|err| match err {
KemError::WrongPassphrase => {
format!("Error: the passphrase did not unlock {}.", path.display())
}
other => format!("Error: {} is not a usable identity: {other}", path.display()),
})
}
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 analyse_container(image: &ImageBuffer) -> Option<(usize, PHashSalt)> {
let salt = compute_stable_phash(image).ok()?;
let cost_map = HillCostProvider::new().compute(image).ok()?;
Some((
compute_capacity(&cost_map, EmbeddingMode::Symmetric).available_bytes(),
salt,
))
}
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,
#[cfg(feature = "pqc")]
recipient,
force,
} => {
#[cfg(feature = "pqc")]
let recipient = match recipient {
Some(path) => format!(" recipient={}", path.display()),
None => String::new(),
};
#[cfg(not(feature = "pqc"))]
let recipient = String::new();
write!(
formatter,
"generate {} {input:?} {width}x{height} force={force}{recipient}",
output.display()
)
}
#[cfg(feature = "pqc")]
Command::Keygen {
public,
private,
force,
} => write!(
formatter,
"keygen {} {} force={force}",
public.display(),
private.display()
),
Command::Extract {
input,
#[cfg(feature = "pqc")]
identity,
payload_out,
force,
} => {
#[cfg(feature = "pqc")]
let identity = match identity {
Some(path) => format!(" identity={}", path.display()),
None => String::new(),
};
#[cfg(not(feature = "pqc"))]
let identity = String::new();
write!(
formatter,
"extract {} {payload_out:?} force={force}{identity}",
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"
);
}
}