Let the copyright survive the export, and the GPS not

Carries the source's metadata all the way to the file the user hands over,
and proves in bytes that the coordinates do not come with it.

The privacy test was the piece that mattered and the piece that was wrong.
It searched the whole file for the two-byte hemisphere reference "N\0" or
"E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the
sRGB tone curve inside the ICC profile every export embeds is 69, written as
`00 45`, and the next sample is below 256, so its high byte is `00`. Every
format would have failed a test about a colour profile. The needle is now the
twenty-four bytes a coordinate actually serialises to — three rationals, both
byte orders, since exif.rs writes little-endian and the tiff crate writes in
the host's — which cannot match by accident, and the retaining test asserts
the same needle is *present* so a search that could never find anything
cannot make the stripping test pass by being useless.

The batch exporter now hands the decoder's reading on to the encoder. It
already read the metadata for the orientation and the {date} token; passing
it through is what puts the camera, the lens and the rights statement into
the file. Nothing about privacy is decided there — dr-export takes that
decision once, from the settings.

The example passes it too, because it is the only place in the tree that
produces files a person can open in exiftool. A unit test can prove a GPS
directory is absent from a byte slice; only a real export proves a real
photograph comes out the far end still knowing which camera took it.

TRACES: FR-EXP-8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:15:06 +02:00
co-authored by Claude Opus 5
parent 3846c277c8
commit 59362fcecf
3 changed files with 158 additions and 15 deletions
+62 -7
View File
@@ -742,7 +742,7 @@ mod tests {
artist: Some("Duncan Tourolle".into()),
copyright: Some("(c) 2026 Duncan Tourolle".into()),
// 48° 51' 29.52" N, 2° 17' 40.2" E.
location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)),
location: dr_types::Location::new(LATITUDE, LONGITUDE, Some(35.0)),
}
}
@@ -802,6 +802,45 @@ mod tests {
bytes.windows(4).any(|w| w == LITTLE || w == BIG)
}
/// TRACES: FR-EXP-8
/// Whether the source's own coordinates appear anywhere in the bytes.
///
/// The complement of [`has_gps_pointer`]. That one says no reader has a
/// *route* to a position; this says the numbers themselves are not in the
/// file at all — not under some other tag, not in a directory this test
/// did not think to look in, not left behind in a heap after the entry
/// pointing at it was dropped.
///
/// The needle is the twenty-four bytes a coordinate serialises to: three
/// rationals, degrees, minutes and seconds. Specific enough that a match
/// is the coordinate rather than a coincidence, which matters because the
/// obvious cheaper needle is not: searching for the hemisphere letter as
/// `"N\0"` or `"E\0"` matches the tone curve inside the ICC profile every
/// export carries — sample 14 of the sRGB curve is 69, which is `00 45`,
/// beside a sample below 256, which is `00 xx`. A privacy test that fails
/// on the colour profile teaches nobody anything.
///
/// Both byte orders, because `exif.rs` writes little-endian and the `tiff`
/// crate writes in the host's.
fn contains_coordinate(bytes: &[u8], degrees: f64) -> bool {
let mut little = Vec::new();
let mut big = Vec::new();
for (n, d) in exif::dms(degrees) {
little.extend_from_slice(&n.to_le_bytes());
little.extend_from_slice(&d.to_le_bytes());
big.extend_from_slice(&n.to_be_bytes());
big.extend_from_slice(&d.to_be_bytes());
}
bytes
.windows(little.len())
.any(|w| w == little.as_slice() || w == big.as_slice())
}
/// The latitude and longitude [`source`] carries, for the two tests that
/// look for them in the bytes.
const LATITUDE: f64 = 48.8582;
const LONGITUDE: f64 = 2.2945;
#[test]
fn no_export_carries_a_location_by_default() {
// TRACES: FR-EXP-8
@@ -818,11 +857,16 @@ mod tests {
);
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
assert_eq!(md.location, None, "{format:?} decodes to a position");
// And the coordinates are not loose in the file under some other
// tag: the hemisphere letters a GPS directory always carries.
// And the numbers are not loose in the file with nothing pointing
// at them, which is what a scrubber that unlinked the directory
// without dropping its values would leave behind.
assert!(
!bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"),
"{format:?} contains a hemisphere reference"
!contains_coordinate(&bytes, LATITUDE),
"{format:?} still contains the latitude"
);
assert!(
!contains_coordinate(&bytes, LONGITUDE),
"{format:?} still contains the longitude"
);
}
}
@@ -863,6 +907,17 @@ mod tests {
has_gps_pointer(&bytes),
"{format:?} dropped the position it was asked to keep"
);
// The control for `contains_coordinate` as well as for the
// pointer: a search that could never find the numbers would make
// the stripping test above pass without proving anything.
assert!(
contains_coordinate(&bytes, LATITUDE),
"{format:?} carries no latitude for the strip test to be about"
);
assert!(
contains_coordinate(&bytes, LONGITUDE),
"{format:?} carries no longitude for the strip test to be about"
);
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}");
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}");
@@ -883,8 +938,8 @@ mod tests {
let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix"));
// Within a metre of where it started, which is finer than any
// consumer receiver and far finer than the tag's own rounding.
assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}");
assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}");
assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}");
assert!((loc.longitude - LONGITUDE).abs() < 1e-5, "{format:?} {loc:?}");
assert_eq!(loc.altitude, Some(35.0), "{format:?}");
}
}