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}