Skip to main content

snitch/
cli.rs

1//! Shared command-line plumbing: colour, argument parsing and the renderers.
2//!
3//! NO CLI FRAMEWORK. Argument parsing here is a few dozen lines over `std::env::args_os`, which is
4//! the house rule and is also the only way to get the exact behaviour these tools need: `--` ends
5//! options so a file genuinely named `--force.jpg` is a file, and an unknown flag is an error
6//! rather than a positional.
7
8use std::io::IsTerminal;
9use std::path::{Path, PathBuf};
10
11use crate::{c2pa, inspect, survival};
12
13pub const BOLD: &str = "\x1b[1m";
14pub const DIM: &str = "\x1b[2m";
15pub const RED: &str = "\x1b[31m";
16pub const GRN: &str = "\x1b[32m";
17pub const YEL: &str = "\x1b[33m";
18pub const OFF: &str = "\x1b[0m";
19
20/// The separator the sourced-notes view puts between a citation's title and its URL. Held as a
21/// constant so no source file in this repository has to contain the character itself.
22const EM_DASH: char = '\u{2014}';
23
24/// Let a closed pipe end the process quietly, the way every other Unix command does.
25///
26/// The Rust runtime sets SIGPIPE to SIG_IGN, so a write to a closed pipe returns an error and
27/// `println!` turns that into a panic. `snitch --platforms | head -4` then prints four lines and a
28/// panic message, where the Python printed four lines and exited 0. Restoring the default
29/// disposition is the whole fix.
30pub fn quiet_on_closed_pipe() {
31    #[cfg(unix)]
32    // SAFETY: setting a signal disposition to SIG_DFL before any thread is spawned. This is the
33    // documented way to undo the runtime's SIG_IGN and is what the standard tools do.
34    unsafe {
35        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
36    }
37}
38
39pub fn colour_enabled() -> bool {
40    if std::env::var_os("NO_COLOR").is_some() {
41        return false;
42    }
43    std::io::stdout().is_terminal()
44}
45
46/// Wrap in a colour, or do not, depending on where output is going.
47pub fn c(text: &str, colour: &str) -> String {
48    if colour_enabled() {
49        format!("{colour}{text}{OFF}")
50    } else {
51        text.to_string()
52    }
53}
54
55/// A path as it should be typed back at a shell. The `--` matters: without it a file named
56/// `--force.jpg` reads as a flag, and the suggestion this tool prints would not work.
57pub fn command_path(path: &Path) -> String {
58    format!("-- {}", shell_quote(&path.display().to_string()))
59}
60
61pub fn shell_quote(s: &str) -> String {
62    let safe = !s.is_empty()
63        && s.chars()
64            .all(|c| c.is_ascii_alphanumeric() || "@%+=:,./-_".contains(c));
65    if safe {
66        s.to_string()
67    } else {
68        format!("'{}'", s.replace('\'', r"'\''"))
69    }
70}
71
72/// A parsed command line: flags that were present, options with values, and the positionals.
73pub struct Args {
74    flags: Vec<String>,
75    options: Vec<(String, String)>,
76    pub files: Vec<PathBuf>,
77}
78
79pub enum Parsed {
80    Args(Args),
81    /// `--help` or `--version` was asked for; the caller prints and exits 0.
82    Help,
83    Version,
84}
85
86impl Args {
87    /// `flag_names` take no value, `option_names` take the next argument or an `=` suffix.
88    pub fn parse(
89        argv: Vec<String>,
90        flag_names: &[&str],
91        option_names: &[&str],
92    ) -> Result<Parsed, String> {
93        let mut args = Args {
94            flags: Vec::new(),
95            options: Vec::new(),
96            files: Vec::new(),
97        };
98        let mut iter = argv.into_iter();
99        let mut only_files = false;
100        while let Some(item) = iter.next() {
101            if only_files {
102                args.files.push(PathBuf::from(item));
103                continue;
104            }
105            if item == "--" {
106                only_files = true;
107                continue;
108            }
109            if item == "--help" || item == "-h" {
110                return Ok(Parsed::Help);
111            }
112            if item == "--version" {
113                return Ok(Parsed::Version);
114            }
115            if let Some(rest) = item.strip_prefix("--") {
116                let (name, inline) = match rest.split_once('=') {
117                    Some((n, v)) => (n.to_string(), Some(v.to_string())),
118                    None => (rest.to_string(), None),
119                };
120                if flag_names.contains(&name.as_str()) {
121                    if inline.is_some() {
122                        return Err(format!("--{name} does not take a value"));
123                    }
124                    args.flags.push(name);
125                    continue;
126                }
127                if option_names.contains(&name.as_str()) {
128                    let value = match inline {
129                        Some(v) => v,
130                        None => iter
131                            .next()
132                            .ok_or_else(|| format!("--{name} needs a value"))?,
133                    };
134                    args.options.push((name, value));
135                    continue;
136                }
137                return Err(format!("unknown option --{name}"));
138            }
139            if let Some(short) = item
140                .strip_prefix('-')
141                .filter(|s| !s.is_empty() && *s != "-")
142            {
143                // A short option is the same name with one dash: `-o` is `--out`'s alias because
144                // "o" is in the option list. Anything else with a leading dash is a mistake, not a
145                // filename, and saying so beats silently treating `-0` as a file.
146                let (name, inline) = match short.split_once('=') {
147                    Some((n, v)) => (n.to_string(), Some(v.to_string())),
148                    None => (short.to_string(), None),
149                };
150                if flag_names.contains(&name.as_str()) {
151                    args.flags.push(name);
152                    continue;
153                }
154                if option_names.contains(&name.as_str()) {
155                    let value = match inline {
156                        Some(v) => v,
157                        None => iter
158                            .next()
159                            .ok_or_else(|| format!("-{name} needs a value"))?,
160                    };
161                    args.options.push((name, value));
162                    continue;
163                }
164                return Err(format!("unknown option {item}"));
165            }
166            args.files.push(PathBuf::from(item));
167        }
168        Ok(Parsed::Args(args))
169    }
170
171    pub fn has(&self, name: &str) -> bool {
172        self.flags.iter().any(|f| f == name)
173    }
174
175    pub fn get(&self, name: &str) -> Option<&str> {
176        self.options
177            .iter()
178            .rev()
179            .find(|(k, _)| k == name)
180            .map(|(_, v)| v.as_str())
181    }
182
183    /// Every value given for a repeatable option, in the order they were typed.
184    pub fn all(&self, name: &str) -> Vec<String> {
185        self.options
186            .iter()
187            .filter(|(k, _)| k == name)
188            .map(|(_, v)| v.clone())
189            .collect()
190    }
191
192    pub fn parse_number<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, String> {
193        match self.get(name) {
194            None => Ok(None),
195            Some(raw) => raw
196                .parse::<T>()
197                .map(Some)
198                .map_err(|_| format!("--{name} needs a number, got {raw:?}")),
199        }
200    }
201}
202
203// ----------------------------------------------------------------------------------------------
204// renderers
205// ----------------------------------------------------------------------------------------------
206
207/// Print one inspection report the way `snitch` prints it. Returns true when something went wrong
208/// enough for a non-zero exit, which today means only a failed C2PA check.
209pub fn print_report(report: &inspect::Report, path: &Path) -> bool {
210    let mut failed = false;
211    println!(
212        "\n{}  {} bytes",
213        c(&report.file, BOLD),
214        thousands(report.bytes)
215    );
216
217    if !report.gps.is_empty() {
218        println!("{}", c("  LOCATION IS IN THIS FILE", RED));
219        for (key, value) in &report.gps {
220            // The data carries `EXIF:GPSLatitude`; a person reading a terminal wants `GPSLatitude`.
221            let short = key.rsplit(':').next().unwrap_or(key);
222            println!("    {short:<16} {}", crate::exif::render(value));
223        }
224        println!(
225            "{}",
226            c(
227                "    Anyone who downloads this can see where it was taken.",
228                RED
229            )
230        );
231        println!("    Make it stop:  no-comment {}", command_path(path));
232    } else {
233        println!("{}", c("  no location data", GRN));
234    }
235
236    if !report.camera.is_empty() {
237        println!("  camera");
238        for (key, value) in &report.camera {
239            println!("    {key:<16} {}", crate::exif::render(value));
240        }
241    }
242
243    if !report.credit.is_empty() {
244        println!("{}", c("  credit", GRN));
245        for (key, value) in &report.credit {
246            println!(
247                "    {key:<16} {}",
248                ellipsis(&crate::exif::render(value), 90)
249            );
250        }
251    } else {
252        println!("{}", c("  NO CREDIT AT ALL", YEL));
253        println!("    Nothing in this file says who made it.");
254        println!(
255            "    Fix it:  credit --creator \"Your Name\" {}",
256            command_path(path)
257        );
258    }
259
260    if !report.png_text.is_empty() {
261        println!("{}", c("  TEXT IS EMBEDDED IN THIS PNG", YEL));
262        for chunk in &report.png_text {
263            let text: String = chunk.text.split_whitespace().collect::<Vec<_>>().join(" ");
264            let keyword: String = chunk.keyword.chars().take(16).collect();
265            println!("    {keyword:<16} {}", ellipsis(&text, 84));
266        }
267        if report.ai_source.as_deref() == Some("png-text-chunk") {
268            println!(
269                "{}",
270                c(
271                    "    A GENERATOR WROTE THIS. It names the tool, and usually the whole prompt.",
272                    YEL
273                )
274            );
275            println!(
276                "    This is plain text, not a signed credential. It proves nothing on its own."
277            );
278        }
279        println!("    Take it out:  no-comment {}", command_path(path));
280    }
281
282    match (&report.c2pa, report.c2pa_status) {
283        (Some(manifest), _) => {
284            let state = c2pa::validation_state(Some(manifest));
285            println!("  C2PA Content Credential  [{state}]");
286            println!(
287                "    title            {}",
288                c2pa::title(Some(manifest)).unwrap_or("?".into())
289            );
290            println!(
291                "    certificate issuer {}",
292                c2pa::signer(Some(manifest)).unwrap_or("?".into())
293            );
294            if c2pa::asset_altered(Some(manifest)) {
295                println!(
296                    "{}",
297                    c(
298                        "    ALTERED AFTER SIGNING: these pixels are not the ones that were signed",
299                        RED
300                    )
301                );
302            }
303            if c2pa::identity_untrusted(Some(manifest)) {
304                println!(
305                    "{}",
306                    c(
307                        "    SIGNER IDENTITY UNTRUSTED (certificate not on validator trust list)",
308                        YEL
309                    )
310                );
311            }
312            match (report.ai.as_deref(), report.ai_source.as_deref()) {
313                (Some("generative"), Some("c2pa")) => println!(
314                    "{}",
315                    c("    THIS SAYS IT WAS MADE BY A GENERATIVE MODEL", YEL)
316                ),
317                (Some("camera"), _) => {
318                    println!("{}", c("    asserts a camera made this, not a model", GRN))
319                }
320                _ => {}
321            }
322        }
323        (None, c2pa::Status::Absent) => println!("  no C2PA Content Credential"),
324        (None, c2pa::Status::DetectedUnverified) => {
325            println!(
326                "{}",
327                c(
328                    "  C2PA Content Credential detected; validation unavailable",
329                    YEL
330                )
331            );
332            println!("    Install c2patool to read and validate it.");
333        }
334        (None, c2pa::Status::Unavailable) => println!(
335            "{}",
336            c("  C2PA status unavailable: c2patool is not installed", YEL)
337        ),
338        (None, _) => {
339            println!(
340                "{}",
341                c(&format!("  C2PA check failed: {}", report.c2pa_error), RED)
342            );
343            failed = true;
344        }
345    }
346    failed
347}
348
349pub fn print_platforms(notes: bool, check: bool) {
350    let table = survival::Table::load();
351    println!(
352        "\n{}   {}\n",
353        c("Evidence on platform metadata handling", BOLD),
354        c(&format!("researched {}", table.researched), DIM)
355    );
356    println!("  D documented by platform   C independently corroborated   ? unverified\n");
357    let platforms = table.ordered_platforms();
358    let width = platforms
359        .iter()
360        .map(|(n, _)| n.chars().count())
361        .max()
362        .unwrap_or(8)
363        + 2;
364    let head: String = table
365        .layers
366        .iter()
367        .map(|l| format!("{:<26}", l.label))
368        .collect();
369    println!("  {:<width$}{head}", "", width = width);
370    for (name, layers) in &platforms {
371        let mut row = format!("  {name:<width$}", width = width);
372        for layer in &table.layers {
373            let cell = layers.get(&layer.key);
374            let text = cell
375                .map(|cell| table.display_cell(cell))
376                .unwrap_or_default();
377            let colour = match cell.map(|cell| (cell.evidence.as_str(), cell.verdict.as_str())) {
378                Some(("inference", _)) => DIM,
379                Some((_, "keeps")) | Some((_, "reads")) => GRN,
380                Some((_, "strips")) => RED,
381                Some((_, "partial")) => YEL,
382                _ => DIM,
383            };
384            row.push_str(&c(&format!("{text:<26}"), colour));
385        }
386        println!("{row}");
387    }
388    println!("\n  {}\n", c(&table.advice, BOLD));
389    if notes {
390        for (name, layers) in &platforms {
391            println!("  {}", c(name, BOLD));
392            for layer in &table.layers {
393                let Some(cell) = layers.get(&layer.key) else {
394                    continue;
395                };
396                if cell.note.is_empty() {
397                    continue;
398                }
399                println!("    {:<26} [{}] {}", layer.label, cell.evidence, cell.note);
400                for source in &cell.sources {
401                    println!("      source: {} {EM_DASH} {}", source.title, source.url);
402                }
403            }
404            println!();
405        }
406    } else {
407        println!("  {}", c("--notes for the detail on every cell", DIM));
408    }
409    if check {
410        println!("\n{}", table.how_to_verify);
411    }
412}
413
414fn ellipsis(text: &str, limit: usize) -> String {
415    if text.chars().count() <= limit {
416        return text.to_string();
417    }
418    let kept: String = text.chars().take(limit.saturating_sub(1)).collect();
419    format!("{kept}\u{2026}")
420}
421
422pub fn thousands(n: u64) -> String {
423    let digits = n.to_string();
424    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
425    for (i, ch) in digits.chars().enumerate() {
426        if i > 0 && (digits.len() - i) % 3 == 0 {
427            out.push(',');
428        }
429        out.push(ch);
430    }
431    out
432}
433
434/// Walk the file list, refusing anything that is not an ordinary file, and hand each survivor to
435/// `each`. Returns true if anything failed, which becomes the exit status.
436pub fn for_each_file(files: &[PathBuf], mut each: impl FnMut(&Path) -> bool) -> bool {
437    let mut failed = false;
438    for path in files {
439        if !path.exists() {
440            eprintln!("  {}: not found", path.display());
441            failed = true;
442            continue;
443        }
444        if !path.is_file() {
445            eprintln!("  {}: not a regular file", path.display());
446            failed = true;
447            continue;
448        }
449        failed |= each(path);
450    }
451    failed
452}