use std::collections::{BTreeSet, HashMap};
use std::fmt::Write as _;
use std::hash::Hash;
use std::path::{Path, PathBuf};
use stenoxide_core::image_io::buffer::CoverSource;
use stenoxide_core::image_io::phash::PHashSalt;
use stenoxide_core::image_io::validate::{load_and_validate, probe_geometry};
use crate::progress::Progress;
use crate::{analyse_container, terminal_renders_unicode, ScanArgs};
const PNG_EXTENSION: &str = "png";
const BYTES_PER_KIB: f64 = 1024.0;
const PATH_HEADING: &str = "PATH";
const SIZE_HEADING: &str = "SIZE";
const CAPACITY_HEADING: &str = "PAYLOAD*";
const CAPACITY_AND_REASON_HEADING: &str = "PAYLOAD* / REASON";
const REASON_HEADING: &str = "REASON";
const NEXT_STEP_HINT: &str = "\n\
Hide a message in one of them:\n\
stenoxide embed --input <file above> --output stego.png\n";
const NOTHING_USABLE_HINT: &str = "\n\
None of these can be used as a container.\n\
stenoxide generate builds one around your message instead. It is a last\n\
resort: the container does not hide that it was generated, only which of\n\
several generated containers carries anything.\n";
const SHARED_SALT_HEADING: &str =
"WARNING: these images are one container as far as the key is concerned.";
const SHARED_SALT_ADVICE: &str = "\
The key is derived from what the image looks like and from your password, so\n\
hiding a message in two of these under one password encrypts both under the\n\
same key, and anyone holding the two images can then recover what they say.\n\
Give each image its own password, or use only one image from each group.\n";
type SaltGroup = Vec<String>;
enum Verdict {
Usable {
dimensions: (u32, u32),
capacity_bytes: usize,
},
Unusable {
reason: &'static str,
dimensions: Option<(u32, u32)>,
},
}
struct Entry {
path: PathBuf,
verdict: Verdict,
}
struct Examined {
entry: Entry,
salt: Option<PHashSalt>,
}
pub fn run(args: &ScanArgs) -> Result<(), String> {
let files = collect_files(&args.path, args.recursive)?;
let probed: Vec<(PathBuf, Option<u64>)> = files
.into_iter()
.map(|path| {
let pixels = candidate_pixels(&path);
(path, pixels)
})
.collect();
let total_megapixels: u64 = probed
.iter()
.filter_map(|(_, pixels)| *pixels)
.map(megapixels)
.sum();
let progress = Progress::new(
&format!(
"Analysing {} of {} files",
probed.iter().filter(|(_, pixels)| pixels.is_some()).count(),
probed.len()
),
total_megapixels,
);
let examined: Vec<Examined> = probed
.into_iter()
.map(|(path, pixels)| {
if let Some(pixels) = pixels {
progress.set_detail(&file_name(&path));
let entry = examine(path);
progress.advance(megapixels(pixels));
entry
} else {
examine(path)
}
})
.collect();
progress.finish();
let collisions = shared_groups(examined.iter().filter_map(|examined| {
let salt = examined.salt.as_ref()?;
Some((salt, examined.entry.path.display().to_string()))
}));
let entries: Vec<Entry> = examined.into_iter().map(|examined| examined.entry).collect();
let report = if args.json {
render_json(&entries, &collisions)
} else {
render_listing(&args.path, &entries, &collisions, args.all)
};
print!("{report}");
Ok(())
}
fn candidate_pixels(path: &Path) -> Option<u64> {
let is_png = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase() == PNG_EXTENSION)
.unwrap_or(false);
if !is_png {
return None;
}
probe_geometry(path)
.ok()
.map(|geometry| geometry.pixel_count())
}
fn megapixels(pixels: u64) -> u64 {
pixels.div_ceil(1024 * 1024)
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn collect_files(argument: &str, recursive: bool) -> Result<Vec<PathBuf>, String> {
let path = Path::new(argument);
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
if path.is_dir() {
let mut found = BTreeSet::new();
walk(path, recursive, &mut found)?;
return Ok(found.into_iter().collect());
}
let matched = glob_matches(argument, recursive)?;
if matched.is_empty() {
return Err(format!(
"Error: {argument} does not name a file, a directory or any matching path."
));
}
Ok(matched)
}
fn walk(directory: &Path, recursive: bool, found: &mut BTreeSet<PathBuf>) -> Result<(), String> {
let entries = std::fs::read_dir(directory)
.map_err(|err| format!("Error: cannot read {}: {err}", directory.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if recursive {
walk(&path, recursive, found)?;
}
} else {
found.insert(path);
}
}
Ok(())
}
fn glob_matches(pattern: &str, recursive: bool) -> Result<Vec<PathBuf>, String> {
let (root, rest) = split_pattern(pattern);
let mut current = vec![root];
for component in rest {
let mut next = BTreeSet::new();
for directory in ¤t {
if component.as_str() == "**" {
next.insert(directory.clone());
collect_directories(directory, &mut next)?;
continue;
}
let entries = match std::fs::read_dir(directory) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if matches_component(&name, &component) {
next.insert(path);
}
}
}
current = next.into_iter().collect();
}
let mut files = BTreeSet::new();
for path in current {
if path.is_dir() {
walk(&path, recursive, &mut files)?;
} else if path.is_file() {
files.insert(path);
}
}
Ok(files.into_iter().collect())
}
fn split_pattern(pattern: &str) -> (PathBuf, Vec<String>) {
let components: Vec<&str> = pattern.split(['/', '\\']).collect();
let fixed = components
.iter()
.position(|component| component.contains(['*', '?']))
.unwrap_or(components.len());
let root = match &components[..fixed] {
[] => PathBuf::from("."),
[""] => PathBuf::from("/"),
fixed => PathBuf::from(fixed.join("/")),
};
let rest = components[fixed..]
.iter()
.map(|component| (*component).to_owned())
.collect();
(root, rest)
}
fn collect_directories(directory: &Path, found: &mut BTreeSet<PathBuf>) -> Result<(), String> {
let entries = match std::fs::read_dir(directory) {
Ok(entries) => entries,
Err(_) => return Ok(()),
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.insert(path.clone());
collect_directories(&path, found)?;
}
}
Ok(())
}
fn matches_component(name: &str, pattern: &str) -> bool {
let name: Vec<char> = name.chars().collect();
let pattern: Vec<char> = pattern.chars().collect();
let (mut matched, mut consumed) = (0usize, 0usize);
let (mut star, mut resume) = (None, 0usize);
while matched < name.len() {
match pattern.get(consumed) {
Some('*') => {
star = Some(consumed);
resume = matched;
consumed += 1;
}
Some('?') => {
matched += 1;
consumed += 1;
}
Some(&literal) if literal == name[matched] => {
matched += 1;
consumed += 1;
}
_ => match star {
Some(position) => {
consumed = position + 1;
resume += 1;
matched = resume;
}
None => return false,
},
}
}
pattern[consumed..]
.iter()
.all(|&character| character == '*')
}
fn examine(path: PathBuf) -> Examined {
let is_png = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase() == PNG_EXTENSION)
.unwrap_or(false);
if !is_png {
return Examined {
entry: Entry {
path,
verdict: Verdict::Unusable {
reason: "UnsupportedFormat",
dimensions: None,
},
},
salt: None,
};
}
let (verdict, salt) = match load_and_validate(&path) {
Ok(image) => {
let dimensions = image.dimensions();
match analyse_container(&image) {
Some((capacity_bytes, salt)) => (
Verdict::Usable {
dimensions,
capacity_bytes,
},
Some(salt),
),
None => (
Verdict::Unusable {
reason: "InsufficientTexture",
dimensions: Some(dimensions),
},
None,
),
}
}
Err(error) => {
let (reason, dimensions) = describe(&error);
(Verdict::Unusable { reason, dimensions }, None)
}
};
Examined {
entry: Entry { path, verdict },
salt,
}
}
fn shared_groups<Key: Eq + Hash>(named: impl IntoIterator<Item = (Key, String)>) -> Vec<SaltGroup> {
let mut opened: HashMap<Key, usize> = HashMap::new();
let mut groups: Vec<SaltGroup> = Vec::new();
for (key, name) in named {
let index = *opened.entry(key).or_insert(groups.len());
match groups.get_mut(index) {
Some(group) => group.push(name),
None => groups.push(vec![name]),
}
}
groups.retain(|group| group.len() > 1);
groups
}
fn describe(
error: &stenoxide_core::image_io::validate::ValidationError,
) -> (&'static str, Option<(u32, u32)>) {
use stenoxide_core::image_io::validate::ValidationError as Error;
match error {
Error::IoError(_) => ("IoError", None),
Error::JpegDetected => ("JpegDetected", None),
Error::WebpDetected => ("WebpDetected", None),
Error::NotPng => ("NotPng", None),
Error::UnsupportedColorSpace { .. } => ("UnsupportedColorSpace", None),
Error::ImageTooSmall { width, height, .. } => ("ImageTooSmall", Some((*width, *height))),
Error::ImageTooLarge { width, height, .. } => ("ImageTooLarge", Some((*width, *height))),
Error::DecodingError(_) => ("DecodingError", None),
Error::JpegArtifactsDetected { .. } => ("JpegArtifactsDetected", None),
}
}
type Marks = (&'static str, &'static str);
struct Row {
mark: &'static str,
path: String,
size: String,
detail: String,
}
fn listing_marks() -> Marks {
if terminal_renders_unicode() {
("\u{2713}", "\u{2717}")
} else {
("[OK]", "[--]")
}
}
fn render_listing(
argument: &str,
entries: &[Entry],
collisions: &[SaltGroup],
all: bool,
) -> String {
render_listing_with(listing_marks(), argument, entries, collisions, all)
}
fn render_listing_with(
marks: Marks,
argument: &str,
entries: &[Entry],
collisions: &[SaltGroup],
all: bool,
) -> String {
let (usable_mark, unusable_mark) = marks;
let mut usable = 0usize;
let mut unusable = 0usize;
let mut rows: Vec<Row> = Vec::new();
let mut rejected: Vec<Row> = Vec::new();
for entry in entries {
let path = entry.path.display().to_string();
match &entry.verdict {
Verdict::Usable {
dimensions: (width, height),
capacity_bytes,
} => {
usable += 1;
rows.push(Row {
mark: usable_mark,
path,
size: format!("{width}x{height}"),
detail: format!("~{:.1} KB", *capacity_bytes as f64 / BYTES_PER_KIB),
});
}
Verdict::Unusable { reason, dimensions } => {
unusable += 1;
if !all {
continue;
}
rejected.push(Row {
mark: unusable_mark,
path,
size: dimensions
.map(|(width, height)| format!("{width}x{height}"))
.unwrap_or_default(),
detail: (*reason).to_owned(),
});
}
}
}
let listed_rejections = !rejected.is_empty();
rows.append(&mut rejected);
let detail_heading = match (usable > 0, listed_rejections) {
(true, true) => CAPACITY_AND_REASON_HEADING,
(true, false) => CAPACITY_HEADING,
(false, _) => REASON_HEADING,
};
let mark_width = usable_mark
.chars()
.count()
.max(unusable_mark.chars().count());
let path_width = column_width(PATH_HEADING, rows.iter().map(|row| row.path.as_str()));
let size_width = column_width(SIZE_HEADING, rows.iter().map(|row| row.size.as_str()));
let mut report = String::new();
let _ = writeln!(report, "Scanning {argument} ...\n");
if !rows.is_empty() {
let _ = writeln!(
report,
" {blank:<mark_width$} {PATH_HEADING:<path_width$} {SIZE_HEADING:<size_width$} {detail_heading}",
blank = ""
);
}
for Row {
mark,
path,
size,
detail,
} in &rows
{
let _ = writeln!(
report,
" {mark:<mark_width$} {path:<path_width$} {size:<size_width$} {detail}"
);
}
let scanned = usable + unusable;
let _ = writeln!(report);
if usable > 0 {
let _ = writeln!(
report,
" * Estimated payload capacity after encryption overhead"
);
}
let _ = writeln!(
report,
" Summary: {usable} valid, {unusable} invalid ({scanned} scanned)"
);
if unusable > 0 && !all {
let _ = writeln!(report, " Run with --all to see why an image was rejected.");
}
let _ = write!(report, "{}", render_collisions(collisions));
if usable > 0 {
let _ = write!(report, "{NEXT_STEP_HINT}");
} else if scanned > 0 {
let _ = write!(report, "{NOTHING_USABLE_HINT}");
}
report
}
fn render_collisions(groups: &[SaltGroup]) -> String {
if groups.is_empty() {
return String::new();
}
let mut block = String::new();
let _ = writeln!(block, "\n {SHARED_SALT_HEADING}");
for group in groups {
let _ = writeln!(block);
for path in group {
let _ = writeln!(block, " {path}");
}
}
let _ = write!(block, "\n{SHARED_SALT_ADVICE}");
block
}
fn column_width<'value>(heading: &str, values: impl Iterator<Item = &'value str>) -> usize {
values.fold(heading.chars().count(), |widest, value| {
widest.max(value.chars().count())
})
}
fn render_json(entries: &[Entry], collisions: &[SaltGroup]) -> String {
let mut valid = Vec::new();
let mut invalid = Vec::new();
for entry in entries {
let path = escape(&entry.path.display().to_string());
match &entry.verdict {
Verdict::Usable {
dimensions: (width, height),
capacity_bytes,
} => valid.push(format!(
" {{\n \"path\": \"{path}\",\n \"dimensions\": [{width}, {height}],\n \"capacity_kb\": {:.1}\n }}",
*capacity_bytes as f64 / BYTES_PER_KIB
)),
Verdict::Unusable { reason, .. } => invalid.push(format!(
" {{\n \"path\": \"{path}\",\n \"reason\": \"{reason}\"\n }}"
)),
}
}
let render = |records: &[String]| {
if records.is_empty() {
"[]".to_owned()
} else {
format!("[\n{}\n ]", records.join(",\n"))
}
};
let groups: Vec<String> = collisions
.iter()
.map(|group| {
let paths: Vec<String> = group
.iter()
.map(|path| format!(" \"{}\"", escape(path)))
.collect();
format!(" [\n{}\n ]", paths.join(",\n"))
})
.collect();
let salt_collisions = if groups.is_empty() {
String::new()
} else {
format!(",\n \"salt_collisions\": [\n{}\n ]", groups.join(",\n"))
};
format!(
"{{\n \"valid\": {},\n \"invalid\": {},\n \"summary\": {{\n \"scanned\": {},\n \"valid\": {},\n \"invalid\": {}\n }}{salt_collisions}\n}}\n",
render(&valid),
render(&invalid),
valid.len() + invalid.len(),
valid.len(),
invalid.len()
)
}
fn escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
control if control < ' ' => {
let _ = write!(escaped, "\\u{:04x}", control as u32);
}
other => escaped.push(other),
}
}
escaped
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::*;
#[test]
fn a_pattern_splits_into_a_root_and_the_parts_still_to_match() {
let split = |pattern: &str| {
let (root, rest) = split_pattern(pattern);
(root.to_string_lossy().into_owned(), rest.join("|"))
};
assert_eq!(split("*.png"), (".".to_owned(), "*.png".to_owned()));
assert_eq!(
split("photos/*.png"),
("photos".to_owned(), "*.png".to_owned())
);
assert_eq!(
split("photos/**/*.png"),
("photos".to_owned(), "**|*.png".to_owned())
);
assert_eq!(
split(r"C:\Users\me\photos\*.png"),
("C:/Users/me/photos".to_owned(), "*.png".to_owned())
);
assert_eq!(
split("/home/me/*.png"),
("/home/me".to_owned(), "*.png".to_owned())
);
assert_eq!(split("/*.png"), ("/".to_owned(), "*.png".to_owned()));
assert_eq!(
split("photos/a.png"),
("photos/a.png".to_owned(), String::new())
);
}
#[test]
fn a_component_with_no_wildcard_matches_only_itself() {
assert!(matches_component("photo.png", "photo.png"));
assert!(!matches_component("photo.png", "photo.jpg"));
assert!(!matches_component("photo.png", "photo"));
}
#[test]
fn a_star_matches_any_run() {
assert!(matches_component("photo.png", "*.png"));
assert!(matches_component("photo.png", "*"));
assert!(matches_component(".png", "*.png"));
assert!(matches_component("a-b-c.png", "a-*-c.png"));
assert!(!matches_component("photo.jpg", "*.png"));
assert!(matches_component("aaa.png.png", "*.png"));
}
#[test]
fn a_question_mark_matches_exactly_one() {
assert!(matches_component("a.png", "?.png"));
assert!(!matches_component("ab.png", "?.png"));
assert!(!matches_component(".png", "?.png"));
}
#[test]
fn a_trailing_star_may_match_nothing() {
assert!(matches_component("photo", "photo*"));
assert!(matches_component("photo", "photo**"));
assert!(!matches_component("photo", "photo?"));
}
#[test]
fn json_strings_are_escaped() {
assert_eq!(escape(r"photos\a.png"), r"photos\\a.png");
assert_eq!(escape("a\"b"), "a\\\"b");
assert_eq!(escape("line\nbreak"), "line\\nbreak");
assert_eq!(escape("bell\u{7}"), "bell\\u0007");
assert_eq!(escape("plain/path.png"), "plain/path.png");
}
#[test]
fn an_empty_scan_renders_an_empty_document() {
let rendered = render_json(&[], &[]);
assert!(rendered.contains("\"valid\": []"), "got: {rendered}");
assert!(rendered.contains("\"invalid\": []"), "got: {rendered}");
assert!(rendered.contains("\"scanned\": 0"), "got: {rendered}");
}
#[test]
fn both_verdicts_reach_the_document() {
let entries = vec![
Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2400),
capacity_bytes: 12_698,
},
},
Entry {
path: PathBuf::from("bad.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
},
];
let rendered = render_json(&entries, &[]);
assert!(
rendered.contains("\"path\": \"good.png\""),
"got: {rendered}"
);
assert!(
rendered.contains("\"dimensions\": [2000, 2400]"),
"got: {rendered}"
);
assert!(
rendered.contains("\"capacity_kb\": 12.4"),
"got: {rendered}"
);
assert!(
rendered.contains("\"reason\": \"JpegDetected\""),
"got: {rendered}"
);
assert!(rendered.contains("\"scanned\": 2"), "got: {rendered}");
assert!(rendered.contains("\"valid\": 1"), "got: {rendered}");
assert!(rendered.contains("\"invalid\": 1"), "got: {rendered}");
}
const MARK_SETS: [Marks; 2] = [("\u{2713}", "\u{2717}"), ("[OK]", "[--]")];
fn table_of(rendered: &str) -> Vec<&str> {
rendered
.lines()
.skip_while(|line| !line.starts_with(" "))
.take_while(|line| !line.trim().is_empty())
.collect()
}
fn offset_of(line: &str, needle: &str) -> usize {
match line.find(needle) {
Some(byte) => line[..byte].chars().count(),
None => panic!("{needle:?} is not in {line:?}"),
}
}
#[test]
fn the_listing_reports_every_entry() {
let entries = vec![
Entry {
path: PathBuf::from("photos/good.png"),
verdict: Verdict::Usable {
dimensions: (3840, 2160),
capacity_bytes: 76_000,
},
},
Entry {
path: PathBuf::from("photos/small.png"),
verdict: Verdict::Unusable {
reason: "ImageTooSmall",
dimensions: Some((400, 400)),
},
},
];
let rendered = render_listing("./photos", &entries, &[], true);
assert!(rendered.contains("Scanning ./photos"), "got: {rendered}");
assert!(rendered.contains("photos/good.png"), "got: {rendered}");
assert!(rendered.contains("3840x2160"), "got: {rendered}");
assert!(rendered.contains("~74.2 KB"), "got: {rendered}");
assert!(rendered.contains("photos/small.png"), "got: {rendered}");
assert!(rendered.contains("400x400"), "got: {rendered}");
assert!(rendered.contains("ImageTooSmall"), "got: {rendered}");
assert!(
rendered.contains("Summary: 1 valid, 1 invalid (2 scanned)"),
"got: {rendered}"
);
}
#[test]
fn one_long_path_does_not_break_the_alignment() {
let entries = vec![
Entry {
path: PathBuf::from("./Alberto_Forest.png"),
verdict: Verdict::Usable {
dimensions: (3264, 2448),
capacity_bytes: 17_000,
},
},
Entry {
path: PathBuf::from("./Alcala_Central_Electrica_de_la_Sierra.png"),
verdict: Verdict::Usable {
dimensions: (2592, 4608),
capacity_bytes: 25_400,
},
},
Entry {
path: PathBuf::from("./Disney.png"),
verdict: Verdict::Usable {
dimensions: (6526, 3679),
capacity_bytes: 51_000,
},
},
];
let values = [
("./Alberto_Forest.png", "3264x2448", "~16.6 KB"),
(
"./Alcala_Central_Electrica_de_la_Sierra.png",
"2592x4608",
"~24.8 KB",
),
("./Disney.png", "6526x3679", "~49.8 KB"),
];
for marks in MARK_SETS {
let rendered = render_listing_with(marks, ".", &entries, &[], false);
let table = table_of(&rendered);
assert_eq!(table.len(), 4, "a heading and three rows: {rendered}");
let columns = (
offset_of(table[0], PATH_HEADING),
offset_of(table[0], SIZE_HEADING),
offset_of(table[0], CAPACITY_HEADING),
);
for (line, (path, size, capacity)) in table[1..].iter().zip(values) {
assert_eq!(
(
offset_of(line, path),
offset_of(line, size),
offset_of(line, capacity)
),
columns,
"{marks:?} left the table ragged: {rendered}"
);
}
}
}
#[test]
fn a_rejected_row_has_the_same_columns_as_a_usable_one() {
let entries = vec![
Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (3000, 3000),
capacity_bytes: 22_000,
},
},
Entry {
path: PathBuf::from("small.png"),
verdict: Verdict::Unusable {
reason: "ImageTooSmall",
dimensions: Some((400, 400)),
},
},
Entry {
path: PathBuf::from("not-an-image.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
},
];
for marks in MARK_SETS {
let rendered = render_listing_with(marks, ".", &entries, &[], true);
let table = table_of(&rendered);
assert_eq!(table.len(), 4, "a heading and three rows: {rendered}");
let path_column = offset_of(table[0], PATH_HEADING);
let size_column = offset_of(table[0], SIZE_HEADING);
let detail_column = offset_of(table[0], CAPACITY_AND_REASON_HEADING);
assert_eq!(
(
offset_of(table[1], "good.png"),
offset_of(table[1], "3000x3000"),
offset_of(table[1], "~21.5 KB")
),
(path_column, size_column, detail_column),
"{marks:?}: {rendered}"
);
assert_eq!(
(
offset_of(table[2], "small.png"),
offset_of(table[2], "400x400"),
offset_of(table[2], "ImageTooSmall")
),
(path_column, size_column, detail_column),
"{marks:?}: {rendered}"
);
assert_eq!(
(
offset_of(table[3], "not-an-image.png"),
offset_of(table[3], "NotPng")
),
(path_column, detail_column),
"{marks:?}: {rendered}"
);
}
}
#[test]
fn the_full_listing_groups_the_usable_containers_first() {
let entries = vec![
Entry {
path: PathBuf::from("a-rejected.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
},
Entry {
path: PathBuf::from("b-usable.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
},
Entry {
path: PathBuf::from("c-rejected.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
},
Entry {
path: PathBuf::from("d-usable.png"),
verdict: Verdict::Usable {
dimensions: (2400, 2400),
capacity_bytes: 12_000,
},
},
];
let rendered = render_listing(".", &entries, &[], true);
let order: Vec<&str> = table_of(&rendered)
.into_iter()
.skip(1)
.filter_map(|line| line.split_whitespace().nth(1))
.collect();
assert_eq!(
order,
["b-usable.png", "d-usable.png", "a-rejected.png", "c-rejected.png"],
"got: {rendered}"
);
}
#[test]
fn the_headings_anchor_the_note_about_the_capacity() {
let usable = || Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
};
let rejected = || Entry {
path: PathBuf::from("bad.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
};
let note = "* Estimated payload capacity";
let only_usable = render_listing(".", &[usable()], &[], false);
assert!(only_usable.contains(CAPACITY_HEADING), "got: {only_usable}");
assert!(only_usable.contains(note), "got: {only_usable}");
let both = render_listing(".", &[usable(), rejected()], &[], true);
assert!(both.contains(CAPACITY_AND_REASON_HEADING), "got: {both}");
assert!(both.contains(note), "got: {both}");
let only_rejected = render_listing(".", &[rejected()], &[], true);
assert!(only_rejected.contains(REASON_HEADING), "got: {only_rejected}");
assert!(!only_rejected.contains('*'), "got: {only_rejected}");
assert!(only_rejected.contains(PATH_HEADING), "got: {only_rejected}");
}
#[test]
fn an_empty_table_has_no_headings() {
let rendered = render_listing(".", &[], &[], true);
assert!(!rendered.contains(PATH_HEADING), "got: {rendered}");
assert!(rendered.contains("0 scanned"), "got: {rendered}");
}
#[test]
fn no_line_is_padded_past_its_last_column() {
let entries = vec![
Entry {
path: PathBuf::from("a-very-long-name-for-a-photograph.png"),
verdict: Verdict::Usable {
dimensions: (3000, 3000),
capacity_bytes: 22_000,
},
},
Entry {
path: PathBuf::from("b.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
},
];
for marks in MARK_SETS {
let rendered = render_listing_with(marks, ".", &entries, &[], true);
for line in rendered.lines() {
assert_eq!(
line.trim_end(),
line,
"{marks:?} padded past the last column: {line:?}"
);
}
}
}
#[test]
fn only_a_scan_that_found_nothing_offers_to_generate_a_container() {
let refused = vec![Entry {
path: PathBuf::from("a.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
}];
let listing = render_listing(".", &refused, &[], true);
assert!(listing.contains("stenoxide generate"), "got: {listing}");
assert!(
listing.contains("does not hide that it was generated"),
"the offer must state what the mode does not do: {listing}"
);
let mut mixed = refused;
mixed.push(Entry {
path: PathBuf::from("b.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
});
assert!(!render_listing(".", &mixed, &[], true).contains("stenoxide generate"));
assert!(!render_listing(".", &[], &[], true).contains("stenoxide generate"));
}
#[test]
fn a_scan_that_found_something_names_the_next_command() {
let entries = vec![Entry {
path: PathBuf::from("holiday.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
}];
let listing = render_listing(".", &entries, &[], false);
assert!(listing.contains("stenoxide embed"), "got: {listing}");
assert!(
!listing.contains("--input holiday.png"),
"the line must not recommend one of the listed files: {listing}"
);
}
#[test]
fn the_two_pointers_never_appear_together() {
let usable = || Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
};
let rejected = || Entry {
path: PathBuf::from("bad.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
};
let found = render_listing(".", &[usable(), rejected()], &[], true);
assert!(found.contains("stenoxide embed"), "got: {found}");
assert!(!found.contains("stenoxide generate"), "got: {found}");
let empty_handed = render_listing(".", &[rejected()], &[], true);
assert!(
empty_handed.contains("stenoxide generate"),
"got: {empty_handed}"
);
assert!(
!empty_handed.contains("stenoxide embed"),
"got: {empty_handed}"
);
let nothing = render_listing(".", &[], &[], true);
assert!(!nothing.contains("stenoxide embed"), "got: {nothing}");
assert!(!nothing.contains("stenoxide generate"), "got: {nothing}");
}
#[test]
fn a_short_listing_says_how_to_see_the_rest() {
let entries = vec![Entry {
path: PathBuf::from("a.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
}];
assert!(render_listing(".", &entries, &[], false).contains("--all"));
assert!(!render_listing(".", &entries, &[], true).contains("--all"));
}
fn container(path: &str) -> Entry {
Entry {
path: PathBuf::from(path),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
}
}
#[test]
fn a_group_needs_more_than_one_file_to_exist() {
let groups = shared_groups([
("a", "first.png".to_owned()),
("b", "alone.png".to_owned()),
("a", "second.png".to_owned()),
("c", "third.png".to_owned()),
("c", "fourth.png".to_owned()),
("a", "fifth.png".to_owned()),
]);
assert_eq!(
groups,
vec![
vec![
"first.png".to_owned(),
"second.png".to_owned(),
"fifth.png".to_owned()
],
vec!["third.png".to_owned(), "fourth.png".to_owned()],
]
);
assert!(shared_groups([("a", "only.png".to_owned())]).is_empty());
assert!(shared_groups(Vec::<(&str, String)>::new()).is_empty());
}
#[test]
fn the_listing_warns_about_containers_that_share_a_salt() {
let entries = vec![
container("burst-01.png"),
container("burst-02.png"),
container("unrelated.png"),
];
let collisions = vec![vec!["burst-01.png".to_owned(), "burst-02.png".to_owned()]];
let warned = render_listing(".", &entries, &collisions, false);
assert!(warned.contains(SHARED_SALT_HEADING), "got: {warned}");
assert!(warned.contains("burst-01.png"), "got: {warned}");
assert!(warned.contains("burst-02.png"), "got: {warned}");
assert!(
warned.contains("under one password encrypts both under the"),
"the warning must say what the mistake costs: {warned}"
);
assert!(
warned.contains("Give each image its own password"),
"the warning must say what to do instead: {warned}"
);
assert!(
warned.contains("Summary: 3 valid, 0 invalid (3 scanned)"),
"a collision must not make a container invalid: {warned}"
);
let summary = warned.find("Summary:").unwrap_or(usize::MAX);
let warning = warned.find(SHARED_SALT_HEADING).unwrap_or(0);
let pointer = warned.find("stenoxide embed").unwrap_or(0);
assert!(summary < warning && warning < pointer, "got: {warned}");
}
#[test]
fn a_scan_with_no_collision_prints_what_it_always_did() {
let entries = vec![container("a.png"), container("b.png")];
let collisions = vec![vec!["a.png".to_owned(), "b.png".to_owned()]];
for all in [false, true] {
let quiet = render_listing(".", &entries, &[], all);
let warned = render_listing(".", &entries, &collisions, all);
assert!(!quiet.contains("WARNING"), "got: {quiet}");
assert_eq!(
quiet,
warned.replace(&render_collisions(&collisions), ""),
"the warning changed more of the listing than its own block"
);
}
}
#[test]
fn the_document_grows_the_groups_without_moving_anything() {
let entries = vec![container("a.png"), container(r"photos\b.png")];
let collisions = vec![vec!["a.png".to_owned(), r"photos\b.png".to_owned()]];
let quiet = render_json(&entries, &[]);
let warned = render_json(&entries, &collisions);
assert!(
!quiet.contains("salt_collisions"),
"a scan with no collision must produce the document it always did: {quiet}"
);
let positions = |document: &str| {
["\"valid\"", "\"invalid\"", "\"summary\"", "\"scanned\""]
.map(|key| document.find(key))
};
assert_eq!(
positions(&quiet),
positions(&warned),
"an existing key moved: {warned}"
);
let groups = warned.find("\"salt_collisions\"").unwrap_or(0);
assert!(
groups > warned.find("\"summary\"").unwrap_or(usize::MAX),
"the new key must come after the ones that were already there: {warned}"
);
assert!(warned.contains("\"a.png\""), "got: {warned}");
assert!(warned.contains(r#""photos\\b.png""#), "got: {warned}");
}
}