Skip to main content

strypt_core/
report.rs

1//! Structured results.
2//!
3//! `strypt-core` returns data; front-ends render it (ADR-0003). Nothing in this module is a
4//! sentence meant for a human — the strings that do appear are *format-domain identifiers*
5//! such as `APP1 (Exif)` or `/Info /Author`, which are stable, machine-usable, and identical
6//! in the CLI's text output, its JSON output, and the Phase 5 GUI. If a type here ever grows
7//! a field holding a translated or prose string, that is the boundary being violated.
8//!
9//! # Values are opt-in, and off by default
10//!
11//! A report names the metadata it found. It carries the *value* only when the caller sets
12//! [`InspectOptions::include_values`]. The default is off because a report is easy to
13//! redirect into a file, paste into an issue, or scroll back to — each of which recreates the
14//! secret the user just asked to have destroyed (`docs/THREAT_MODEL.md` §5.5). Front-ends
15//! that show values, such as the Phase 5 before/after diff, opt in deliberately.
16
17use crate::detect::Format;
18
19/// What kind of identifying information an item represents.
20///
21/// These categories mirror `docs/THREAT_MODEL.md` §3 so that a user can connect what strypt
22/// reports to what the threat model claims it protects against.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24#[non_exhaustive]
25pub enum MetadataKind {
26    /// GPS coordinates, altitude, bearing — anything that places the file.
27    Location,
28    /// Camera make, model, lens, and body serial numbers. Serial numbers link every file a
29    /// device ever produced, so one missed image can retroactively deanonymise an archive.
30    DeviceIdentity,
31    /// Author, creator, last-modified-by, organisation, registered owner.
32    PersonalIdentity,
33    /// Producing application and version.
34    SoftwareFingerprint,
35    /// Creation and modification times.
36    Timestamp,
37    /// Embedded thumbnails and previews, which survive cropping and visual redaction of the
38    /// main image — a "redacted" photograph can carry an unredacted copy of itself.
39    Thumbnail,
40    /// Revision identifiers, editing-cycle counts, total editing time, tracked changes.
41    EditingHistory,
42    /// An identifier that stays stable across saves and copies of one document — a PDF file
43    /// identifier, an XMP `DocumentID`. It names nobody, and it links every copy and every
44    /// revision of the document to each other, which for a leaked draft is the whole question.
45    DocumentIdentifier,
46    /// Embedded ICC colour profiles, which frequently carry a device or vendor name.
47    ColourProfile,
48    /// Free-text comments.
49    Comment,
50    /// Recognised as metadata, but not in any category above.
51    Other,
52}
53
54impl MetadataKind {
55    /// The stable lowercase identifier used in JSON output.
56    #[must_use]
57    pub const fn id(self) -> &'static str {
58        match self {
59            Self::Location => "location",
60            Self::DeviceIdentity => "device-identity",
61            Self::PersonalIdentity => "personal-identity",
62            Self::SoftwareFingerprint => "software-fingerprint",
63            Self::Timestamp => "timestamp",
64            Self::Thumbnail => "thumbnail",
65            Self::EditingHistory => "editing-history",
66            Self::DocumentIdentifier => "document-identifier",
67            Self::ColourProfile => "colour-profile",
68            Self::Comment => "comment",
69            Self::Other => "other",
70        }
71    }
72
73    /// How much this kind of item typically matters, used by front-ends for ordering and
74    /// emphasis.
75    #[must_use]
76    pub const fn sensitivity(self) -> Sensitivity {
77        match self {
78            // Each of these can identify a person or a place on its own, with no correlation
79            // and no further work by the adversary.
80            Self::Location | Self::DeviceIdentity | Self::PersonalIdentity | Self::Thumbnail => {
81                Sensitivity::Direct
82            }
83            // Rarely identifying alone; frequently identifying in combination
84            // (docs/THREAT_MODEL.md §4.7).
85            Self::Timestamp
86            | Self::EditingHistory
87            | Self::DocumentIdentifier
88            | Self::ColourProfile
89            | Self::Comment => Sensitivity::Correlating,
90            Self::SoftwareFingerprint | Self::Other => Sensitivity::Incidental,
91        }
92    }
93}
94
95/// How directly an item exposes its subject.
96///
97/// A hint for presentation only. It must never gate whether something is removed: strypt
98/// removes what it removes regardless of how it is ranked here.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
100#[non_exhaustive]
101pub enum Sensitivity {
102    /// Identifies a person, device, or place on its own.
103    Direct,
104    /// Narrows the field, and identifies when combined with other traits.
105    Correlating,
106    /// Unlikely to identify anyone by itself.
107    Incidental,
108}
109
110/// A metadata value, when the caller asked for values.
111#[derive(Debug, Clone, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum MetadataValue {
114    /// A decoded textual value.
115    Text(String),
116    /// A value that is not text, or not valid text. Only its length is carried: rendering an
117    /// arbitrary binary blob into a report has no benefit to the user and every opportunity
118    /// to leak.
119    Opaque {
120        /// Length of the value in bytes.
121        bytes: u64,
122    },
123}
124
125/// One piece of metadata found in a file.
126#[derive(Debug, Clone, PartialEq, Eq)]
127#[non_exhaustive]
128pub struct Finding {
129    /// What kind of identifying information this is.
130    pub kind: MetadataKind,
131    /// Where it lives in the file's structure, as a format-domain identifier — `APP1 (Exif)`,
132    /// `tEXt`, `/Info /Author`. Stable across releases; front-ends may show it verbatim.
133    pub location: String,
134    /// The field's name within that structure, where the format names its fields.
135    pub field: Option<String>,
136    /// Size of the item in bytes.
137    pub bytes: u64,
138    /// The value — present only when [`InspectOptions::include_values`] was set.
139    pub value: Option<MetadataValue>,
140}
141
142impl Finding {
143    /// A finding with no field name and no value.
144    #[must_use]
145    pub fn new(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
146        Self {
147            kind,
148            location: location.into(),
149            field: None,
150            bytes,
151            value: None,
152        }
153    }
154
155    /// Attach the field name this item was stored under.
156    #[must_use]
157    pub fn with_field(mut self, field: impl Into<String>) -> Self {
158        self.field = Some(field.into());
159        self
160    }
161
162    /// Attach the value, but only if `options` asked for it.
163    ///
164    /// Taking the options here rather than at the call site means a handler cannot leak a
165    /// value by forgetting to check — the check lives in one place and every handler goes
166    /// through it.
167    #[must_use]
168    pub fn with_value(
169        mut self,
170        options: &InspectOptions,
171        value: impl FnOnce() -> MetadataValue,
172    ) -> Self {
173        if options.include_values {
174            self.value = Some(value());
175        }
176        self
177    }
178
179    /// How directly this finding exposes its subject.
180    #[must_use]
181    pub const fn sensitivity(&self) -> Sensitivity {
182        self.kind.sensitivity()
183    }
184}
185
186/// A caveat about what strypt did or could not do.
187///
188/// Modelled as an enum rather than free text so that front-ends can present notes
189/// consistently, and so that a limitation cannot be introduced by a handler quietly writing a
190/// new sentence. Notes are how the tool stays honest about partial knowledge without either
191/// hiding a limitation or failing outright.
192#[derive(Debug, Clone, PartialEq, Eq)]
193#[non_exhaustive]
194pub enum Note {
195    /// The file contained a region the handler could not parse. Its bytes were preserved
196    /// as-is, so metadata inside it — if any — was not removed.
197    UnparsedRegion {
198        /// The region's format-domain identifier.
199        location: String,
200        /// How many bytes were left untouched.
201        bytes: u64,
202    },
203    /// The file had been saved incrementally, so earlier revisions were present in it.
204    IncrementalHistory {
205        /// How many prior revisions were found.
206        revisions: usize,
207    },
208    /// Objects that nothing in the finished document referred to any more were dropped.
209    ///
210    /// Usually the remains of superseded revisions. They are worth reporting because their
211    /// presence tells the user something true about the file they were about to publish:
212    /// earlier drafts of it were physically inside it.
213    OrphanedObjectsRemoved {
214        /// How many unreachable objects were dropped.
215        objects: usize,
216    },
217    /// Content was found that strypt deliberately does not touch, such as text drawn under a
218    /// redaction rectangle (`docs/THREAT_MODEL.md` §4.3).
219    OutOfScopeContent {
220        /// What was seen.
221        location: String,
222    },
223    /// The filename itself may identify its subject. A scrubbed file called
224    /// `IMG_survivor_address.jpg` is not scrubbed in any meaningful sense
225    /// (`docs/ARCHITECTURE.md` §8).
226    FilenameMayIdentify,
227    /// Removing metadata cost the file something it could previously do — not what it renders,
228    /// but what it can still be used for. JPEG XL's `jbrd` box is the case the variant exists
229    /// for: it holds the original JPEG's marker segments, so it goes, and bit-exact JPEG
230    /// reconstruction goes with it (ADR-0036).
231    CapabilityRemoved {
232        /// What was removed.
233        location: String,
234        /// What the file can no longer do, phrased to follow "this file can no longer".
235        capability: String,
236    },
237}
238
239/// The result of inspecting a file. Produced without modifying anything.
240#[derive(Debug, Clone, PartialEq, Eq)]
241#[non_exhaustive]
242pub struct MetadataReport {
243    /// The format the content was detected as.
244    pub format: Format,
245    /// Everything found, in the order the handler encountered it in the file.
246    pub findings: Vec<Finding>,
247    /// Caveats about the inspection.
248    pub notes: Vec<Note>,
249}
250
251impl MetadataReport {
252    /// An empty report for `format`.
253    #[must_use]
254    pub const fn empty(format: Format) -> Self {
255        Self {
256            format,
257            findings: Vec::new(),
258            notes: Vec::new(),
259        }
260    }
261
262    /// Whether anything removable was found.
263    ///
264    /// Drives the documented non-zero exit of `strypt show`, so it must mean exactly "there
265    /// is something here to remove" — never "something might be here".
266    #[must_use]
267    pub fn has_findings(&self) -> bool {
268        !self.findings.is_empty()
269    }
270}
271
272/// The result of stripping a file.
273#[derive(Debug, Clone, PartialEq, Eq)]
274#[non_exhaustive]
275pub struct StripReport {
276    /// The format that was processed.
277    pub format: Format,
278    /// What was removed. Says what came out, not merely that something did (PRD §8.2).
279    pub removed: Vec<Finding>,
280    /// What was deliberately kept, and why. An empty list here is a claim, so a handler that
281    /// knowingly leaves something behind must say so rather than staying silent.
282    pub retained: Vec<Retained>,
283    /// Caveats about the operation.
284    pub notes: Vec<Note>,
285    /// Input size in bytes.
286    pub input_bytes: u64,
287    /// Output size in bytes.
288    pub output_bytes: u64,
289}
290
291/// Something the handler found and chose not to remove.
292#[derive(Debug, Clone, PartialEq, Eq)]
293#[non_exhaustive]
294pub struct Retained {
295    /// Where it lives, as a format-domain identifier.
296    pub location: String,
297    /// Why it was kept.
298    pub reason: RetentionReason,
299}
300
301/// Why a handler kept something it could see.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303#[non_exhaustive]
304pub enum RetentionReason {
305    /// Removing it would have altered the payload — re-encoding pixels, for instance — which
306    /// PRD §8.1 forbids. Preserving the payload wins, and the limitation gets documented.
307    RemovalWouldAlterPayload,
308    /// The format requires it to be present for the file to remain valid.
309    StructurallyRequired,
310    /// It is computed from the payload the file still carries, so removing it would hide nothing
311    /// from anyone holding that file. FLAC's MD5 of the unencoded audio is the case this exists
312    /// for: a fingerprint, and one the holder can recompute (ADR-0038). Declared rather than
313    /// removed — and declared rather than passed over, because it does link one copy of a
314    /// recording to another.
315    DerivedFromPayload,
316}
317
318/// What a caller wants from an inspection.
319#[derive(Debug, Clone, Default, PartialEq, Eq)]
320#[non_exhaustive]
321pub struct InspectOptions {
322    /// Include metadata values in findings.
323    ///
324    /// Off by default. See this module's note on why the default is the safe one.
325    pub include_values: bool,
326}
327
328impl InspectOptions {
329    /// Options that report field names and counts but never values. The default.
330    #[must_use]
331    pub const fn names_only() -> Self {
332        Self {
333            include_values: false,
334        }
335    }
336
337    /// Options that include values, for a caller that has decided it needs them.
338    #[must_use]
339    pub const fn with_values() -> Self {
340        Self {
341            include_values: true,
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn values_are_absent_unless_the_caller_asks() {
352        let options = InspectOptions::names_only();
353        let f = Finding::new(MetadataKind::Location, "APP1 (Exif)", 42)
354            .with_field("GPSLatitude")
355            .with_value(&options, || MetadataValue::Text("51.5074".into()));
356
357        assert_eq!(f.field.as_deref(), Some("GPSLatitude"));
358        assert_eq!(
359            f.value, None,
360            "a default inspection must name the field and withhold the coordinate"
361        );
362    }
363
364    #[test]
365    fn values_are_present_when_the_caller_opts_in() {
366        let options = InspectOptions::with_values();
367        let f = Finding::new(MetadataKind::PersonalIdentity, "/Info", 12)
368            .with_value(&options, || MetadataValue::Text("A. Name".into()));
369        assert_eq!(f.value, Some(MetadataValue::Text("A. Name".into())));
370    }
371
372    #[test]
373    fn directly_identifying_kinds_outrank_incidental_ones() {
374        // Front-ends sort by this, so the ordering is part of the contract.
375        assert!(Sensitivity::Direct < Sensitivity::Correlating);
376        assert!(Sensitivity::Correlating < Sensitivity::Incidental);
377        assert_eq!(MetadataKind::Location.sensitivity(), Sensitivity::Direct);
378        assert_eq!(
379            MetadataKind::SoftwareFingerprint.sensitivity(),
380            Sensitivity::Incidental
381        );
382    }
383
384    #[test]
385    fn an_embedded_thumbnail_ranks_as_directly_identifying() {
386        // It can survive cropping and visual redaction, so it is not a lesser finding than
387        // the GPS tag next to it (docs/THREAT_MODEL.md §3).
388        assert_eq!(MetadataKind::Thumbnail.sensitivity(), Sensitivity::Direct);
389    }
390}