use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use stenoxide_core::image_io::buffer::CoverSource;
use stenoxide_core::image_io::validate::{load_and_validate, probe_geometry};
use crate::progress::Progress;
use crate::{container_capacity, terminal_renders_unicode, ScanArgs};
const PNG_EXTENSION: &str = "png";
const BYTES_PER_KIB: f64 = 1024.0;
const PATH_COLUMN: usize = 28;
const SIZE_COLUMN: usize = 11;
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";
enum Verdict {
Usable {
dimensions: (u32, u32),
capacity_bytes: usize,
},
Unusable {
reason: &'static str,
dimensions: Option<(u32, u32)>,
},
}
struct Entry {
path: PathBuf,
verdict: Verdict,
}
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 entries: Vec<Entry> = 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 report = if args.json {
render_json(&entries)
} else {
render_listing(&args.path, &entries, 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) -> Entry {
let is_png = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase() == PNG_EXTENSION)
.unwrap_or(false);
if !is_png {
return Entry {
path,
verdict: Verdict::Unusable {
reason: "UnsupportedFormat",
dimensions: None,
},
};
}
let verdict = match load_and_validate(&path) {
Ok(image) => {
let dimensions = image.dimensions();
match container_capacity(&image) {
Some(capacity_bytes) => Verdict::Usable {
dimensions,
capacity_bytes,
},
None => Verdict::Unusable {
reason: "InsufficientTexture",
dimensions: Some(dimensions),
},
}
}
Err(error) => {
let (reason, dimensions) = describe(&error);
Verdict::Unusable { reason, dimensions }
}
};
Entry { path, verdict }
}
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),
}
}
fn render_listing(argument: &str, entries: &[Entry], all: bool) -> String {
let (usable_mark, unusable_mark) = if terminal_renders_unicode() {
("\u{2713}", "\u{2717}")
} else {
("[OK]", "[--]")
};
let mut report = String::new();
let _ = writeln!(report, "Scanning {argument} ...\n");
let mut usable = 0usize;
let mut unusable = 0usize;
for entry in entries {
let path = entry.path.display().to_string();
match &entry.verdict {
Verdict::Usable {
dimensions: (width, height),
capacity_bytes,
} => {
usable += 1;
let size = format!("{width}x{height}");
let capacity = *capacity_bytes as f64 / BYTES_PER_KIB;
let _ = writeln!(
report,
" {usable_mark} {path:<PATH_COLUMN$} {size:<SIZE_COLUMN$} ~{capacity:.1} KB payload"
);
}
Verdict::Unusable { reason, dimensions } => {
unusable += 1;
if !all {
continue;
}
let size = dimensions
.map(|(width, height)| format!(" {width}x{height}"))
.unwrap_or_default();
let _ = writeln!(
report,
" {unusable_mark} {path:<PATH_COLUMN$} {reason}{size}"
);
}
}
}
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.");
}
if usable == 0 && scanned > 0 {
let _ = write!(report, "{NOTHING_USABLE_HINT}");
}
report
}
fn render_json(entries: &[Entry]) -> 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"))
}
};
format!(
"{{\n \"valid\": {},\n \"invalid\": {},\n \"summary\": {{\n \"scanned\": {},\n \"valid\": {},\n \"invalid\": {}\n }}\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 {
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}");
}
#[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 payload"), "got: {rendered}");
assert!(
rendered.contains("ImageTooSmall 400x400"),
"got: {rendered}"
);
assert!(
rendered.contains("Summary: 1 valid, 1 invalid (2 scanned)"),
"got: {rendered}"
);
}
#[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_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"));
}
}