Skip to main content

zenmeta/
audit.rs

1//! Explicit metadata inspection and structural diffing.
2//!
3//! Audits do not filter or certify privacy. Unknown/opaque content is reported,
4//! never interpreted as harmless. EXIF entries use the borrowing iterator;
5//! XML is optional (`xmp` feature). No image pixels are touched.
6use alloc::{
7    collections::BTreeMap,
8    format,
9    string::{String, ToString},
10    vec::Vec,
11};
12use zencodec::exif::{ByteOrder, Exif};
13
14/// An inspected field. Values use escaped text or typed numeric/hex notation.
15#[derive(Clone, Debug, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct Entry {
18    path: String,
19    kind: String,
20    value: String,
21}
22impl Entry {
23    /// Directory/namespace path, including occurrence index for duplicate tags.
24    pub fn path(&self) -> &str {
25        &self.path
26    }
27    /// Carrier and field type; unknown types retain their numeric identifier.
28    pub fn kind(&self) -> &str {
29        &self.kind
30    }
31    /// Decoded numeric values or losslessly escaped text/hex bytes.
32    pub fn value(&self) -> &str {
33        &self.value
34    }
35}
36/// Inspection limitation or content requiring an explicit retention decision.
37#[derive(Clone, Debug, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum Finding {
40    /// EXIF header/directory could not be parsed.
41    InvalidExif,
42    /// The forgiving EXIF parser can skip malformed entries and does not model
43    /// arbitrary SubIFDs. Retain the source for byte-level forensic comparison.
44    ExifCoverageLimited,
45    /// Opaque vendor data may contain identity/location and rendering parameters.
46    MakerNote { path: String, bytes: usize },
47    /// Input exceeds the inspection budget.
48    Limit,
49    /// XMP XML is invalid or exceeds its parsing budget.
50    InvalidXmp(String),
51}
52/// Read-only metadata snapshot with explicit coverage findings.
53#[derive(Clone, Debug, Default, PartialEq, Eq)]
54pub struct Report {
55    entries: Vec<Entry>,
56    findings: Vec<Finding>,
57}
58impl Report {
59    /// Inspect EXIF/TIFF up to 16 MiB. Preserve unknown tags and duplicate entries.
60    pub fn exif(bytes: &[u8]) -> Self {
61        let mut out = Self::default();
62        if bytes.len() > 16 * 1024 * 1024 {
63            out.findings.push(Finding::Limit);
64            return out;
65        }
66        let Some(exif) = Exif::parse(bytes) else {
67            out.findings.push(Finding::InvalidExif);
68            return out;
69        };
70        out.findings.push(Finding::ExifCoverageLimited);
71        let mut occurrences = BTreeMap::<String, usize>::new();
72        let mut budget = 0usize;
73        for e in exif.entries() {
74            budget = budget
75                .saturating_add(e.value.len().saturating_mul(24))
76                .saturating_add(256);
77            if budget > 16 * 1024 * 1024 {
78                out.findings.push(Finding::Limit);
79                break;
80            }
81            let key = format!("exif/{:?}/0x{:04x}", e.ifd, e.tag);
82            let index = occurrences.entry(key.clone()).or_default();
83            let path = format!("{key}[{index}]");
84            *index += 1;
85            if e.tag == 0x927c {
86                out.findings.push(Finding::MakerNote {
87                    path: path.clone(),
88                    bytes: e.value.len(),
89                });
90            }
91            out.entries.push(Entry {
92                path,
93                kind: format!("TIFF type={} count={}", e.kind, e.count),
94                value: display_value(e.kind, e.value, exif.byte_order()),
95            });
96        }
97        out.entries.sort_by(|a, b| a.path.cmp(&b.path));
98        out
99    }
100    /// Inspect every XML field, including unknown namespaces and annotations.
101    /// Does not canonicalize all RDF equivalents or interpret vendor schemas.
102    #[cfg(feature = "xmp")]
103    pub fn xmp(xml: &str) -> Self {
104        match crate::xmp::Packet::parse(xml).and_then(|p| p.fields()) {
105            Ok(fields) => Self {
106                entries: fields
107                    .into_iter()
108                    .map(|f| Entry {
109                        path: f.path,
110                        kind: "XML".into(),
111                        value: f.value,
112                    })
113                    .collect(),
114                findings: Vec::new(),
115            },
116            Err(e) => Self {
117                entries: Vec::new(),
118                findings: alloc::vec![Finding::InvalidXmp(e.to_string())],
119            },
120        }
121    }
122    /// Sorted fields. Absence does not prove a malformed source contained no data.
123    pub fn entries(&self) -> &[Entry] {
124        &self.entries
125    }
126    /// Coverage and opaque-data findings; inspect these along with field diffs.
127    pub fn findings(&self) -> &[Finding] {
128        &self.findings
129    }
130    /// Compare field paths/types/values. Findings are deliberately separate so an
131    /// empty field diff is never presented as proof of equal parse coverage.
132    pub fn diff<'a>(&'a self, after: &'a Self) -> Vec<Change<'a>> {
133        let before: BTreeMap<_, _> = self.entries.iter().map(|e| (e.path(), e)).collect();
134        let next: BTreeMap<_, _> = after.entries.iter().map(|e| (e.path(), e)).collect();
135        let mut changes = Vec::new();
136        for (path, e) in &before {
137            match next.get(path) {
138                None => changes.push(Change::Removed(e)),
139                Some(n) if e != n => changes.push(Change::Modified {
140                    before: e,
141                    after: n,
142                }),
143                _ => {}
144            }
145        }
146        for (path, e) in &next {
147            if !before.contains_key(path) {
148                changes.push(Change::Added(e));
149            }
150        }
151        changes
152    }
153}
154/// A structural field change; unchanged values allocate no copy.
155#[derive(Debug, PartialEq, Eq)]
156#[non_exhaustive]
157pub enum Change<'a> {
158    Added(&'a Entry),
159    Removed(&'a Entry),
160    Modified { before: &'a Entry, after: &'a Entry },
161}
162fn display_value(kind: u16, bytes: &[u8], order: ByteOrder) -> String {
163    if kind == 2 || kind == 129 {
164        return match core::str::from_utf8(bytes) {
165            Ok(text) => format!("{text:?}"),
166            Err(_) => format!("bytes={bytes:02x?}"),
167        };
168    }
169    let width = match kind {
170        1 | 6 => 1,
171        3 | 8 => 2,
172        4 | 9 | 11 | 13 => 4,
173        5 | 10 | 12 => 8,
174        _ => return format!("{bytes:02x?}"),
175    };
176    let number = |b: &[u8]| -> u64 {
177        match order {
178            ByteOrder::Little => b
179                .iter()
180                .enumerate()
181                .fold(0, |v, (i, b)| v | ((*b as u64) << (i * 8))),
182            ByteOrder::Big => b.iter().fold(0, |v, b| (v << 8) | *b as u64),
183        }
184    };
185    let values: Vec<_> = bytes
186        .chunks_exact(width)
187        .map(|b| match kind {
188            6 => (number(b) as i8).to_string(),
189            8 => (number(b) as i16).to_string(),
190            9 => (number(b) as i32).to_string(),
191            5 => format!("{}/{}", number(&b[..4]), number(&b[4..])),
192            10 => format!("{}/{}", number(&b[..4]) as i32, number(&b[4..]) as i32),
193            11 => format!(
194                "{:?} [bits={:08x}]",
195                f32::from_bits(number(b) as u32),
196                number(b)
197            ),
198            12 => format!("{:?} [bits={:016x}]", f64::from_bits(number(b)), number(b)),
199            _ => number(b).to_string(),
200        })
201        .collect();
202    format!("[{}]", values.join(", "))
203}