strip metadata
This commit is contained in:
@@ -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
@@ -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`.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user