From 899c4ff611e6f37ae525f452318d254c3ef0e933 Mon Sep 17 00:00:00 2001 From: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Date: Wed, 22 Jul 2026 15:04:52 -0700 Subject: [PATCH 1/2] fix(desktop): strip GIF metadata extensions before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay rejects media carrying metadata (422 MetadataForbidden, #2006): for GIF that means comment extensions, plain-text extensions, and any application extension other than the standard NETSCAPE2.0/ANIMEXTS1.0 looping ones. Photoshop, Giphy, and most GIF tooling emit XMP or comment extensions routinely, so custom-emoji and attachment GIF uploads from desktop failed with 'media contains metadata or a non-canonical metadata channel'. Desktop already sanitizes JPEG/PNG/WebP in sanitize_image_for_upload, but GIF fell through untouched (re-encoding was never an option — it would destroy animation timing). Add a structural strip instead: walk the GIF block sequence, drop the metadata channels the relay forbids, copy frames, colour tables, graphic-control and looping extensions verbatim, and truncate trailing bytes after the trailer. Unparseable payloads pass through unchanged so the relay's validator remains the authority. Verified against a live relay: a GIF with a spliced comment or XMP extension 422s when uploaded raw and is accepted after stripping, with output byte-identical to the pre-splice original. Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c --- desktop/src-tauri/src/commands/media.rs | 215 ++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ee90de9453..e7972f5e4e 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -212,11 +212,128 @@ fn is_animated_image(body: &[u8], mime: &str) -> bool { } } +/// Walk length-prefixed GIF data sub-blocks starting at `i`; return the index +/// just past the block terminator. +fn gif_sub_blocks_end(body: &[u8], mut i: usize) -> Option { + loop { + let len = *body.get(i)? as usize; + i += 1; + if len == 0 { + return Some(i); + } + i = i.checked_add(len).filter(|&end| end <= body.len())?; + } +} + +/// Strip metadata channels from a GIF without re-encoding. +/// +/// GIF carries three unrestricted metadata channels — comment extensions +/// (0xFE), plain-text extensions (0x01), and application extensions (0xFF) +/// other than the standard NETSCAPE2.0/ANIMEXTS1.0 looping ones. The relay +/// rejects all of them (`MetadataForbidden` in buzz-media's +/// `validate_gif_metadata_free`), and encoders like Photoshop and Giphy emit +/// them routinely, so uploads fail unless the client drops them first. +/// +/// Everything the relay accepts — header, colour tables, graphic-control +/// extensions, image descriptors and frame data, standard looping extensions — +/// is copied verbatim, so animation timing, disposal, and pixel data stay +/// byte-identical. Trailing bytes after the trailer (another relay reject) are +/// truncated. Returns `None` when the payload isn't structurally parseable as +/// GIF; the caller then uploads the original bytes and the relay's validator +/// remains the authority. +fn strip_gif_metadata(body: &[u8]) -> Option> { + if !(body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a")) || body.len() < 13 { + return None; + } + + let packed = body[10]; + let mut i = 13usize; + if packed & 0x80 != 0 { + let table_len = 3usize << ((packed & 0x07) as usize + 1); + i = i.checked_add(table_len).filter(|&end| end <= body.len())?; + } + + let mut out = Vec::with_capacity(body.len()); + out.extend_from_slice(&body[..i]); + + loop { + match *body.get(i)? { + // Image descriptor: optional local colour table, LZW minimum code + // size, then image-data sub-blocks. Copied verbatim. + 0x2c => { + if i + 10 > body.len() { + return None; + } + let image_packed = body[i + 9]; + let mut end = i + 10; + if image_packed & 0x80 != 0 { + let table_len = 3usize << ((image_packed & 0x07) as usize + 1); + end = end.checked_add(table_len).filter(|&e| e <= body.len())?; + } + end = end.checked_add(1).filter(|&e| e <= body.len())?; + end = gif_sub_blocks_end(body, end)?; + out.extend_from_slice(&body[i..end]); + i = end; + } + 0x21 => { + let label = *body.get(i + 1)?; + let start = i; + i += 2; + match label { + // Graphic Control Extension: fixed-shape rendering state + // (delay, disposal, transparency). Kept verbatim. + 0xf9 => { + if body.get(i) != Some(&4) || i + 6 > body.len() || body[i + 5] != 0 { + return None; + } + i += 6; + out.extend_from_slice(&body[start..i]); + } + // Application extension: keep only the standard looping + // extensions; anything else (XMP, Photoshop, Giphy…) is a + // metadata channel and is dropped. + 0xff => { + if body.get(i) != Some(&11) || i + 12 > body.len() { + return None; + } + let app = &body[i + 1..i + 12]; + let keep = app == b"NETSCAPE2.0" || app == b"ANIMEXTS1.0"; + i = gif_sub_blocks_end(body, i + 12)?; + if keep { + out.extend_from_slice(&body[start..i]); + } + } + // Comment (0xFE), plain-text (0x01), and unknown + // extensions: pure metadata channels, dropped. Their + // bodies are all length-prefixed sub-block sequences + // (plain-text's 12-byte header is itself a sub-block). + _ => { + i = gif_sub_blocks_end(body, i)?; + } + } + } + // Trailer: emit and stop, truncating any trailing bytes. + 0x3b => { + out.push(0x3b); + return Some(out); + } + _ => return None, + } + } +} + fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, String> { let format = match mime { "image/jpeg" => image::ImageFormat::Jpeg, "image/png" => image::ImageFormat::Png, "image/webp" => image::ImageFormat::WebP, + // GIF is never re-encoded (that would destroy animation timing); + // metadata extensions are stripped structurally instead. Unparseable + // payloads pass through — the relay's validator is the authority. + "image/gif" => { + let stripped = strip_gif_metadata(&body); + return Ok(stripped.unwrap_or(body)); + } _ => return Ok(body), }; @@ -907,6 +1024,104 @@ mod tests { ); } + /// Minimal single-frame GIF89a: header, 2×2 logical screen, 2-entry global + /// colour table, NETSCAPE looping extension, graphic control extension, + /// one image descriptor with LZW data, trailer. Structurally canonical — + /// the relay's validator accepts exactly this shape. + fn minimal_gif() -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&[ + 0x02, 0x00, 0x02, 0x00, // logical screen 2×2 + 0x80, 0x00, 0x00, // GCT flag, 2 entries + 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, // colour table + ]); + // NETSCAPE2.0 looping application extension. + gif.extend_from_slice(&[0x21, 0xff, 11]); + gif.extend_from_slice(b"NETSCAPE2.0"); + gif.extend_from_slice(&[3, 0x01, 0x00, 0x00, 0x00]); + // Graphic control extension. + gif.extend_from_slice(&[0x21, 0xf9, 4, 0x00, 0x0a, 0x00, 0x00, 0x00]); + // Image descriptor + 2-bit LZW data. + gif.extend_from_slice(&[0x2c, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00]); + gif.extend_from_slice(&[0x02, 0x02, 0x44, 0x01, 0x00]); + gif.push(0x3b); + gif + } + + /// Comment extension: `0x21 0xFE`, one data sub-block, terminator. + fn gif_comment_ext() -> Vec { + let mut ext = vec![0x21, 0xfe, 5]; + ext.extend_from_slice(b"hello"); + ext.push(0); + ext + } + + /// XMP-style application extension (what Photoshop/Giphy emit). + fn gif_xmp_ext() -> Vec { + let mut ext = vec![0x21, 0xff, 11]; + ext.extend_from_slice(b"XMP DataXMP"); + ext.extend_from_slice(&[4]); + ext.extend_from_slice(b""); + ext.push(0); + ext + } + + #[test] + fn test_strip_gif_metadata_removes_comment_and_foreign_app_extensions() { + let clean = minimal_gif(); + // Splice metadata extensions after the global colour table (offset 19). + let mut dirty = clean[..19].to_vec(); + dirty.extend_from_slice(&gif_comment_ext()); + dirty.extend_from_slice(&gif_xmp_ext()); + dirty.extend_from_slice(&clean[19..]); + + let stripped = strip_gif_metadata(&dirty).unwrap(); + assert_eq!(stripped, clean); + } + + #[test] + fn test_strip_gif_metadata_preserves_clean_gif_byte_identical() { + let clean = minimal_gif(); + assert_eq!(strip_gif_metadata(&clean).unwrap(), clean); + } + + #[test] + fn test_strip_gif_metadata_truncates_bytes_after_trailer() { + let mut padded = minimal_gif(); + padded.extend_from_slice(b"junk after trailer"); + assert_eq!(strip_gif_metadata(&padded).unwrap(), minimal_gif()); + } + + #[test] + fn test_strip_gif_metadata_rejects_unparseable_payloads() { + // Not a GIF at all. + assert!(strip_gif_metadata(b"GIF89a").is_none()); + assert!(strip_gif_metadata(b"\x89PNG\r\n\x1a\n").is_none()); + // Truncated mid-structure. + let clean = minimal_gif(); + assert!(strip_gif_metadata(&clean[..clean.len() - 3]).is_none()); + } + + #[test] + fn test_sanitize_gif_strips_metadata_and_passes_through_unparseable() { + let clean = minimal_gif(); + let mut dirty = clean[..19].to_vec(); + dirty.extend_from_slice(&gif_comment_ext()); + dirty.extend_from_slice(&clean[19..]); + assert_eq!( + sanitize_image_for_upload(dirty, "image/gif").unwrap(), + clean + ); + + // Unparseable GIF payloads pass through unchanged — the relay's + // validator stays the authority. + let junk = b"GIF89a\x00\x01".to_vec(); + assert_eq!( + sanitize_image_for_upload(junk.clone(), "image/gif").unwrap(), + junk + ); + } + #[test] fn test_legacy_upload_retry_statuses_are_narrow() { assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND)); From 79d41cce2f8e957258ed12cdb9a30c8d1120c06f Mon Sep 17 00:00:00 2001 From: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Date: Wed, 22 Jul 2026 15:16:09 -0700 Subject: [PATCH 2/2] refactor(desktop): move GIF stripping to commands/media_gif.rs The GIF sanitizer pushed media.rs past the 1000-line desktop file-size limit (check-file-sizes.mjs). Split it into its own module, following the existing media_transcode/media_download precedent. No behavior change. Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c --- desktop/src-tauri/src/commands/media.rs | 212 +------------------ desktop/src-tauri/src/commands/media_gif.rs | 221 ++++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 1 + 3 files changed, 224 insertions(+), 210 deletions(-) create mode 100644 desktop/src-tauri/src/commands/media_gif.rs diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index e7972f5e4e..5aaa45b4a3 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -212,117 +212,7 @@ fn is_animated_image(body: &[u8], mime: &str) -> bool { } } -/// Walk length-prefixed GIF data sub-blocks starting at `i`; return the index -/// just past the block terminator. -fn gif_sub_blocks_end(body: &[u8], mut i: usize) -> Option { - loop { - let len = *body.get(i)? as usize; - i += 1; - if len == 0 { - return Some(i); - } - i = i.checked_add(len).filter(|&end| end <= body.len())?; - } -} - -/// Strip metadata channels from a GIF without re-encoding. -/// -/// GIF carries three unrestricted metadata channels — comment extensions -/// (0xFE), plain-text extensions (0x01), and application extensions (0xFF) -/// other than the standard NETSCAPE2.0/ANIMEXTS1.0 looping ones. The relay -/// rejects all of them (`MetadataForbidden` in buzz-media's -/// `validate_gif_metadata_free`), and encoders like Photoshop and Giphy emit -/// them routinely, so uploads fail unless the client drops them first. -/// -/// Everything the relay accepts — header, colour tables, graphic-control -/// extensions, image descriptors and frame data, standard looping extensions — -/// is copied verbatim, so animation timing, disposal, and pixel data stay -/// byte-identical. Trailing bytes after the trailer (another relay reject) are -/// truncated. Returns `None` when the payload isn't structurally parseable as -/// GIF; the caller then uploads the original bytes and the relay's validator -/// remains the authority. -fn strip_gif_metadata(body: &[u8]) -> Option> { - if !(body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a")) || body.len() < 13 { - return None; - } - - let packed = body[10]; - let mut i = 13usize; - if packed & 0x80 != 0 { - let table_len = 3usize << ((packed & 0x07) as usize + 1); - i = i.checked_add(table_len).filter(|&end| end <= body.len())?; - } - - let mut out = Vec::with_capacity(body.len()); - out.extend_from_slice(&body[..i]); - - loop { - match *body.get(i)? { - // Image descriptor: optional local colour table, LZW minimum code - // size, then image-data sub-blocks. Copied verbatim. - 0x2c => { - if i + 10 > body.len() { - return None; - } - let image_packed = body[i + 9]; - let mut end = i + 10; - if image_packed & 0x80 != 0 { - let table_len = 3usize << ((image_packed & 0x07) as usize + 1); - end = end.checked_add(table_len).filter(|&e| e <= body.len())?; - } - end = end.checked_add(1).filter(|&e| e <= body.len())?; - end = gif_sub_blocks_end(body, end)?; - out.extend_from_slice(&body[i..end]); - i = end; - } - 0x21 => { - let label = *body.get(i + 1)?; - let start = i; - i += 2; - match label { - // Graphic Control Extension: fixed-shape rendering state - // (delay, disposal, transparency). Kept verbatim. - 0xf9 => { - if body.get(i) != Some(&4) || i + 6 > body.len() || body[i + 5] != 0 { - return None; - } - i += 6; - out.extend_from_slice(&body[start..i]); - } - // Application extension: keep only the standard looping - // extensions; anything else (XMP, Photoshop, Giphy…) is a - // metadata channel and is dropped. - 0xff => { - if body.get(i) != Some(&11) || i + 12 > body.len() { - return None; - } - let app = &body[i + 1..i + 12]; - let keep = app == b"NETSCAPE2.0" || app == b"ANIMEXTS1.0"; - i = gif_sub_blocks_end(body, i + 12)?; - if keep { - out.extend_from_slice(&body[start..i]); - } - } - // Comment (0xFE), plain-text (0x01), and unknown - // extensions: pure metadata channels, dropped. Their - // bodies are all length-prefixed sub-block sequences - // (plain-text's 12-byte header is itself a sub-block). - _ => { - i = gif_sub_blocks_end(body, i)?; - } - } - } - // Trailer: emit and stop, truncating any trailing bytes. - 0x3b => { - out.push(0x3b); - return Some(out); - } - _ => return None, - } - } -} - -fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, String> { +pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, String> { let format = match mime { "image/jpeg" => image::ImageFormat::Jpeg, "image/png" => image::ImageFormat::Png, @@ -331,7 +221,7 @@ fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, Strin // metadata extensions are stripped structurally instead. Unparseable // payloads pass through — the relay's validator is the authority. "image/gif" => { - let stripped = strip_gif_metadata(&body); + let stripped = super::media_gif::strip_gif_metadata(&body); return Ok(stripped.unwrap_or(body)); } _ => return Ok(body), @@ -1024,104 +914,6 @@ mod tests { ); } - /// Minimal single-frame GIF89a: header, 2×2 logical screen, 2-entry global - /// colour table, NETSCAPE looping extension, graphic control extension, - /// one image descriptor with LZW data, trailer. Structurally canonical — - /// the relay's validator accepts exactly this shape. - fn minimal_gif() -> Vec { - let mut gif = b"GIF89a".to_vec(); - gif.extend_from_slice(&[ - 0x02, 0x00, 0x02, 0x00, // logical screen 2×2 - 0x80, 0x00, 0x00, // GCT flag, 2 entries - 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, // colour table - ]); - // NETSCAPE2.0 looping application extension. - gif.extend_from_slice(&[0x21, 0xff, 11]); - gif.extend_from_slice(b"NETSCAPE2.0"); - gif.extend_from_slice(&[3, 0x01, 0x00, 0x00, 0x00]); - // Graphic control extension. - gif.extend_from_slice(&[0x21, 0xf9, 4, 0x00, 0x0a, 0x00, 0x00, 0x00]); - // Image descriptor + 2-bit LZW data. - gif.extend_from_slice(&[0x2c, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00]); - gif.extend_from_slice(&[0x02, 0x02, 0x44, 0x01, 0x00]); - gif.push(0x3b); - gif - } - - /// Comment extension: `0x21 0xFE`, one data sub-block, terminator. - fn gif_comment_ext() -> Vec { - let mut ext = vec![0x21, 0xfe, 5]; - ext.extend_from_slice(b"hello"); - ext.push(0); - ext - } - - /// XMP-style application extension (what Photoshop/Giphy emit). - fn gif_xmp_ext() -> Vec { - let mut ext = vec![0x21, 0xff, 11]; - ext.extend_from_slice(b"XMP DataXMP"); - ext.extend_from_slice(&[4]); - ext.extend_from_slice(b""); - ext.push(0); - ext - } - - #[test] - fn test_strip_gif_metadata_removes_comment_and_foreign_app_extensions() { - let clean = minimal_gif(); - // Splice metadata extensions after the global colour table (offset 19). - let mut dirty = clean[..19].to_vec(); - dirty.extend_from_slice(&gif_comment_ext()); - dirty.extend_from_slice(&gif_xmp_ext()); - dirty.extend_from_slice(&clean[19..]); - - let stripped = strip_gif_metadata(&dirty).unwrap(); - assert_eq!(stripped, clean); - } - - #[test] - fn test_strip_gif_metadata_preserves_clean_gif_byte_identical() { - let clean = minimal_gif(); - assert_eq!(strip_gif_metadata(&clean).unwrap(), clean); - } - - #[test] - fn test_strip_gif_metadata_truncates_bytes_after_trailer() { - let mut padded = minimal_gif(); - padded.extend_from_slice(b"junk after trailer"); - assert_eq!(strip_gif_metadata(&padded).unwrap(), minimal_gif()); - } - - #[test] - fn test_strip_gif_metadata_rejects_unparseable_payloads() { - // Not a GIF at all. - assert!(strip_gif_metadata(b"GIF89a").is_none()); - assert!(strip_gif_metadata(b"\x89PNG\r\n\x1a\n").is_none()); - // Truncated mid-structure. - let clean = minimal_gif(); - assert!(strip_gif_metadata(&clean[..clean.len() - 3]).is_none()); - } - - #[test] - fn test_sanitize_gif_strips_metadata_and_passes_through_unparseable() { - let clean = minimal_gif(); - let mut dirty = clean[..19].to_vec(); - dirty.extend_from_slice(&gif_comment_ext()); - dirty.extend_from_slice(&clean[19..]); - assert_eq!( - sanitize_image_for_upload(dirty, "image/gif").unwrap(), - clean - ); - - // Unparseable GIF payloads pass through unchanged — the relay's - // validator stays the authority. - let junk = b"GIF89a\x00\x01".to_vec(); - assert_eq!( - sanitize_image_for_upload(junk.clone(), "image/gif").unwrap(), - junk - ); - } - #[test] fn test_legacy_upload_retry_statuses_are_narrow() { assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND)); diff --git a/desktop/src-tauri/src/commands/media_gif.rs b/desktop/src-tauri/src/commands/media_gif.rs new file mode 100644 index 0000000000..be2ea1a251 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_gif.rs @@ -0,0 +1,221 @@ +//! Structural GIF metadata stripping for the upload sanitizer. +//! +//! Split out of `media.rs` to keep that file under the desktop line-size +//! limit. The relay rejects media carrying metadata (`MetadataForbidden` in +//! buzz-media's `validate_gif_metadata_free`); these helpers drop the GIF +//! metadata channels it forbids without re-encoding, so animation timing, +//! disposal, and pixel data survive byte-identical. + +/// Walk length-prefixed GIF data sub-blocks starting at `i`; return the index +/// just past the block terminator. +fn gif_sub_blocks_end(body: &[u8], mut i: usize) -> Option { + loop { + let len = *body.get(i)? as usize; + i += 1; + if len == 0 { + return Some(i); + } + i = i.checked_add(len).filter(|&end| end <= body.len())?; + } +} + +/// Strip metadata channels from a GIF without re-encoding. +/// +/// GIF carries three unrestricted metadata channels — comment extensions +/// (0xFE), plain-text extensions (0x01), and application extensions (0xFF) +/// other than the standard NETSCAPE2.0/ANIMEXTS1.0 looping ones. The relay +/// rejects all of them (`MetadataForbidden` in buzz-media's +/// `validate_gif_metadata_free`), and encoders like Photoshop and Giphy emit +/// them routinely, so uploads fail unless the client drops them first. +/// +/// Everything the relay accepts — header, colour tables, graphic-control +/// extensions, image descriptors and frame data, standard looping extensions — +/// is copied verbatim, so animation timing, disposal, and pixel data stay +/// byte-identical. Trailing bytes after the trailer (another relay reject) are +/// truncated. Returns `None` when the payload isn't structurally parseable as +/// GIF; the caller then uploads the original bytes and the relay's validator +/// remains the authority. +pub(crate) fn strip_gif_metadata(body: &[u8]) -> Option> { + if !(body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a")) || body.len() < 13 { + return None; + } + + let packed = body[10]; + let mut i = 13usize; + if packed & 0x80 != 0 { + let table_len = 3usize << ((packed & 0x07) as usize + 1); + i = i.checked_add(table_len).filter(|&end| end <= body.len())?; + } + + let mut out = Vec::with_capacity(body.len()); + out.extend_from_slice(&body[..i]); + + loop { + match *body.get(i)? { + // Image descriptor: optional local colour table, LZW minimum code + // size, then image-data sub-blocks. Copied verbatim. + 0x2c => { + if i + 10 > body.len() { + return None; + } + let image_packed = body[i + 9]; + let mut end = i + 10; + if image_packed & 0x80 != 0 { + let table_len = 3usize << ((image_packed & 0x07) as usize + 1); + end = end.checked_add(table_len).filter(|&e| e <= body.len())?; + } + end = end.checked_add(1).filter(|&e| e <= body.len())?; + end = gif_sub_blocks_end(body, end)?; + out.extend_from_slice(&body[i..end]); + i = end; + } + 0x21 => { + let label = *body.get(i + 1)?; + let start = i; + i += 2; + match label { + // Graphic Control Extension: fixed-shape rendering state + // (delay, disposal, transparency). Kept verbatim. + 0xf9 => { + if body.get(i) != Some(&4) || i + 6 > body.len() || body[i + 5] != 0 { + return None; + } + i += 6; + out.extend_from_slice(&body[start..i]); + } + // Application extension: keep only the standard looping + // extensions; anything else (XMP, Photoshop, Giphy…) is a + // metadata channel and is dropped. + 0xff => { + if body.get(i) != Some(&11) || i + 12 > body.len() { + return None; + } + let app = &body[i + 1..i + 12]; + let keep = app == b"NETSCAPE2.0" || app == b"ANIMEXTS1.0"; + i = gif_sub_blocks_end(body, i + 12)?; + if keep { + out.extend_from_slice(&body[start..i]); + } + } + // Comment (0xFE), plain-text (0x01), and unknown + // extensions: pure metadata channels, dropped. Their + // bodies are all length-prefixed sub-block sequences + // (plain-text's 12-byte header is itself a sub-block). + _ => { + i = gif_sub_blocks_end(body, i)?; + } + } + } + // Trailer: emit and stop, truncating any trailing bytes. + 0x3b => { + out.push(0x3b); + return Some(out); + } + _ => return None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::media::sanitize_image_for_upload; + + /// Minimal single-frame GIF89a: header, 2×2 logical screen, 2-entry global + /// colour table, NETSCAPE looping extension, graphic control extension, + /// one image descriptor with LZW data, trailer. Structurally canonical — + /// the relay's validator accepts exactly this shape. + fn minimal_gif() -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&[ + 0x02, 0x00, 0x02, 0x00, // logical screen 2×2 + 0x80, 0x00, 0x00, // GCT flag, 2 entries + 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, // colour table + ]); + // NETSCAPE2.0 looping application extension. + gif.extend_from_slice(&[0x21, 0xff, 11]); + gif.extend_from_slice(b"NETSCAPE2.0"); + gif.extend_from_slice(&[3, 0x01, 0x00, 0x00, 0x00]); + // Graphic control extension. + gif.extend_from_slice(&[0x21, 0xf9, 4, 0x00, 0x0a, 0x00, 0x00, 0x00]); + // Image descriptor + 2-bit LZW data. + gif.extend_from_slice(&[0x2c, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00]); + gif.extend_from_slice(&[0x02, 0x02, 0x44, 0x01, 0x00]); + gif.push(0x3b); + gif + } + + /// Comment extension: `0x21 0xFE`, one data sub-block, terminator. + fn gif_comment_ext() -> Vec { + let mut ext = vec![0x21, 0xfe, 5]; + ext.extend_from_slice(b"hello"); + ext.push(0); + ext + } + + /// XMP-style application extension (what Photoshop/Giphy emit). + fn gif_xmp_ext() -> Vec { + let mut ext = vec![0x21, 0xff, 11]; + ext.extend_from_slice(b"XMP DataXMP"); + ext.extend_from_slice(&[4]); + ext.extend_from_slice(b""); + ext.push(0); + ext + } + + #[test] + fn test_strip_gif_metadata_removes_comment_and_foreign_app_extensions() { + let clean = minimal_gif(); + // Splice metadata extensions after the global colour table (offset 19). + let mut dirty = clean[..19].to_vec(); + dirty.extend_from_slice(&gif_comment_ext()); + dirty.extend_from_slice(&gif_xmp_ext()); + dirty.extend_from_slice(&clean[19..]); + + let stripped = strip_gif_metadata(&dirty).unwrap(); + assert_eq!(stripped, clean); + } + + #[test] + fn test_strip_gif_metadata_preserves_clean_gif_byte_identical() { + let clean = minimal_gif(); + assert_eq!(strip_gif_metadata(&clean).unwrap(), clean); + } + + #[test] + fn test_strip_gif_metadata_truncates_bytes_after_trailer() { + let mut padded = minimal_gif(); + padded.extend_from_slice(b"junk after trailer"); + assert_eq!(strip_gif_metadata(&padded).unwrap(), minimal_gif()); + } + + #[test] + fn test_strip_gif_metadata_rejects_unparseable_payloads() { + // Not a GIF at all. + assert!(strip_gif_metadata(b"GIF89a").is_none()); + assert!(strip_gif_metadata(b"\x89PNG\r\n\x1a\n").is_none()); + // Truncated mid-structure. + let clean = minimal_gif(); + assert!(strip_gif_metadata(&clean[..clean.len() - 3]).is_none()); + } + + #[test] + fn test_sanitize_gif_strips_metadata_and_passes_through_unparseable() { + let clean = minimal_gif(); + let mut dirty = clean[..19].to_vec(); + dirty.extend_from_slice(&gif_comment_ext()); + dirty.extend_from_slice(&clean[19..]); + assert_eq!( + sanitize_image_for_upload(dirty, "image/gif").unwrap(), + clean + ); + + // Unparseable GIF payloads pass through unchanged — the relay's + // validator stays the authority. + let junk = b"GIF89a\x00\x01".to_vec(); + assert_eq!( + sanitize_image_for_upload(junk.clone(), "image/gif").unwrap(), + junk + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 92e10c4b9f..cf4cdca91d 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ mod legacy_storage; mod link_preview; pub(crate) mod media; mod media_download; +mod media_gif; mod media_transcode; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm;