crates/veilvoice-meta/src/image.rs

what this file is for · veilvoice-meta · 210 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! Image EXIF/GPS removal.
//!
//! Images are handled at the container level with `img-parts`: the EXIF and XMP
//! segments are dropped and the compressed pixel data is copied through
//! untouched. Nothing is re-encoded, so cleaning is lossless and cannot
//! introduce visible artefacts.
//!
//! GPS coordinates are the reason this matters most. A single holiday snapshot
//! attached to an otherwise anonymous message can place someone within a few
//! metres, and no amount of voice processing helps with that.
//!
//! # In plain words
//!
//! Strips the hidden information out of a picture, including where it was taken.
//!
//! Photographs from a phone routinely carry the exact location, the time, the
//! device and its serial number. The picture is copied through untouched and only
//! those sections are dropped, so nothing about how it looks changes.

use crate::{Error, Policy, Report};
use img_parts::{DynImage, ImageEXIF, ImageICC};
use std::path::Path;


/// Image container formats this crate can clean.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImageKind {
    /// JPEG (EXIF in an APP1 segment).
    Jpeg,
    /// PNG (EXIF in an `eXIf` chunk).
    Png,
    /// WebP (EXIF in an `EXIF` chunk).
    WebP,
}


impl ImageKind {

    /// Identify a format from its magic bytes.
    ///
    /// Deliberately content-sniffed rather than taken from the extension: a
    /// file named `.png` that is really a JPEG must still get cleaned.
    pub fn sniff(bytes: &[u8]) -> Option<Self> {
        if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
            Some(Self::Jpeg)
        } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
            Some(Self::Png)
        } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
            Some(Self::WebP)
        } else {
            None
        }
    }

}


/// Strip identifying metadata from encoded image bytes.
///
/// Returns the cleaned bytes and a report of what was removed.
pub fn clean_image_bytes(bytes: &[u8], policy: Policy) -> Result<(Vec<u8>, Report), Error> {
    if ImageKind::sniff(bytes).is_none() {
        return Err(Error::UnsupportedFormat);
    }
    let mut image = DynImage::from_bytes(bytes.to_vec().into())
        .map_err(|e| Error::Malformed(e.to_string()))?
        .ok_or(Error::UnsupportedFormat)?;

    let mut report = Report::default();
    if image.exif().is_some() {
        // EXIF carries the camera model, serial number, timestamps and GPS.
        report.note("EXIF");
        image.set_exif(None);
    }
    if image.icc_profile().is_some() {
        // ICC profiles are rarer but can be device-specific, so they identify
        // the capture hardware in much the same way.
        report.note("ICC profile");
        image.set_icc_profile(None);
    }

    // There is no "realistic" EXIF worth forging: a plausible-looking camera
    // tag would be a false statement about provenance, and unlike an audio
    // encoder string it invites the reader to draw conclusions. Images are
    // always stripped, and the policy is accepted only so callers can use one
    // uniform interface.
    let _ = policy;

    let mut out = Vec::with_capacity(bytes.len());
    image
        .encoder()
        .write_to(&mut out)
        .map_err(|e| Error::Malformed(e.to_string()))?;
    Ok((out, report))
}



/// Strip identifying metadata from an image file, in place.
pub fn clean_image_file(path: &Path, policy: Policy) -> Result<Report, Error> {
    let bytes = std::fs::read(path)?;
    let (cleaned, report) = clean_image_bytes(&bytes, policy)?;
    if report.changed {
        std::fs::write(path, cleaned)?;
    }
    Ok(report)
}


#[cfg(test)]
mod tests {
    use super::*;

    /// A minimal JPEG carrying an APP1/EXIF segment with a GPS-looking payload.
    fn jpeg_with_exif() -> Vec<u8> {
        let secret = b"GPS 51.5074N 0.1278W CameraSerial#12345";
        let mut exif = Vec::new();
        exif.extend_from_slice(b"Exif\0\0");
        exif.extend_from_slice(secret);

        let mut out = Vec::new();
        out.extend_from_slice(&[0xFF, 0xD8]); // SOI
        out.extend_from_slice(&[0xFF, 0xE1]); // APP1
        out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
        out.extend_from_slice(&exif);
        // A minimal but structurally valid scan so parsers accept the file.
        out.extend_from_slice(&[0xFF, 0xDB, 0x00, 0x43, 0x00]);
        out.extend_from_slice(&[0x08; 64]);
        out.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00]);
        out.extend_from_slice(&[0x00, 0x01, 0x02, 0x03]);
        out.extend_from_slice(&[0xFF, 0xD9]); // EOI
        out
    }

    #[test]
    fn sniffing_recognises_the_formats_it_supports() {
        assert_eq!(ImageKind::sniff(&jpeg_with_exif()), Some(ImageKind::Jpeg));
        assert_eq!(
            ImageKind::sniff(b"\x89PNG\r\n\x1a\nrest"),
            Some(ImageKind::Png)
        );

        let mut webp = Vec::from(*b"RIFF____WEBPVP8 ");
        webp.extend_from_slice(&[0u8; 8]);
        assert_eq!(ImageKind::sniff(&webp), Some(ImageKind::WebP));

        assert_eq!(ImageKind::sniff(b"not an image at all"), None);
    }

    #[test]
    fn sniffing_ignores_the_file_extension() {
        // A JPEG misnamed `.png` must still be identified as a JPEG.
        let bytes = jpeg_with_exif();
        assert_eq!(ImageKind::sniff(&bytes), Some(ImageKind::Jpeg));
    }

    #[test]
    fn exif_and_its_gps_payload_are_removed() {
        let bytes = jpeg_with_exif();
        let needle = b"51.5074N";
        assert!(
            bytes.windows(needle.len()).any(|w| w == needle),
            "fixture lacks GPS"
        );

        let (cleaned, report) = clean_image_bytes(&bytes, Policy::Strip).unwrap();
        assert!(report.changed);
        assert!(report.removed.iter().any(|r| r == "EXIF"));
        assert!(
            !cleaned.windows(needle.len()).any(|w| w == needle),
            "GPS coordinates survived stripping"
        );
        assert!(
            !cleaned.windows(13).any(|w| w == b"CameraSerial#"),
            "camera serial survived stripping"
        );
    }

    #[test]
    fn the_result_is_still_a_valid_jpeg() {
        let (cleaned, _) = clean_image_bytes(&jpeg_with_exif(), Policy::Strip).unwrap();
        assert_eq!(ImageKind::sniff(&cleaned), Some(ImageKind::Jpeg));
        assert!(cleaned.ends_with(&[0xFF, 0xD9]), "EOI marker missing");
    }

    #[test]
    fn cleaning_twice_is_stable() {
        let (once, _) = clean_image_bytes(&jpeg_with_exif(), Policy::Strip).unwrap();
        let (twice, report) = clean_image_bytes(&once, Policy::Strip).unwrap();
        assert!(
            !report.changed,
            "an already-clean image needs no second pass"
        );
        assert_eq!(once, twice);
    }

    #[test]
    fn unsupported_input_is_rejected() {
        assert!(matches!(
            clean_image_bytes(b"plain text", Policy::Strip),
            Err(Error::UnsupportedFormat)
        ));
    }

    #[test]
    fn file_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("photo.jpg");
        std::fs::write(&path, jpeg_with_exif()).unwrap();

        let report = clean_image_file(&path, Policy::Strip).unwrap();
        assert!(report.changed);

        let raw = std::fs::read(&path).unwrap();
        assert!(!raw.windows(8).any(|w| w == b"51.5074N"));
    }
}