WIP: EXIF metadata on export

Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
This commit is contained in:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit cd8750462f
8 changed files with 1797 additions and 46 deletions
+106
View File
@@ -0,0 +1,106 @@
//! TRACES: FR-EXP-8
//! What an export is allowed to say about where it came from.
//!
//! # An allowlist, not a filter
//!
//! [`SourceMetadata`] is the whole of what can reach a file this crate writes.
//! It is populated field by field from whatever the caller decoded, and
//! nothing else travels — not because each unwanted tag is removed, but
//! because there is nowhere in this type for one to sit. That is the
//! difference between "we strip GPS" and "GPS cannot be written unless
//! [`SourceMetadata::location`] is `Some`", and only the second survives
//! somebody adding a field to the decoder next year.
//!
//! # What is deliberately not here
//!
//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no
//! public format, and its contents differ by body and firmware. Canon's
//! carries the body serial number and the shutter count; several bodies put a
//! *duplicate copy of the GPS fix* inside it, which is the specific reason it
//! cannot be passed through as an unexamined byte range: an export that
//! stripped the GPS directory and copied the maker note would have published
//! the coordinates anyway, while reporting itself as private. Parsing it per
//! vendor to decide what is safe is a research project with a permanent
//! maintenance cost, and the value on the other side is a few tags a
//! photographer rarely misses. So it is dropped, in both directions, whatever
//! the settings say.
//!
//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431,
//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a
//! person and a specific piece of equipment, and a serial number in a
//! published file links every photograph that person has ever posted. They
//! have no field here, so no export writes them.
//!
//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode`
//! today, so there is nothing to carry through; when there is, it arrives as
//! fields on this type and is written from them, and the same allowlist
//! reasoning applies unchanged.
use dr_types::Location;
/// TRACES: FR-EXP-8
/// The source metadata an export may carry.
///
/// Every field is optional because every field is genuinely absent from some
/// real file: scanner output has no aperture, a JPEG from a phone has no lens
/// model, and most photographs have no copyright statement at all.
///
/// Built by the caller, which is the only place that has both the decoded
/// source and the crate that decoded it — `dr-export` deliberately depends on
/// no decoder (see the crate docs), so the copy is made one field at a time
/// where both types are in scope. That transcription is a feature: it is the
/// point where somebody has to decide, in writing, that a newly-parsed piece
/// of the source is allowed to leave the machine.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SourceMetadata {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
/// Exposure time in seconds.
pub shutter: Option<f32>,
/// The f-number, as in f/2.8.
pub aperture: Option<f32>,
pub iso: Option<u32>,
/// Millimetres, as marked on the lens rather than 35 mm equivalent.
pub focal_length: Option<f32>,
/// When the shutter fired, as Unix seconds read as a wall clock.
pub captured_at: Option<i64>,
/// Minutes east of UTC, where the camera recorded a zone.
pub captured_offset: Option<i32>,
/// Who made the photograph.
pub artist: Option<String>,
/// The rights statement.
pub copyright: Option<String>,
/// TRACES: FR-EXP-8
/// Where the shutter fired.
///
/// The one field the strip option is about. It is carried this far so that
/// a photographer who *wants* their coordinates can have them; by the time
/// the encoder sees the record this field has already been through
/// [`Self::sanitised`], and is `None` unless the user turned stripping
/// off.
pub location: Option<Location>,
}
impl SourceMetadata {
/// This record as the settings permit it to be written.
///
/// **The single place stripping happens.** The encoders below take a
/// record and write what is in it, with no view on privacy; concentrating
/// the decision here means there is one function to read to know what an
/// export can disclose, and no format can quietly disagree with the
/// others — the failure mode where JPEG honours the setting and TIFF, five
/// hundred lines away, does not.
///
/// Stripping empties the field rather than blanking it. A `GPSLatitude` of
/// `0/0` still announces that the camera had a fix and that this file has
/// been through a scrubber; an absent directory says nothing at all, and
/// says it in the same shape as the millions of files that never had one.
pub(crate) fn sanitised(&self, strip_location: bool) -> Self {
let mut out = self.clone();
if strip_location {
out.location = None;
}
out
}
}