strip metadata

This commit is contained in:
Anaïs Saraiva
2026-08-25 12:57:54 +02:00
parent 1079025ec2
commit 9b12838083
9 changed files with 952 additions and 1 deletions
+5
View File
@@ -34,3 +34,8 @@ RATE_LIMIT_WINDOW_MS=60000
# Set 1 only behind a proxy that sets X-Forwarded-For.
TRUST_PROXY=0
# Strip embedded metadata (EXIF/GPS/author/comments, video moov/udta, EBML
# Tags, etc.) from uploaded images and videos on the server. Best-effort:
# unrecognized or unparseable formats are stored untouched. Set 0 to disable.
STRIP_METADATA=1
+3
View File
@@ -17,6 +17,7 @@ pub struct Config {
pub rate_limit_max: usize,
pub rate_limit_window_ms: u64,
pub trust_proxy: bool,
pub strip_metadata: bool,
}
pub fn parse_size(value: &str) -> Result<u64, String> {
@@ -117,6 +118,7 @@ pub fn load() -> Result<Config, String> {
let trust_proxy = bool_from_env(env("TRUST_PROXY").as_deref(), false);
let rate_limit_enabled = bool_from_env(env("RATE_LIMIT_ENABLED").as_deref(), true);
let strip_metadata = bool_from_env(env("STRIP_METADATA").as_deref(), true);
let public_base_url = match env("PUBLIC_BASE_URL") {
Some(raw) => {
@@ -147,5 +149,6 @@ pub fn load() -> Result<Config, String> {
rate_limit_max,
rate_limit_window_ms,
trust_proxy,
strip_metadata,
})
}
+1
View File
@@ -4,6 +4,7 @@ mod ratelimit;
mod routes;
mod security;
mod storage;
mod strip;
use std::net::SocketAddr;
use std::sync::Arc;
+14
View File
@@ -114,6 +114,20 @@ pub async fn upload(
outcome = Outcome::Internal;
break 'fields;
}
// Strip embedded metadata (EXIF/GPS/author/comments) when
// enabled. Best-effort: on unknown format or parse failure
// the original file is kept untouched.
if state.config.strip_metadata && size <= state.storage.strip_limit() {
match state.storage.strip_file(&id).await {
Ok(Some(new_size)) => size = new_size,
Ok(None) => {}
Err(_) => {
outcome = Outcome::Internal;
break 'fields;
}
}
}
}
} else {
while let Ok(Some(_)) = field.chunk().await {}
+32
View File
@@ -90,6 +90,38 @@ impl Storage {
self.files_dir.join(format!("{id}.part"))
}
/// Files above this size are never buffered into memory for metadata
/// stripping (too large). They pass through untouched.
pub fn strip_limit(&self) -> u64 {
256 * 1024 * 1024
}
/// Best-effort in-band metadata stripping. Reads the stored file, produces
/// a metadata-free copy via `crate::strip::strip`, and atomically replaces
/// the original only when stripping actually succeeds and shrinks the file.
///
/// Returns `Ok(Some(new_size))` on success, `Ok(None)` when the file was
/// left untouched (unknown format / unparseable / no metadata), or `Err` on
/// an I/O failure that the caller should treat as a fatal upload error.
pub async fn strip_file(&self, id: &str) -> std::io::Result<Option<u64>> {
let path = self.file_path(id);
let data = tokio::fs::read(&path).await?;
let Some(stripped) = crate::strip::strip(&data) else {
return Ok(None);
};
// Never write a larger or empty result; the original is authoritative.
if stripped.is_empty() || stripped.len() >= data.len() {
return Ok(None);
}
let tmp = self.files_dir.join(format!("{id}.strip"));
tokio::fs::write(&tmp, &stripped).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(Some(stripped.len() as u64))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.metadata_dir.join(format!("{id}.json"))
}
+886
View File
@@ -0,0 +1,886 @@
//! Best-effort in-band metadata stripper.
//!
//! Removes embedded metadata (EXIF, XMP, GPS, comments, ICC, timestamps, video
//! `moov/udta`, EBML `Tags`, AVI `LIST INFO`, ...) from common media formats.
//!
//! The policy is **safe**: if a format is not recognized, or a structure cannot
//! be parsed reliably, the original bytes are returned untouched. We never risk
//! corrupting a file by half-parsing it.
/// Returns `Some(stripped_bytes)` if `data` was recognized and successfully
/// rewritten without metadata, or `None` if the format is unknown / parsing
/// failed (caller keeps the original).
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
if data.is_empty() {
return None;
}
detect_and_strip(data)
}
fn detect_and_strip(data: &[u8]) -> Option<Vec<u8>> {
if data.len() >= 3 && &data[0..3] == b"\xff\xd8\xff" {
return jpeg::strip(data);
}
if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
return png::strip(data);
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return webp::strip(data);
}
if data.len() >= 6 && (&data[0..6] == b"GIF87a" || &data[0..6] == b"GIF89a") {
return gif::strip(data);
}
if data.len() >= 4 && &data[0..4] == b"\x1a\x45\xdf\xa3" {
return ebml::strip(data);
}
if data.len() >= 12 && &data[4..8] == b"ftyp" {
return mp4::strip(data);
}
if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"AVI " {
return avi::strip(data);
}
None
}
// ---------------------------------------------------------------------------
// JPEG — remove EXIF / XMP / IPTC / ICC / comment segments.
// ---------------------------------------------------------------------------
mod jpeg {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
let mut i = 2usize; // skip SOI (0xFFD8)
// Copy SOI marker.
out.extend_from_slice(&data[0..2]);
let mut saw_sos = false;
while i + 1 < data.len() {
// In scan data (after SOS) we copy verbatim until EOI.
if saw_sos {
out.extend_from_slice(&data[i..]);
break;
}
if data[i] != 0xFF {
return None;
}
// Skip fill bytes.
let marker = data[i + 1];
if marker == 0xFF {
i += 1;
continue;
}
// Standalone markers (no length field).
if matches!(marker, 0x01) || (0xD0..=0xD7).contains(&marker) {
out.extend_from_slice(&data[i..i + 2]);
i += 2;
continue;
}
// SOS: copy marker + length, then everything after is scan data.
if marker == 0xDA {
if i + 4 > data.len() {
return None;
}
let len = read_u16(data, i + 2)? as usize;
if i + 2 + len > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + 2 + len]);
i += 2 + len;
saw_sos = true;
continue;
}
if marker == 0xD9 {
// EOI
out.extend_from_slice(&data[i..i + 2]);
break;
}
// Any other marker with a length field.
if i + 4 > data.len() {
return None;
}
let len = read_u16(data, i + 2)? as usize;
if len < 2 || i + 2 + len > data.len() {
return None;
}
// Segments to drop: APP1 (EXIF/XMP), APP2 (ICC/FPXR), APP13 (IPTC),
// APP4 (FPX), APP5/6/7/10, APP11 (JPEG-HDR), APP12 (PictureInfo),
// COM (comment).
let drop = match marker {
0xE0 => false, // APP0 — JFIF / potentially AVIF, keep neutral JFIF
0xE1 | 0xE2 | 0xED | 0xEE | 0xE4 | 0xE5 | 0xE6 | 0xE7 | 0xEA
| 0xEB | 0xEC | 0xFE => true,
_ => false,
};
if !drop {
out.extend_from_slice(&data[i..i + 2 + len]);
}
i += 2 + len;
}
// Ensure we ended with EOI; otherwise bail (keep original).
if out.len() < 2 || out[out.len() - 2] != 0xFF || out[out.len() - 1] != 0xD9 {
return None;
}
Some(out)
}
}
// ---------------------------------------------------------------------------
// PNG — remove text / time / eXIf chunks.
// ---------------------------------------------------------------------------
mod png {
use super::*;
const DROP: &[&[u8; 4]] = &[
b"tEXt", b"zTXt", b"iTXt", b"tIME", b"eXIf", b"iCCP", b"sRGB", b"gAMA", b"cHRM",
];
fn is_drop(ty: &[u8; 4]) -> bool {
DROP.iter().any(|d| *d == ty)
}
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..8]); // signature
let mut i = 8usize;
while i + 8 <= data.len() {
let len = read_u32(data, i)? as usize;
let ty: &[u8; 4] = data[i + 4..i + 8].try_into().ok()?;
// 12 bytes header (len + type + crc) + len data
if i + 12 + len > data.len() {
return None;
}
let total = 12 + len;
if is_drop(ty) {
i += total;
continue;
}
out.extend_from_slice(&data[i..i + total]);
i += total;
if ty == b"IEND" {
break;
}
}
// Require IEND to have been reached.
if out.len() < 12 || &out[out.len() - 8..out.len() - 4] != b"IEND" {
return None;
}
Some(out)
}
}
// ---------------------------------------------------------------------------
// WebP — remove EXIF / XMP chunks.
// ---------------------------------------------------------------------------
mod webp {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..12]); // RIFF header + WEBP
let mut i = 12usize;
// Flags we clear from the VP8X header when dropping metadata chunks.
// 0x20 = ICC, 0x08 = EXIF, 0x04 = XMP.
const META_FLAGS: u8 = 0x20 | 0x08 | 0x04;
while i + 8 <= data.len() {
let tag = &data[i..i + 4];
let size = read_u32_le(data, i + 4)? as usize;
if i + 8 + size > data.len() {
return None;
}
let padded = size + (size & 1);
let chunk_size = 8 + padded;
let drop = tag == b"EXIF" || tag == b"XMP " || tag == b"ICCP";
if tag == b"VP8X" {
// Keep VP8X but clear the metadata flags.
if size < 1 {
return None;
}
out.extend_from_slice(&data[i..i + 8]);
let mut flags = data[i + 8];
flags &= !META_FLAGS;
out.push(flags);
out.extend_from_slice(&data[i + 9..i + 8 + size]);
} else if !drop {
out.extend_from_slice(&data[i..i + chunk_size]);
}
i += chunk_size;
}
// Patch the RIFF total size field.
let riff_size = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&riff_size.to_le_bytes());
Some(out)
}
}
// ---------------------------------------------------------------------------
// GIF — remove comment & application extensions.
// ---------------------------------------------------------------------------
mod gif {
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..6]); // header
let mut i = 6usize;
// Logical screen descriptor (7 bytes).
if i + 7 > data.len() {
return None;
}
let packed = data[i + 4];
out.extend_from_slice(&data[i..i + 7]);
i += 7;
// Global color table (GCT), if present. GCT is NOT metadata; keep it.
if packed & 0x80 != 0 {
let gct_len = 3 * (1 << ((packed & 0x07) + 1));
if i + gct_len > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + gct_len]);
i += gct_len;
}
while i < data.len() {
match data[i] {
0x3B => {
// Trailer
out.push(data[i]);
break;
}
0x21 => {
// Extension introducer
if i + 1 >= data.len() {
return None;
}
let label = data[i + 1];
// Drop comment (0xFE) and application (0xFF) extension blocks.
if label == 0xFE || label == 0xFF {
i += 2;
i = skip_gif_subblocks(data, i)?;
continue;
}
// Other extensions (graphic control 0xF9, plain text 0x01):
// copy the whole block verbatim.
i += 2;
while i < data.len() {
let n = data[i] as usize;
out.push(data[i]);
i += 1;
if n == 0 {
break;
}
if i + n > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + n]);
i += n;
}
}
0x2C => {
// Image descriptor (10 bytes) + LZW data. Copy verbatim;
// it does not carry identity metadata.
if i + 10 > data.len() {
return None;
}
let img_packed = data[i + 9];
out.extend_from_slice(&data[i..i + 10]);
i += 10;
// Local color table (LCT), if present.
if img_packed & 0x80 != 0 {
let lct_len = 3 * (1 << ((img_packed & 0x07) + 1));
if i + lct_len > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + lct_len]);
i += lct_len;
}
// LZW minimum code size.
if i >= data.len() {
return None;
}
out.push(data[i]);
i += 1;
// Image data sub-blocks.
while i < data.len() {
let n = data[i] as usize;
out.push(data[i]);
i += 1;
if n == 0 {
break;
}
if i + n > data.len() {
return None;
}
out.extend_from_slice(&data[i..i + n]);
i += n;
}
}
_ => {
// Unknown descriptor; bail and keep original.
return None;
}
}
}
Some(out)
}
fn skip_gif_subblocks(data: &[u8], mut i: usize) -> Option<usize> {
while i < data.len() {
let n = data[i] as usize;
i += 1;
if n == 0 {
return Some(i);
}
if i + n > data.len() {
return None;
}
i += n;
}
None
}
}
// ---------------------------------------------------------------------------
// EBML / Matroska / WebM — drop Tags / Metadata-mapped elements.
// ---------------------------------------------------------------------------
mod ebml {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
// The `Tags` element (0x1254C367) is in the top-level (Segment) scope.
// We do a conservative top-level scan: copy the EBML header and the
// Segment but drop any top-level `Tags` element. Top-level children are
// SeekHead, Info, Tracks, Chapters, Cluster, Cues, Attachments, Tags.
// We rely on element IDs being present; on any parse ambiguity, bail.
let mut out = Vec::with_capacity(data.len());
let mut i = 0usize;
// EBML header element (ID 0x1A45DFA3).
let (id, size, hdr_len) = read_ebml_element(data, i)?;
if id != 0x1A45DFA3 {
return None;
}
let header_end = hdr_len + (size as usize);
if header_end > data.len() {
return None;
}
out.extend_from_slice(&data[0..header_end]);
i = header_end;
// Segment element (ID 0x18538067).
let (id, size, _hdr_len) = read_ebml_element(data, i)?;
if id != 0x18538067 {
return None;
}
let seg_id_len = ebml_id_len(data, i)?;
let seg_size_len = ebml_size_len(data, i + seg_id_len)?;
let seg_hdr_len = seg_id_len + seg_size_len;
let seg_data_start = i + seg_hdr_len;
let seg_data_end = if size == u64::MAX {
data.len()
} else {
(seg_data_start + size as usize).min(data.len())
};
if seg_data_end > data.len() {
return None;
}
// Copy segment ID, store size-field width, then rebuild children.
let seg_size_encoded = size;
let seg_id_bytes = &data[i..i + seg_id_len];
out.extend_from_slice(seg_id_bytes);
// Build children body (drop Tags).
let mut body = Vec::new();
let mut j = seg_data_start;
// Element IDs for Tag-bearing metadata children of Segment.
// Tags = 0x1254C367, Chapters = 0x1043A770, Attachments = 0x1941A469.
while j < seg_data_end {
let (id2, size2, hdr_len2) = read_ebml_element(data, j)?;
if size2 == u64::MAX {
// Nested unknown-size child: too ambiguous, keep original.
return None;
}
let elem_end = hdr_len2 + (size2 as usize);
if elem_end > seg_data_end {
// Malformed; keep original.
return None;
}
if id2 == 0x1254C367 || id2 == 0x1043A770 || id2 == 0x1941A469 {
j = elem_end;
continue;
}
body.extend_from_slice(&data[j..elem_end]);
j = elem_end;
}
// Re-encode segment length field with the same width as the source.
if seg_size_encoded == u64::MAX {
// Unknown size -> single-byte 0x01 (all-ones style).
out.push(0x01);
} else {
out.extend_from_slice(&encode_ebml_size(body.len() as u64, seg_size_len)?);
}
out.extend_from_slice(&body);
Some(out)
}
}
// ---------------------------------------------------------------------------
// ISO BMFF (MP4/MOV/M4V) — drop moov/udta/meta (and ilst) boxes.
// ---------------------------------------------------------------------------
mod mp4 {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
// Recursively rebuild the box list at top level, dropping metadata
// boxes inside `moov`. We edit in-place structurally, preserving the
// relative media data untouched (mdat is kept verbatim).
let mut out = Vec::with_capacity(data.len());
let mut i = 0usize;
while i + 8 <= data.len() {
let (size, hdr_len, ty) = read_box(data, i)?;
let box_end = if size == 0 {
data.len()
} else if size == 1 {
let largesize = read_u64(data, i + 8)?;
(i + largesize as usize).min(data.len())
} else {
(i + size as usize).min(data.len())
};
if box_end > data.len() || box_end < i {
return None;
}
if ty == *b"moov" {
let body = &data[i + hdr_len..box_end];
let new_body = strip_moov(body)?;
// Rebuild moov box with new size.
out.extend_from_slice(&build_box(b"moov", &new_body));
} else {
out.extend_from_slice(&data[i..box_end]);
}
i = box_end;
}
Some(out)
}
fn strip_moov(body: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(body.len());
let mut i = 0usize;
while i + 8 <= body.len() {
let (size, hdr_len, ty) = read_box(body, i)?;
let box_end = if size == 0 {
body.len()
} else if size == 1 {
let largesize = read_u64(body, i + 8)?;
(i + largesize as usize).min(body.len())
} else {
(i + size as usize).min(body.len())
};
if box_end > body.len() || box_end < i {
return None;
}
// Drop metadata-bearing boxes.
if ty == *b"udta" || ty == *b"meta" {
i = box_end;
continue;
}
// `trak` contains `udta` too: recurse into it.
if ty == *b"trak" {
let inner = &body[i + hdr_len..box_end];
let new_inner = strip_trak(inner)?;
out.extend_from_slice(&build_box(b"trak", &new_inner));
i = box_end;
continue;
}
out.extend_from_slice(&body[i..box_end]);
i = box_end;
}
Some(out)
}
fn strip_trak(body: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(body.len());
let mut i = 0usize;
while i + 8 <= body.len() {
let (size, _hdr_len, ty) = read_box(body, i)?;
let box_end = if size == 0 {
body.len()
} else if size == 1 {
let largesize = read_u64(body, i + 8)?;
(i + largesize as usize).min(body.len())
} else {
(i + size as usize).min(body.len())
};
if box_end > body.len() || box_end < i {
return None;
}
if ty == *b"udta" || ty == *b"meta" {
i = box_end;
continue;
}
out.extend_from_slice(&body[i..box_end]);
i = box_end;
}
Some(out)
}
}
// ---------------------------------------------------------------------------
// AVI (RIFF/Audio-Video Interleave) — drop LIST INFO / JUNK / IDIT.
// ---------------------------------------------------------------------------
mod avi {
use super::*;
pub fn strip(data: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
out.extend_from_slice(&data[0..12]); // RIFF + size + AVI
let mut i = 12usize;
while i + 8 <= data.len() {
let id = &data[i..i + 4];
let size = read_u32_le(data, i + 4)? as usize;
let padded = size + (size & 1);
if i + 8 + padded > data.len() {
return None;
}
if id == b"LIST" {
if i + 12 <= data.len() {
let list_type = &data[i + 8..i + 12];
if list_type == b"INFO" || list_type == b"JUNK" {
i += 8 + padded;
continue;
}
}
}
// `IDIT` chunk (encrypted digital interface time) is metadata.
if id == b"IDIT" {
i += 8 + padded;
continue;
}
out.extend_from_slice(&data[i..i + 8 + padded]);
i += 8 + padded;
}
// Patch RIFF size in header.
let new_size = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&new_size.to_le_bytes());
Some(out)
}
}
// ---------------------------------------------------------------------------
// Common byte readers / writers.
// ---------------------------------------------------------------------------
fn read_u16(data: &[u8], i: usize) -> Option<u16> {
Some(u16::from_be_bytes([*data.get(i)?, *data.get(i + 1)?]))
}
fn read_u32(data: &[u8], i: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*data.get(i)?,
*data.get(i + 1)?,
*data.get(i + 2)?,
*data.get(i + 3)?,
]))
}
fn read_u32_le(data: &[u8], i: usize) -> Option<u32> {
Some(u32::from_le_bytes([
*data.get(i)?,
*data.get(i + 1)?,
*data.get(i + 2)?,
*data.get(i + 3)?,
]))
}
fn read_u64(data: &[u8], i: usize) -> Option<u64> {
Some(u64::from_be_bytes([
*data.get(i)?,
*data.get(i + 1)?,
*data.get(i + 2)?,
*data.get(i + 3)?,
*data.get(i + 4)?,
*data.get(i + 5)?,
*data.get(i + 6)?,
*data.get(i + 7)?,
]))
}
// --- ISO BMFF box ----------------------------------------------------------
fn read_box(data: &[u8], i: usize) -> Option<(u64, usize, [u8; 4])> {
if i + 8 > data.len() {
return None;
}
let size32 = read_u32(data, i)? as u64;
let mut ty = [0u8; 4];
ty.copy_from_slice(&data[i + 4..i + 8]);
match size32 {
0 => Some((0, 8, ty)),
1 => {
if i + 16 > data.len() {
return None;
}
let largesize = read_u64(data, i + 8)?;
Some((largesize, 16, ty))
}
n if n < 8 => None,
n => Some((n, 8, ty)),
}
}
fn build_box(ty: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + body.len());
out.extend_from_slice(&((8 + body.len()) as u32).to_be_bytes());
out.extend_from_slice(ty);
out.extend_from_slice(body);
out
}
// --- EBML -------------------------------------------------------------------
fn ebml_id_len(data: &[u8], i: usize) -> Option<usize> {
let first = *data.get(i)?;
let mut len = 1usize;
let mut mask = 0x80u8;
while mask != 0 && first & mask == 0 {
len += 1;
mask >>= 1;
}
if len > 4 {
return None;
}
Some(len)
}
fn ebml_size_len(data: &[u8], i: usize) -> Option<usize> {
let first = *data.get(i)?;
let mut len = 1usize;
let mut mask = 0x80u8;
while mask != 0 && first & mask == 0 {
len += 1;
mask >>= 1;
}
if len > 8 {
return None;
}
Some(len)
}
/// Returns (element_id, size, header_len). `size` is the payload length after
/// the header, or `u64::MAX` for unknown-size elements. The element id is
/// decoded with its leading length-marker bit cleared.
fn read_ebml_element(data: &[u8], i: usize) -> Option<(u64, u64, usize)> {
let id_len = ebml_id_len(data, i)?;
let id_bytes = &data.get(i..i + id_len)?;
// Clear the leading length-marker bit in the first byte before decoding.
let mut id = 0u64;
for (idx, b) in id_bytes.iter().enumerate() {
let mut v = *b;
if idx == 0 {
v &= 0x7F >> (id_len - 1);
}
id = (id << 8) | v as u64;
}
let si = i + id_len;
let size_len = ebml_size_len(data, si)?;
let size_bytes = &data.get(si..si + size_len)?;
// Unknown-size: first byte is 0x01 followed by all-1s (e.g. 0x01 0xFF...).
// The first byte after clearing the marker is zero and the rest are all 1s.
let mut size = 0u64;
for (idx, b) in size_bytes.iter().enumerate() {
let mut v = *b;
if idx == 0 {
v &= 0x7F >> (size_len - 1);
}
size = (size << 8) | v as u64;
}
// Detect all-ones payload (unknown size).
let unknown = size_bytes
.iter()
.enumerate()
.all(|(idx, b)| {
if idx == 0 {
*b & (0x7F >> (size_len - 1)) == 0
} else {
*b == 0xFF
}
});
let size = if unknown { u64::MAX } else { size };
Some((id, size, id_len + size_len))
}
fn encode_ebml_size(value: u64, width: usize) -> Option<Vec<u8>> {
if width == 0 || width > 8 {
return None;
}
let mut out = vec![0u8; width];
for k in 0..width {
out[width - 1 - k] = (value >> (8 * k)) as u8;
}
// Set the leading marker bit (most-significant bit of first byte).
out[0] |= 0x80 >> (width - 1);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
fn png_chunk(ty: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(ty);
out.extend_from_slice(payload);
let mut crc_input = Vec::with_capacity(4 + payload.len());
crc_input.extend_from_slice(ty);
crc_input.extend_from_slice(payload);
out.extend_from_slice(&crc32(&crc_input).to_be_bytes());
out
}
#[test]
fn png_strips_text_chunk() {
let mut data = b"\x89PNG\r\n\x1a\n".to_vec();
data.extend_from_slice(&png_chunk(b"IHDR", &[0; 13]));
data.extend_from_slice(&png_chunk(b"tEXt", b"Comment\0secret-author"));
data.extend_from_slice(&png_chunk(b"IDAT", &[1, 2, 3, 4]));
data.extend_from_slice(&png_chunk(b"IEND", &[]));
let stripped = strip(&data).expect("should strip");
assert!(!stripped.windows(4).any(|w| w == b"tEXt"));
assert!(stripped.windows(4).any(|w| w == b"IEND"));
assert!(stripped.len() < data.len());
}
#[test]
fn unknown_format_returns_none() {
assert!(strip(b"hello world this is random data").is_none());
assert!(strip(b"").is_none());
}
#[test]
fn gif_strips_comment_and_application_extensions() {
// GIF89a header + LSD (no GCT) + comment ext + application ext + trailer
let mut data = b"GIF89a".to_vec();
data.extend_from_slice(&[1, 0, 1, 0, 0, 0, 0]); // LSD w/o GCT
// Comment extension (0x21 0xFE ...)
data.push(0x21);
data.push(0xFE);
data.push(6);
data.extend_from_slice(b"secret");
data.push(0);
// Application extension (0x21 0xFF ...)
data.push(0x21);
data.push(0xFF);
data.push(11);
data.extend_from_slice(b"NETSCAPE2.0");
data.push(3);
data.extend_from_slice(&[1, 0, 0]);
data.push(0);
data.push(0x3B); // trailer
let stripped = strip(&data).expect("should strip");
assert!(!stripped.windows(2).any(|w| w == [0x21, 0xFE]));
assert!(!stripped.windows(2).any(|w| w == [0x21, 0xFF]));
assert_eq!(stripped.last(), Some(&0x3B));
}
#[test]
fn jpeg_strips_app1_exif() {
// SOI + APP1(EXIF) + EOI
let mut data = b"\xff\xd8\xff".to_vec();
data.push(0xE1); // APP1
data.extend_from_slice(&8u16.to_be_bytes()); // length = 2 + 6 payload
data.extend_from_slice(b"Exif\0\0");
data.extend_from_slice(&[0xFF, 0xD9]); // EOI
let stripped = strip(&data).expect("should strip");
assert_eq!(&stripped, b"\xff\xd8\xff\xd9");
}
#[test]
fn webp_strips_exif_xmp() {
// RIFF + WEBP + VP8X chunk + EXIF chunk + XMP chunk
let mut data = b"RIFF".to_vec();
data.extend_from_slice(&0u32.to_le_bytes()); // size patched later
data.extend_from_slice(b"WEBP");
// VP8X header (flags = ICC|EXIF|XMP|alpha)
let mut vp8x = Vec::new();
vp8x.push(0x20 | 0x08 | 0x04 | 0x10); // flags
vp8x.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0]); // rest of 10-byte payload
data.extend_from_slice(b"VP8X");
data.extend_from_slice(&(vp8x.len() as u32).to_le_bytes());
data.extend_from_slice(&vp8x);
// EXIF chunk
data.extend_from_slice(b"EXIF");
data.extend_from_slice(&6u32.to_le_bytes());
data.extend_from_slice(b"Exif\0\0");
// XMP chunk
data.extend_from_slice(b"XMP ");
data.extend_from_slice(&4u32.to_le_bytes());
data.extend_from_slice(b"xml!"); // 4-byte payload
// Patch RIFF size
let riff = (data.len() - 8) as u32;
data[4..8].copy_from_slice(&riff.to_le_bytes());
let stripped = strip(&data).expect("should strip");
assert!(!stripped.windows(4).any(|w| w == b"EXIF"));
assert!(!stripped.windows(4).any(|w| w == b"XMP "));
assert!(stripped.windows(4).any(|w| w == b"VP8X"));
// VP8X flags should have metadata bits cleared.
let vp8x_pos = stripped.windows(4).position(|w| w == b"VP8X").unwrap();
assert_eq!(stripped[vp8x_pos + 8] & 0x2C, 0);
}
// Real-media smoke test. Only runs when pre-generated files exist under
// /tmp/meta_test; otherwise it silently skips (CI-safe).
#[test]
fn strip_real_media_when_present() {
for name in ["test.jpg", "test.png", "test.mp4"] {
let path = std::path::Path::new("/tmp/meta_test").join(name);
let Ok(data) = std::fs::read(&path) else {
continue;
};
let Some(stripped) = strip(&data) else {
eprintln!("{name}: not stripped (unknown/unparseable format) — kept original");
continue;
};
// Stripping must never grow the file, and must still be valid enough
// to be non-empty.
assert!(!stripped.is_empty());
assert!(stripped.len() <= data.len());
let out_path = std::path::Path::new("/tmp/meta_test").join(format!("{name}.stripped"));
std::fs::write(&out_path, &stripped).ok();
eprintln!("{name}: {} -> {} bytes", data.len(), stripped.len());
}
}
}