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
@@ -40,3 +40,8 @@ RATE_LIMIT_WINDOW_MS=60000
# X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when # X-Forwarded-For $proxy_add_x_forwarded_for). Never enable this when
# clients can reach the server directly - they could spoof the header. # clients can reach the server directly - they could spoof the header.
TRUST_PROXY=0 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
+5 -1
View File
@@ -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 at 48h) and are swept hourly. Deletion also requires possession of the key
(`DELETE /api/files/:id` with `X-Delete-Key`). (`DELETE /api/files/:id` with `X-Delete-Key`).
- Uploads are rejected with `503 try again later` when the storage filesystem is - Uploads are rejected with `503 try again later` when the storage filesystem is
more than 50% full (configurable via `MAX_STORAGE_USAGE`). 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`.
+1
View File
@@ -43,6 +43,7 @@ Copy `backend/.env.example` to `.env` and edit it.
| `RATE_LIMIT_MAX` | `300` | Max requests per window. | | `RATE_LIMIT_MAX` | `300` | Max requests per window. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. | | `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. |
| `TRUST_PROXY` | `0` | Enable behind a proxy (`X-Forwarded-For`). | | `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 ## API
+5
View File
@@ -34,3 +34,8 @@ RATE_LIMIT_WINDOW_MS=60000
# Set 1 only behind a proxy that sets X-Forwarded-For. # Set 1 only behind a proxy that sets X-Forwarded-For.
TRUST_PROXY=0 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_max: usize,
pub rate_limit_window_ms: u64, pub rate_limit_window_ms: u64,
pub trust_proxy: bool, pub trust_proxy: bool,
pub strip_metadata: bool,
} }
pub fn parse_size(value: &str) -> Result<u64, String> { 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 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 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") { let public_base_url = match env("PUBLIC_BASE_URL") {
Some(raw) => { Some(raw) => {
@@ -147,5 +149,6 @@ pub fn load() -> Result<Config, String> {
rate_limit_max, rate_limit_max,
rate_limit_window_ms, rate_limit_window_ms,
trust_proxy, trust_proxy,
strip_metadata,
}) })
} }
+1
View File
@@ -4,6 +4,7 @@ mod ratelimit;
mod routes; mod routes;
mod security; mod security;
mod storage; mod storage;
mod strip;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
+14
View File
@@ -114,6 +114,20 @@ pub async fn upload(
outcome = Outcome::Internal; outcome = Outcome::Internal;
break 'fields; 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 { } else {
while let Ok(Some(_)) = field.chunk().await {} while let Ok(Some(_)) = field.chunk().await {}
+32
View File
@@ -90,6 +90,38 @@ impl Storage {
self.files_dir.join(format!("{id}.part")) 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 { fn meta_path(&self, id: &str) -> PathBuf {
self.metadata_dir.join(format!("{id}.json")) 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());
}
}
}