1use 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
20const EM_DASH: char = '\u{2014}';
23
24pub fn quiet_on_closed_pipe() {
31 #[cfg(unix)]
32 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
46pub 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
55pub 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
72pub 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,
83 Version,
84}
85
86impl Args {
87 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 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 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
203pub 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 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
434pub 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}