#![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, Parser, Subcommand};
use zeroize::Zeroizing;
use stenoxide_core::cost::hill::HillCostProvider;
use stenoxide_core::cost::CostProvider;
use stenoxide_core::image_io::buffer::ImageBuffer;
use stenoxide_core::image_io::phash::compute_stable_phash;
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 TYPING_GUIDANCE: &str = "\
Message to hide. It may span as many lines as you need.
Finish with a line containing a single dot: .
";
#[derive(Parser)]
#[command(name = "stenoxide", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Scan(ScanArgs),
Embed {
#[arg(long, value_name = "PATH")]
input: PathBuf,
#[arg(long, value_name = "PATH")]
output: PathBuf,
#[arg(long, value_name = "PATH")]
payload: Option<PathBuf>,
},
Extract {
#[arg(long, value_name = "PATH")]
input: PathBuf,
#[arg(long, value_name = "PATH")]
payload_out: Option<PathBuf>,
#[arg(long, requires = "payload_out")]
force: bool,
},
}
#[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,
} => run_embed(input, output, payload.as_deref()),
Command::Extract {
input,
payload_out,
force,
} => run_extract(input, payload_out.as_deref(), *force),
};
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 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 run_embed(input: &Path, output: &Path, payload: Option<&Path>) -> Result<(), String> {
drop(load_container(input)?);
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 {
let PipelineError::Sizer(SizerError::PayloadTooLarge {
payload,
available,
deficit,
}) = error
else {
return format!("Error: {error}");
};
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."
)
}
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 => io::stdout()
.write_all(plaintext.as_slice())
.and_then(|()| io::stdout().flush())
.map_err(|_| EXTRACTION_FAILED.to_string()),
}
}
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 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())
}
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 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 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 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"
);
}
}