Skip to main content

snitch/
inspect.rs

1//! The structured report. The CLI formats it, the MCP server serialises it, and the data lives
2//! here so neither of them has to reimplement it.
3
4use std::path::Path;
5
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::{c2pa, exif, png};
10
11#[derive(Debug, Clone, Serialize)]
12pub struct Report {
13    pub file: String,
14    pub path: String,
15    pub bytes: u64,
16    pub mime_type: Option<String>,
17    /// Held as ordered pairs because the CLI prints them in the order the field table declares,
18    /// and serialised as JSON objects because that is the shape the Python emitted and other
19    /// things already parse.
20    #[serde(serialize_with = "as_object")]
21    pub camera: Vec<(String, Value)>,
22    /// Full ExifTool keys, `EXIF:GPSLatitude` and friends. The renderer trims the group prefix;
23    /// the data keeps it, because that is what the JSON contract says and what a caller can look
24    /// up in the ExifTool tag directory.
25    #[serde(serialize_with = "as_object")]
26    pub gps: Vec<(String, Value)>,
27    #[serde(serialize_with = "as_object")]
28    pub credit: Vec<(String, Value)>,
29    pub png_text: Vec<png::TextChunk>,
30    pub generator_keywords: Vec<String>,
31    pub c2pa: Option<Value>,
32    pub c2pa_status: c2pa::Status,
33    pub c2pa_error: String,
34    /// "generative", "camera", or nothing.
35    pub ai: Option<String>,
36    /// Which signal answered: "c2pa" or "png-text-chunk". Never absent when `ai` is present.
37    pub ai_source: Option<String>,
38    pub has_any_credit: bool,
39}
40
41fn as_object<S: serde::Serializer>(pairs: &[(String, Value)], s: S) -> Result<S::Ok, S::Error> {
42    use serde::ser::SerializeMap;
43    let mut map = s.serialize_map(Some(pairs.len()))?;
44    for (k, v) in pairs {
45        map.serialize_entry(k, v)?;
46    }
47    map.end()
48}
49
50pub fn inspect(path: &Path, tool: Option<&str>) -> Result<Report, String> {
51    let meta = exif::read_metadata(path)?;
52    let mut report = c2pa::read_report(path, tool);
53    c2pa::refine_with_metadata(&mut report, &meta);
54
55    // The raw value is kept, not a rendered string: a latitude is a number in the Python's JSON
56    // and a consumer that starts parsing strings back into floats is a contract this broke.
57    let gps: Vec<(String, Value)> = exif::GPS_FIELDS
58        .iter()
59        .filter_map(|key| meta.get(*key).map(|v| ((*key).to_string(), v.clone())))
60        .collect();
61    let credit = exif::ordered_group(&meta, &exif::CREDIT_FIELDS);
62    let camera = exif::ordered_group(&meta, &exif::CAMERA_FIELDS);
63
64    let png_text = png::read_text(path);
65    let mut generator_keywords: Vec<String> = png_text
66        .iter()
67        .filter(|c| c.is_generator())
68        .map(|c| c.keyword.clone())
69        .collect();
70    generator_keywords.sort();
71    generator_keywords.dedup();
72
73    // A C2PA claim is cryptographically bound to the asset. A text chunk is plain text that anyone
74    // can write and that survives being copied around. Both are worth reporting, they are not
75    // worth the same, and the caller is told which one answered so a chunk can never pass for a
76    // credential.
77    let mut ai: Option<String> = None;
78    let mut ai_source: Option<String> = None;
79    if let Some(source) = c2pa::digital_source(report.manifest.as_ref()) {
80        ai = Some(source.to_string());
81        ai_source = Some("c2pa".into());
82    }
83    if ai.is_none() && !generator_keywords.is_empty() {
84        ai = Some("generative".into());
85        ai_source = Some("png-text-chunk".into());
86    }
87
88    let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
89    Ok(Report {
90        file: path
91            .file_name()
92            .and_then(|s| s.to_str())
93            .unwrap_or_default()
94            .to_string(),
95        path: absolute.display().to_string(),
96        bytes: std::fs::metadata(path).map(|m| m.len()).unwrap_or(0),
97        mime_type: meta.get("File:MIMEType").map(exif::render),
98        camera,
99        gps,
100        credit: credit.clone(),
101        png_text,
102        generator_keywords,
103        c2pa: report.manifest,
104        c2pa_status: report.status,
105        c2pa_error: report.error,
106        ai,
107        ai_source,
108        has_any_credit: !credit.is_empty(),
109    })
110}
111
112/// Presence, integrity and signer trust as three separate answers, because they are three facts.
113#[derive(Debug, Clone, Serialize)]
114pub struct C2paSummary {
115    pub manifest: String,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub validation_state: Option<String>,
118    pub integrity: String,
119    pub signer_trusted: String,
120    pub signer: Option<String>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub title: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub validation_status: Option<Value>,
125    pub means: String,
126}
127
128pub fn c2pa_summary(report: &Report) -> C2paSummary {
129    if report.c2pa_status != c2pa::Status::Present {
130        let means = match report.c2pa_status {
131            c2pa::Status::Absent => {
132                "No content credential in this file. That is not evidence about how the image was \
133                 made: most files have never had one."
134                    .to_string()
135            }
136            c2pa::Status::DetectedUnverified => {
137                "A credential is in this file but c2patool is not installed, so it could not be \
138                 validated. Install c2patool to read it."
139                    .to_string()
140            }
141            c2pa::Status::Unavailable => {
142                "c2patool is not installed, so no credential could be read.".to_string()
143            }
144            _ => {
145                if report.c2pa_error.is_empty() {
146                    "the validator failed".to_string()
147                } else {
148                    report.c2pa_error.clone()
149                }
150            }
151        };
152        return C2paSummary {
153            manifest: report.c2pa_status.as_str().to_string(),
154            validation_state: None,
155            integrity: "unknown".into(),
156            signer_trusted: "unknown".into(),
157            signer: None,
158            title: None,
159            validation_status: None,
160            means,
161        };
162    }
163    let manifest = report.c2pa.as_ref();
164    let state = c2pa::validation_state(manifest);
165    let altered = c2pa::asset_altered(manifest);
166    let untrusted = c2pa::identity_untrusted(manifest);
167    let means = if altered {
168        "The pixels are not the ones that were signed: this image was altered after signing."
169            .to_string()
170    } else {
171        let trust = if untrusted {
172            "The signing certificate is not on the validator trust list, so this says nothing \
173             about who signed it."
174        } else {
175            "The signer is on the validator trust list."
176        };
177        format!("The asset binding holds, so the image matches its credential. {trust}")
178    };
179    C2paSummary {
180        manifest: "present".into(),
181        integrity: if altered {
182            "altered".into()
183        } else if state == "Valid" {
184            "valid".into()
185        } else {
186            "unknown".into()
187        },
188        signer_trusted: if untrusted {
189            "no".into()
190        } else if state == "Valid" {
191            "yes".into()
192        } else {
193            "unknown".into()
194        },
195        validation_state: Some(state),
196        signer: c2pa::signer(manifest),
197        title: c2pa::title(manifest),
198        validation_status: manifest.and_then(|m| m.get("validation_status")).cloned(),
199        means,
200    }
201}