From 9b12838083e415db568bb4783040aac470ba8583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?= <53227802+Kisakay@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:57:54 +0200 Subject: [PATCH] strip metadata --- .env.example | 5 + HOSTING.md | 6 +- README.md | 1 + backend/.env.example | 5 + backend/src/config.rs | 3 + backend/src/main.rs | 1 + backend/src/routes/upload.rs | 14 + backend/src/storage.rs | 32 ++ backend/src/strip.rs | 886 +++++++++++++++++++++++++++++++++++ 9 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 backend/src/strip.rs diff --git a/.env.example b/.env.example index 7b7facd..e7b5de1 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,8 @@ RATE_LIMIT_WINDOW_MS=60000 # X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when # clients can reach the server directly - they could spoof the header. 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 diff --git a/HOSTING.md b/HOSTING.md index 1b89757..460e1ac 100644 --- a/HOSTING.md +++ b/HOSTING.md @@ -254,4 +254,8 @@ Files in `storage/` are untouched by updates and keep working. at 48h) and are swept hourly. Deletion also requires possession of the key (`DELETE /api/files/:id` with `X-Delete-Key`). - Uploads are rejected with `503 try again later` when the storage filesystem is - more than 50% full (configurable via `MAX_STORAGE_USAGE`). \ No newline at end of file + more than 50% full (configurable via `MAX_STORAGE_USAGE`). +- Embedded metadata (EXIF/GPS/author/comments, video `moov`/`udta`, EBML + `Tags`, AVI `INFO`) is stripped server-side by default for recognized image + and video formats. Unparseable or unrecognized files are stored untouched + (never corrupted). Disable with `STRIP_METADATA=0`. \ No newline at end of file diff --git a/README.md b/README.md index b7155e6..b5a5948 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Copy `backend/.env.example` to `.env` and edit it. | `RATE_LIMIT_MAX` | `300` | Max requests per window. | | `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. | | `TRUST_PROXY` | `0` | Enable behind a proxy (`X-Forwarded-For`). | +| `STRIP_METADATA` | `1` | Strip embedded EXIF/GPS/author/comments and video metadata server-side. | ## API diff --git a/backend/.env.example b/backend/.env.example index 25f9d8a..5641b64 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/config.rs b/backend/src/config.rs index 159a442..9076e11 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -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 { @@ -117,6 +118,7 @@ pub fn load() -> Result { 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 { rate_limit_max, rate_limit_window_ms, trust_proxy, + strip_metadata, }) } diff --git a/backend/src/main.rs b/backend/src/main.rs index dd6227e..f75e212 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,6 +4,7 @@ mod ratelimit; mod routes; mod security; mod storage; +mod strip; use std::net::SocketAddr; use std::sync::Arc; diff --git a/backend/src/routes/upload.rs b/backend/src/routes/upload.rs index 8cf9e39..4f79aa3 100644 --- a/backend/src/routes/upload.rs +++ b/backend/src/routes/upload.rs @@ -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 {} diff --git a/backend/src/storage.rs b/backend/src/storage.rs index 2c8566d..0f096e8 100644 --- a/backend/src/storage.rs +++ b/backend/src/storage.rs @@ -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> { + 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")) } diff --git a/backend/src/strip.rs b/backend/src/strip.rs new file mode 100644 index 0000000..409b3fc --- /dev/null +++ b/backend/src/strip.rs @@ -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> { + if data.is_empty() { + return None; + } + detect_and_strip(data) +} + +fn detect_and_strip(data: &[u8]) -> Option> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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> { + // 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> { + // 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> { + 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> { + 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> { + 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 { + Some(u16::from_be_bytes([*data.get(i)?, *data.get(i + 1)?])) +} + +fn read_u32(data: &[u8], i: usize) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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()); + } + } +}