diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 0d03429e06..bba338d52c 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -626,6 +626,40 @@ fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { } fn validate_webp_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { + fn validate_frame_payload(payload: &[u8]) -> Result<(), MediaError> { + const FRAME_HEADER_LEN: usize = 16; + if payload.len() < FRAME_HEADER_LEN { + return Err(MediaError::InvalidImage); + } + + let mut i = FRAME_HEADER_LEN; + let mut saw_alpha = false; + let mut saw_image = false; + while i < payload.len() { + if i + 8 > payload.len() { + return Err(MediaError::InvalidImage); + } + let kind: [u8; 4] = payload[i..i + 4].try_into().unwrap(); + let len = u32::from_le_bytes(payload[i + 4..i + 8].try_into().unwrap()) as usize; + let padded = len.checked_add(len & 1).ok_or(MediaError::InvalidImage)?; + i = i + .checked_add(8) + .and_then(|start| start.checked_add(padded)) + .filter(|&end| end <= payload.len()) + .ok_or(MediaError::InvalidImage)?; + + match &kind { + b"ALPH" if !saw_alpha && !saw_image => saw_alpha = true, + b"VP8 " if !saw_image => saw_image = true, + b"VP8L" if !saw_alpha && !saw_image => saw_image = true, + b"ALPH" | b"VP8 " | b"VP8L" => return Err(MediaError::InvalidImage), + _ => return Err(MediaError::MetadataForbidden), + } + } + + saw_image.then_some(()).ok_or(MediaError::InvalidImage) + } + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { return Err(MediaError::InvalidImage); } @@ -659,6 +693,8 @@ fn validate_webp_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { if flags & (0x20 | 0x08 | 0x04) != 0 { return Err(MediaError::MetadataForbidden); } + } else if &kind == b"ANMF" { + validate_frame_payload(&bytes[payload_start..payload_start + len])?; } } Ok(()) @@ -740,7 +776,13 @@ fn validate_gif_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { return Err(MediaError::MetadataForbidden); } i += 12; - skip_sub_blocks(bytes, &mut i)?; + if bytes.get(i) != Some(&3) + || bytes.get(i + 1) != Some(&1) + || bytes.get(i + 4) != Some(&0) + { + return Err(MediaError::MetadataForbidden); + } + i += 5; } _ => return Err(MediaError::MetadataForbidden), } @@ -1296,6 +1338,30 @@ mod tests { Err(MediaError::MetadataForbidden) )); } + let mut frame = vec![0; 16]; + frame.extend_from_slice(b"VP8 "); + frame.extend_from_slice(&3u32.to_le_bytes()); + frame.extend_from_slice(&[1, 2, 3, 0]); + let clean_frame = frame.clone(); + frame.extend_from_slice(b"JUNK"); + frame.extend_from_slice(&8u32.to_le_bytes()); + frame.extend_from_slice(b"location"); + let nested_metadata = webp(&[ + (b"VP8X", &[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (b"ANIM", &[0; 6]), + (b"ANMF", &frame), + ]); + assert!(matches!( + validate_webp_metadata_free(&nested_metadata), + Err(MediaError::MetadataForbidden) + )); + let canonical_animation = webp(&[ + (b"VP8X", &[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (b"ANIM", &[0; 6]), + (b"ANMF", &clean_frame), + ]); + assert!(validate_webp_metadata_free(&canonical_animation).is_ok()); + let mut trailing = webp(&[(b"VP8 ", b"pixels")]); trailing.extend_from_slice(b"hidden"); assert!(matches!( @@ -1327,6 +1393,23 @@ mod tests { validate_gif_metadata_free(&trailing), Err(MediaError::MetadataForbidden) )); + + let mut hidden_in_loop = TINY_GIF[..TINY_GIF.len() - 1].to_vec(); + hidden_in_loop.extend_from_slice(&[0x21, 0xff, 11]); + hidden_in_loop.extend_from_slice(b"NETSCAPE2.0"); + hidden_in_loop.extend_from_slice(&[3, 1, 0, 0, 8]); + hidden_in_loop.extend_from_slice(b"location"); + hidden_in_loop.extend_from_slice(&[0, 0x3b]); + assert!(matches!( + validate_gif_metadata_free(&hidden_in_loop), + Err(MediaError::MetadataForbidden) + )); + + let mut canonical_loop = TINY_GIF[..TINY_GIF.len() - 1].to_vec(); + canonical_loop.extend_from_slice(&[0x21, 0xff, 11]); + canonical_loop.extend_from_slice(b"NETSCAPE2.0"); + canonical_loop.extend_from_slice(&[3, 1, 0, 0, 0, 0x3b]); + assert!(validate_gif_metadata_free(&canonical_loop).is_ok()); } #[test] diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 5aaa45b4a3..1ecf77192a 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -155,9 +155,9 @@ pub(crate) fn sanitize_filename(name: &str) -> String { /// Return true when a PNG/WebP payload declares animation. /// -/// Animated payloads are left byte-identical here so frame timing, looping, -/// and disposal semantics are preserved. The relay's structural validator is -/// still the authority that rejects any metadata-bearing animation. +/// Animated payloads use structural sanitizers so frame timing, looping, and +/// disposal semantics are preserved without flattening the image. The relay's +/// validator remains the final authority for the sanitized container. fn is_animated_image(body: &[u8], mime: &str) -> bool { match mime { "image/png" if body.starts_with(b"\x89PNG\r\n\x1a\n") => { @@ -228,7 +228,40 @@ pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result { + Some("PNG") + } + "image/webp" if super::media_animated::animated_webp_uses_exif_orientation(&body) => { + Some("WebP") + } + _ => None, + }; + if let Some(format) = oriented_format { + return Err(format!( + "animated {format} with EXIF orientation cannot be uploaded without changing its appearance" + )); + } + let color_profile_format = match mime { + "image/png" if super::media_animated::animated_png_uses_icc_profile(&body) => { + Some("PNG") + } + "image/webp" if super::media_animated::animated_webp_uses_icc_profile(&body) => { + Some("WebP") + } + _ => None, + }; + if let Some(format) = color_profile_format { + return Err(format!( + "animated {format} with an ICC profile cannot be uploaded without changing its colors" + )); + } + let stripped = match mime { + "image/png" => super::media_animated::strip_animated_png_metadata(&body), + "image/webp" => super::media_animated::strip_animated_webp_metadata(&body), + _ => None, + }; + return Ok(stripped.unwrap_or(body)); } use image::ImageDecoder; @@ -900,18 +933,12 @@ mod tests { apng.extend_from_slice(&[0; 8]); apng.extend_from_slice(&[0; 4]); assert!(is_animated_image(&apng, "image/png")); - assert_eq!( - sanitize_image_for_upload(apng.clone(), "image/png").unwrap(), - apng - ); + assert!(sanitize_image_for_upload(apng, "image/png").is_ok()); let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec(); webp.extend_from_slice(&0u32.to_le_bytes()); assert!(is_animated_image(&webp, "image/webp")); - assert_eq!( - sanitize_image_for_upload(webp.clone(), "image/webp").unwrap(), - webp - ); + assert!(sanitize_image_for_upload(webp, "image/webp").is_ok()); } #[test] diff --git a/desktop/src-tauri/src/commands/media_animated.rs b/desktop/src-tauri/src/commands/media_animated.rs new file mode 100644 index 0000000000..42dec3f191 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_animated.rs @@ -0,0 +1,693 @@ +//! Structural metadata stripping for animated PNG and WebP uploads. +//! +//! Re-encoding an animated image through `image::DynamicImage` keeps only its +//! first frame. These helpers instead copy rendering chunks byte-for-byte while +//! dropping the metadata channels rejected by the relay. + +const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; +const PNG_ALLOWED_ANCILLARY: &[[u8; 4]] = &[ + *b"cHRM", *b"gAMA", *b"sBIT", *b"sRGB", *b"bKGD", *b"hIST", *b"tRNS", *b"sPLT", *b"acTL", + *b"fcTL", *b"fdAT", +]; +const WEBP_ALLOWED_CHUNKS: &[[u8; 4]] = + &[*b"VP8 ", *b"VP8L", *b"VP8X", *b"ALPH", *b"ANIM", *b"ANMF"]; +const WEBP_METADATA_FLAGS: u8 = 0x20 | 0x08 | 0x04; + +#[derive(Clone, Copy)] +enum TiffEndian { + Little, + Big, +} + +fn tiff_u16(bytes: &[u8], offset: usize, endian: TiffEndian) -> Option { + let value: [u8; 2] = bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?; + Some(match endian { + TiffEndian::Little => u16::from_le_bytes(value), + TiffEndian::Big => u16::from_be_bytes(value), + }) +} + +fn tiff_u32(bytes: &[u8], offset: usize, endian: TiffEndian) -> Option { + let value: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?; + Some(match endian { + TiffEndian::Little => u32::from_le_bytes(value), + TiffEndian::Big => u32::from_be_bytes(value), + }) +} + +fn exif_orientation(payload: &[u8]) -> Option { + let tiff = payload.strip_prefix(b"Exif\0\0").unwrap_or(payload); + let endian = match tiff.get(..2)? { + b"II" => TiffEndian::Little, + b"MM" => TiffEndian::Big, + _ => return None, + }; + if tiff_u16(tiff, 2, endian)? != 42 { + return None; + } + + let ifd_offset = usize::try_from(tiff_u32(tiff, 4, endian)?).ok()?; + let entry_count = usize::from(tiff_u16(tiff, ifd_offset, endian)?); + let entries_start = ifd_offset.checked_add(2)?; + for index in 0..entry_count { + let entry = entries_start.checked_add(index.checked_mul(12)?)?; + if tiff_u16(tiff, entry, endian)? == 0x0112 + && tiff_u16(tiff, entry.checked_add(2)?, endian)? == 3 + && tiff_u32(tiff, entry.checked_add(4)?, endian)? == 1 + { + return tiff_u16(tiff, entry.checked_add(8)?, endian); + } + } + None +} + +fn png_contains_chunk(body: &[u8], target: &[u8; 4]) -> bool { + if !body.starts_with(PNG_SIGNATURE) { + return false; + } + + let mut offset = PNG_SIGNATURE.len(); + while offset < body.len() { + let Some(header_end) = offset.checked_add(8).filter(|&end| end <= body.len()) else { + return false; + }; + let Some(payload_len) = body + .get(offset..offset + 4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_be_bytes) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let Some(kind) = body.get(offset + 4..header_end) else { + return false; + }; + let Some(chunk_end) = header_end + .checked_add(payload_len) + .and_then(|end| end.checked_add(4)) + .filter(|&end| end <= body.len()) + else { + return false; + }; + if kind == target { + return true; + } + offset = chunk_end; + if kind == b"IEND" { + return false; + } + } + false +} + +fn webp_contains_top_level_chunk(body: &[u8], target: &[u8; 4]) -> bool { + if body.len() < 12 || &body[..4] != b"RIFF" || &body[8..12] != b"WEBP" { + return false; + } + let Some(declared) = body + .get(4..8) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let Some(input_end) = declared + .checked_add(8) + .filter(|&end| (12..=body.len()).contains(&end)) + else { + return false; + }; + + let mut offset = 12usize; + while offset < input_end { + let Some(header_end) = offset.checked_add(8).filter(|&end| end <= input_end) else { + return false; + }; + let Some(kind) = body.get(offset..offset + 4) else { + return false; + }; + let Some(payload_len) = body + .get(offset + 4..header_end) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let Some(chunk_end) = payload_len + .checked_add(payload_len & 1) + .and_then(|length| header_end.checked_add(length)) + .filter(|&end| end <= input_end) + else { + return false; + }; + if kind == target { + return true; + } + offset = chunk_end; + } + false +} + +/// Return true when removing an APNG ICC profile would change color rendering. +pub(crate) fn animated_png_uses_icc_profile(body: &[u8]) -> bool { + png_contains_chunk(body, b"iCCP") +} + +/// Return true when removing an animated WebP ICC profile would change colors. +pub(crate) fn animated_webp_uses_icc_profile(body: &[u8]) -> bool { + webp_contains_top_level_chunk(body, b"ICCP") +} + +/// Return true when an animated PNG relies on eXIf to rotate or mirror frames. +pub(crate) fn animated_png_uses_exif_orientation(body: &[u8]) -> bool { + if !body.starts_with(PNG_SIGNATURE) { + return false; + } + + let mut offset = PNG_SIGNATURE.len(); + while offset < body.len() { + let Some(header_end) = offset.checked_add(8).filter(|&end| end <= body.len()) else { + return false; + }; + let Some(payload_len) = body + .get(offset..offset + 4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_be_bytes) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let Some(kind) = body.get(offset + 4..header_end) else { + return false; + }; + let payload_start = header_end; + let Some(chunk_end) = payload_start + .checked_add(payload_len) + .and_then(|end| end.checked_add(4)) + .filter(|&end| end <= body.len()) + else { + return false; + }; + if kind == b"eXIf" + && exif_orientation(&body[payload_start..payload_start + payload_len]) + .is_some_and(|orientation| (2..=8).contains(&orientation)) + { + return true; + } + offset = chunk_end; + if kind == b"IEND" { + return false; + } + } + false +} + +/// Return true when a WebP relies on EXIF to rotate or mirror its frames. +/// +/// Structural metadata removal cannot bake this transform without decoding +/// and re-encoding every frame, so callers must reject this uncommon case +/// rather than silently changing how the animation renders. +pub(crate) fn animated_webp_uses_exif_orientation(body: &[u8]) -> bool { + if body.len() < 12 || &body[..4] != b"RIFF" || &body[8..12] != b"WEBP" { + return false; + } + let Some(declared) = body + .get(4..8) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let Some(input_end) = declared + .checked_add(8) + .filter(|&end| (12..=body.len()).contains(&end)) + else { + return false; + }; + + let mut offset = 12usize; + while offset < input_end { + let Some(header_end) = offset.checked_add(8).filter(|&end| end <= input_end) else { + return false; + }; + let Some(kind) = body.get(offset..offset + 4) else { + return false; + }; + let Some(payload_len) = body + .get(offset + 4..header_end) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let payload_start = header_end; + let Some(chunk_end) = payload_len + .checked_add(payload_len & 1) + .and_then(|length| payload_start.checked_add(length)) + .filter(|&end| end <= input_end) + else { + return false; + }; + if kind == b"EXIF" + && exif_orientation(&body[payload_start..payload_start + payload_len]) + .is_some_and(|orientation| (2..=8).contains(&orientation)) + { + return true; + } + offset = chunk_end; + } + false +} + +/// Strip metadata-bearing ancillary chunks from a PNG without touching frame +/// control or image data. Bytes after `IEND` are truncated. +pub(crate) fn strip_animated_png_metadata(body: &[u8]) -> Option> { + if !body.starts_with(PNG_SIGNATURE) { + return None; + } + + let mut output = Vec::with_capacity(body.len()); + output.extend_from_slice(PNG_SIGNATURE); + let mut offset = PNG_SIGNATURE.len(); + + while offset < body.len() { + let header_end = offset.checked_add(8)?; + if header_end > body.len() { + return None; + } + let payload_len = u32::from_be_bytes(body[offset..offset + 4].try_into().ok()?) as usize; + let kind: [u8; 4] = body[offset + 4..offset + 8].try_into().ok()?; + let chunk_end = offset + .checked_add(12)? + .checked_add(payload_len) + .filter(|&end| end <= body.len())?; + + let ancillary = kind[0] & 0x20 != 0; + if !ancillary || PNG_ALLOWED_ANCILLARY.contains(&kind) { + output.extend_from_slice(&body[offset..chunk_end]); + } + + offset = chunk_end; + if kind == *b"IEND" { + return Some(output); + } + } + + None +} + +fn append_webp_chunk(output: &mut Vec, kind: &[u8; 4], payload: &[u8]) -> Option<()> { + output.extend_from_slice(kind); + output.extend_from_slice(&u32::try_from(payload.len()).ok()?.to_le_bytes()); + output.extend_from_slice(payload); + if payload.len() & 1 != 0 { + output.push(0); + } + Some(()) +} + +fn strip_anmf_metadata(payload: &[u8]) -> Option> { + const FRAME_HEADER_LEN: usize = 16; + if payload.len() < FRAME_HEADER_LEN { + return None; + } + + let mut output = Vec::with_capacity(payload.len()); + output.extend_from_slice(&payload[..FRAME_HEADER_LEN]); + let mut offset = FRAME_HEADER_LEN; + let mut saw_alpha = false; + let mut saw_image = false; + + while offset < payload.len() { + let header_end = offset.checked_add(8).filter(|&end| end <= payload.len())?; + let kind: [u8; 4] = payload[offset..offset + 4].try_into().ok()?; + let chunk_len = + u32::from_le_bytes(payload[offset + 4..header_end].try_into().ok()?) as usize; + let chunk_start = header_end; + let chunk_end = chunk_len + .checked_add(chunk_len & 1) + .and_then(|length| chunk_start.checked_add(length)) + .filter(|&end| end <= payload.len())?; + let chunk_payload = &payload[chunk_start..chunk_start + chunk_len]; + + match &kind { + b"ALPH" if !saw_alpha && !saw_image => { + append_webp_chunk(&mut output, &kind, chunk_payload)?; + saw_alpha = true; + } + b"VP8 " if !saw_image => { + append_webp_chunk(&mut output, &kind, chunk_payload)?; + saw_image = true; + } + b"VP8L" if !saw_alpha && !saw_image => { + append_webp_chunk(&mut output, &kind, chunk_payload)?; + saw_image = true; + } + b"ALPH" | b"VP8 " | b"VP8L" => return None, + _ => {} + } + + offset = chunk_end; + } + + saw_image.then_some(output) +} + +/// Strip metadata chunks and flags from a WebP container while retaining all +/// still/animated rendering chunks. RIFF padding is canonicalized to zero and +/// the container length is rewritten after removals. +pub(crate) fn strip_animated_webp_metadata(body: &[u8]) -> Option> { + if body.len() < 12 || &body[..4] != b"RIFF" || &body[8..12] != b"WEBP" { + return None; + } + + let declared = u32::from_le_bytes(body[4..8].try_into().ok()?) as usize; + let input_end = declared + .checked_add(8) + .filter(|&end| (12..=body.len()).contains(&end))?; + let mut output = Vec::with_capacity(input_end); + output.extend_from_slice(b"RIFF\0\0\0\0WEBP"); + let mut offset = 12usize; + + while offset < input_end { + if offset.checked_add(8)? > input_end { + return None; + } + let kind: [u8; 4] = body[offset..offset + 4].try_into().ok()?; + let payload_len = + u32::from_le_bytes(body[offset + 4..offset + 8].try_into().ok()?) as usize; + let payload_start = offset + 8; + let padded_len = payload_len.checked_add(payload_len & 1)?; + let chunk_end = payload_start + .checked_add(padded_len) + .filter(|&end| end <= input_end)?; + + if WEBP_ALLOWED_CHUNKS.contains(&kind) { + if kind == *b"VP8X" { + let (&flags, rest) = + body[payload_start..payload_start + payload_len].split_first()?; + let mut payload = Vec::with_capacity(payload_len); + payload.push(flags & !WEBP_METADATA_FLAGS); + payload.extend_from_slice(rest); + append_webp_chunk(&mut output, &kind, &payload)?; + } else if kind == *b"ANMF" { + let payload = + strip_anmf_metadata(&body[payload_start..payload_start + payload_len])?; + append_webp_chunk(&mut output, &kind, &payload)?; + } else { + append_webp_chunk( + &mut output, + &kind, + &body[payload_start..payload_start + payload_len], + )?; + } + } + + offset = chunk_end; + } + + let riff_len = u32::try_from(output.len().checked_sub(8)?).ok()?; + output[4..8].copy_from_slice(&riff_len.to_le_bytes()); + Some(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::media::sanitize_image_for_upload; + + fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut chunk = Vec::new(); + chunk.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(payload); + // The structural sanitizer copies CRCs without interpreting them. A + // zero placeholder keeps these focused tests dependency-free. + chunk.extend_from_slice(&[0; 4]); + chunk + } + + fn animated_png(metadata: bool) -> Vec { + let mut png = PNG_SIGNATURE.to_vec(); + png.extend_from_slice(&png_chunk(b"IHDR", &[0; 13])); + png.extend_from_slice(&png_chunk(b"acTL", &[0, 0, 0, 2, 0, 0, 0, 0])); + if metadata { + png.extend_from_slice(&png_chunk(b"tEXt", b"Location\0secret")); + png.extend_from_slice(&png_chunk(b"pHYs", &[0; 9])); + } + png.extend_from_slice(&png_chunk(b"fcTL", &[0; 26])); + png.extend_from_slice(&png_chunk(b"IDAT", &[1, 2, 3])); + png.extend_from_slice(&png_chunk(b"fdAT", &[0, 0, 0, 1, 4, 5])); + png.extend_from_slice(&png_chunk(b"IEND", &[])); + png + } + + fn exif_orientation_payload( + orientation: u16, + endian: TiffEndian, + include_preamble: bool, + ) -> Vec { + let mut exif = if include_preamble { + b"Exif\0\0".to_vec() + } else { + Vec::new() + }; + match endian { + TiffEndian::Little => { + exif.extend_from_slice(b"II"); + exif.extend_from_slice(&42u16.to_le_bytes()); + exif.extend_from_slice(&8u32.to_le_bytes()); + exif.extend_from_slice(&1u16.to_le_bytes()); + exif.extend_from_slice(&0x0112u16.to_le_bytes()); + exif.extend_from_slice(&3u16.to_le_bytes()); + exif.extend_from_slice(&1u32.to_le_bytes()); + exif.extend_from_slice(&orientation.to_le_bytes()); + exif.extend_from_slice(&[0; 2]); + exif.extend_from_slice(&0u32.to_le_bytes()); + } + TiffEndian::Big => { + exif.extend_from_slice(b"MM"); + exif.extend_from_slice(&42u16.to_be_bytes()); + exif.extend_from_slice(&8u32.to_be_bytes()); + exif.extend_from_slice(&1u16.to_be_bytes()); + exif.extend_from_slice(&0x0112u16.to_be_bytes()); + exif.extend_from_slice(&3u16.to_be_bytes()); + exif.extend_from_slice(&1u32.to_be_bytes()); + exif.extend_from_slice(&orientation.to_be_bytes()); + exif.extend_from_slice(&[0; 2]); + exif.extend_from_slice(&0u32.to_be_bytes()); + } + } + exif + } + + fn animated_png_with_orientation(orientation: u16, endian: TiffEndian) -> Vec { + let clean = animated_png(false); + let mut png = clean[..33].to_vec(); + png.extend_from_slice(&png_chunk( + b"eXIf", + &exif_orientation_payload(orientation, endian, false), + )); + png.extend_from_slice(&clean[33..]); + png + } + + fn webp_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut chunk = Vec::new(); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + chunk.extend_from_slice(payload); + if payload.len() & 1 != 0 { + chunk.push(0); + } + chunk + } + + fn animated_webp(metadata: bool) -> Vec { + let metadata_flags = if metadata { WEBP_METADATA_FLAGS } else { 0 }; + let mut chunks = webp_chunk(b"VP8X", &[metadata_flags | 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + chunks.extend_from_slice(&webp_chunk(b"ANIM", &[0; 6])); + if metadata { + chunks.extend_from_slice(&webp_chunk(b"EXIF", b"location")); + chunks.extend_from_slice(&webp_chunk(b"XMP ", b"")); + chunks.extend_from_slice(&webp_chunk(b"JUNK", b"private")); + } + let mut frame = vec![0; 16]; + frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3])); + chunks.extend_from_slice(&webp_chunk(b"ANMF", &frame)); + + let mut webp = b"RIFF".to_vec(); + webp.extend_from_slice(&((chunks.len() + 4) as u32).to_le_bytes()); + webp.extend_from_slice(b"WEBP"); + webp.extend_from_slice(&chunks); + webp + } + + fn animated_webp_with_orientation(orientation: u16, endian: TiffEndian) -> Vec<u8> { + let exif = exif_orientation_payload(orientation, endian, true); + let mut chunks = webp_chunk( + b"VP8X", + &[WEBP_METADATA_FLAGS | 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ); + chunks.extend_from_slice(&webp_chunk(b"ANIM", &[0; 6])); + chunks.extend_from_slice(&webp_chunk(b"EXIF", &exif)); + let mut frame = vec![0; 16]; + frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3])); + chunks.extend_from_slice(&webp_chunk(b"ANMF", &frame)); + let mut webp = b"RIFF".to_vec(); + webp.extend_from_slice(&((chunks.len() + 4) as u32).to_le_bytes()); + webp.extend_from_slice(b"WEBP"); + webp.extend_from_slice(&chunks); + webp + } + + #[test] + fn test_strip_animated_png_metadata_preserves_animation_chunks() { + assert_eq!( + strip_animated_png_metadata(&animated_png(true)), + Some(animated_png(false)) + ); + } + + #[test] + fn test_strip_animated_png_metadata_is_byte_identical_for_clean_input() { + let clean = animated_png(false); + assert_eq!(strip_animated_png_metadata(&clean), Some(clean)); + } + + #[test] + fn test_strip_animated_webp_metadata_preserves_animation_chunks() { + assert_eq!( + strip_animated_webp_metadata(&animated_webp(true)), + Some(animated_webp(false)) + ); + } + + #[test] + fn test_strip_animated_webp_metadata_is_byte_identical_for_clean_input() { + let clean = animated_webp(false); + assert_eq!(strip_animated_webp_metadata(&clean), Some(clean)); + } + + #[test] + fn test_detects_non_identity_animated_webp_exif_orientation() { + for endian in [TiffEndian::Little, TiffEndian::Big] { + assert!(!animated_webp_uses_exif_orientation( + &animated_webp_with_orientation(1, endian) + )); + assert!(animated_webp_uses_exif_orientation( + &animated_webp_with_orientation(6, endian) + )); + } + } + + #[test] + fn test_detects_non_identity_animated_png_exif_orientation() { + for endian in [TiffEndian::Little, TiffEndian::Big] { + assert!(!animated_png_uses_exif_orientation( + &animated_png_with_orientation(1, endian) + )); + assert!(animated_png_uses_exif_orientation( + &animated_png_with_orientation(6, endian) + )); + } + } + + #[test] + fn test_detects_animated_icc_profiles() { + let clean_png = animated_png(false); + let mut png = clean_png[..33].to_vec(); + png.extend_from_slice(&png_chunk(b"iCCP", b"profile")); + png.extend_from_slice(&clean_png[33..]); + assert!(animated_png_uses_icc_profile(&png)); + assert!(sanitize_image_for_upload(png, "image/png").is_err()); + assert!(!animated_png_uses_icc_profile(&clean_png)); + + let mut chunks = webp_chunk( + b"VP8X", + &[WEBP_METADATA_FLAGS | 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ); + chunks.extend_from_slice(&webp_chunk(b"ICCP", b"profile")); + chunks.extend_from_slice(&animated_webp(false)[30..]); + let mut webp = b"RIFF".to_vec(); + webp.extend_from_slice(&((chunks.len() + 4) as u32).to_le_bytes()); + webp.extend_from_slice(b"WEBP"); + webp.extend_from_slice(&chunks); + assert!(animated_webp_uses_icc_profile(&webp)); + assert!(sanitize_image_for_upload(webp, "image/webp").is_err()); + assert!(!animated_webp_uses_icc_profile(&animated_webp(false))); + } + + #[test] + fn test_strip_animated_webp_removes_nested_frame_metadata() { + let mut dirty_frame = vec![0; 16]; + dirty_frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3])); + dirty_frame.extend_from_slice(&webp_chunk(b"JUNK", b"location")); + let mut clean_frame = vec![0; 16]; + clean_frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3])); + + assert_eq!(strip_anmf_metadata(&dirty_frame), Some(clean_frame.clone())); + + clean_frame.extend_from_slice(&webp_chunk(b"VP8L", &[4])); + assert!(strip_anmf_metadata(&clean_frame).is_none()); + } + + #[test] + fn test_animated_sanitizers_truncate_trailing_bytes() { + let clean_png = animated_png(false); + let mut padded_png = clean_png.clone(); + padded_png.extend_from_slice(b"trailing metadata"); + assert_eq!(strip_animated_png_metadata(&padded_png), Some(clean_png)); + + let clean_webp = animated_webp(false); + let mut padded_webp = clean_webp.clone(); + padded_webp.extend_from_slice(b"trailing metadata"); + assert_eq!(strip_animated_webp_metadata(&padded_webp), Some(clean_webp)); + } + + #[test] + fn test_animated_sanitizers_reject_malformed_containers() { + assert!(strip_animated_png_metadata(PNG_SIGNATURE).is_none()); + assert!(strip_animated_webp_metadata(b"RIFF\x20\0\0\0WEBP").is_none()); + } + + #[test] + fn test_upload_sanitizer_uses_structural_animation_scrubbers() { + assert_eq!( + sanitize_image_for_upload(animated_png(true), "image/png"), + Ok(animated_png(false)) + ); + assert_eq!( + sanitize_image_for_upload(animated_webp(true), "image/webp"), + Ok(animated_webp(false)) + ); + assert_eq!( + sanitize_image_for_upload( + animated_webp_with_orientation(1, TiffEndian::Little), + "image/webp" + ), + Ok(animated_webp(false)) + ); + assert_eq!( + sanitize_image_for_upload( + animated_png_with_orientation(1, TiffEndian::Little), + "image/png" + ), + Ok(animated_png(false)) + ); + assert!(sanitize_image_for_upload( + animated_webp_with_orientation(6, TiffEndian::Little), + "image/webp" + ) + .is_err()); + assert!(sanitize_image_for_upload( + animated_png_with_orientation(6, TiffEndian::Little), + "image/png" + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/media_gif.rs b/desktop/src-tauri/src/commands/media_gif.rs index be2ea1a251..ac5814d41c 100644 --- a/desktop/src-tauri/src/commands/media_gif.rs +++ b/desktop/src-tauri/src/commands/media_gif.rs @@ -92,9 +92,17 @@ pub(crate) fn strip_gif_metadata(body: &[u8]) -> Option<Vec<u8>> { } 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)?; + let data_start = i + 12; + i = gif_sub_blocks_end(body, data_start)?; if keep { - out.extend_from_slice(&body[start..i]); + if body.get(data_start) != Some(&3) + || body.get(data_start + 1) != Some(&1) + || data_start.checked_add(5)? > body.len() + { + return None; + } + out.extend_from_slice(&body[start..data_start + 4]); + out.push(0); } } // Comment (0xFE), plain-text (0x01), and unknown @@ -182,6 +190,17 @@ mod tests { assert_eq!(strip_gif_metadata(&clean).unwrap(), clean); } + #[test] + fn test_strip_gif_metadata_canonicalizes_loop_extension() { + let clean = minimal_gif(); + let mut dirty = clean[..37].to_vec(); + dirty.extend_from_slice(&[8]); + dirty.extend_from_slice(b"location"); + dirty.push(0); + dirty.extend_from_slice(&clean[38..]); + assert_eq!(strip_gif_metadata(&dirty).unwrap(), clean); + } + #[test] fn test_strip_gif_metadata_truncates_bytes_after_trailer() { let mut padded = minimal_gif(); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index cf4cdca91d..77080e930d 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -23,6 +23,7 @@ mod identity_archive; mod legacy_storage; mod link_preview; pub(crate) mod media; +mod media_animated; mod media_download; mod media_gif; mod media_transcode; diff --git a/mobile/lib/shared/relay/animated_image_sanitizer.dart b/mobile/lib/shared/relay/animated_image_sanitizer.dart new file mode 100644 index 0000000000..6067d65fb9 --- /dev/null +++ b/mobile/lib/shared/relay/animated_image_sanitizer.dart @@ -0,0 +1,443 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +const _pngSignature = <int>[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const _allowedPngAncillaryChunks = { + 'cHRM', + 'gAMA', + 'sBIT', + 'sRGB', + 'bKGD', + 'hIST', + 'tRNS', + 'sPLT', + 'acTL', + 'fcTL', + 'fdAT', +}; +const _allowedWebpChunks = {'VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF'}; +const _webpMetadataFlags = 0x20 | 0x08 | 0x04; + +/// Remove metadata from an animated image without decoding its frames. +/// +/// Decoding through UIKit or Android Bitmap would flatten animations. These +/// structural scrubbers retain only the chunks/extensions accepted by the +/// relay and preserve animation timing, disposal, looping, and frame data. +Uint8List sanitizeAnimatedImageForUpload(Uint8List bytes, String mimeType) { + return switch (mimeType) { + 'image/gif' => _scrubGif(bytes), + 'image/png' => _scrubPng(bytes), + 'image/webp' => _scrubWebp(bytes), + _ => throw FormatException('Unsupported animated image type: $mimeType'), + }; +} + +Uint8List _scrubPng(Uint8List bytes) { + if (!_startsWith(bytes, _pngSignature)) { + throw const FormatException('Invalid PNG signature'); + } + + final output = BytesBuilder(copy: false)..add(_pngSignature); + var offset = _pngSignature.length; + while (offset < bytes.length) { + if (bytes.length - offset < 12) { + throw const FormatException('Truncated PNG chunk'); + } + final payloadLength = _readUint32BigEndian(bytes, offset); + final chunkLength = payloadLength + 12; + if (payloadLength > bytes.length - offset - 12) { + throw const FormatException('Invalid PNG chunk length'); + } + final typeStart = offset + 4; + final type = ascii.decode(bytes.sublist(typeStart, typeStart + 4)); + if (type == 'iCCP') { + throw const FormatException( + 'Animated PNG ICC profile cannot be removed safely', + ); + } + if (type == 'eXIf') { + final orientation = _readExifOrientation( + bytes, + offset + 8, + payloadLength, + ); + if (orientation != null && orientation >= 2 && orientation <= 8) { + throw const FormatException( + 'Animated PNG EXIF orientation cannot be removed safely', + ); + } + } + final isAncillary = bytes[typeStart] & 0x20 != 0; + if (!isAncillary || _allowedPngAncillaryChunks.contains(type)) { + output.add(Uint8List.sublistView(bytes, offset, offset + chunkLength)); + } + + offset += chunkLength; + if (type == 'IEND') { + return output.takeBytes(); + } + } + + throw const FormatException('PNG is missing IEND'); +} + +Uint8List _scrubWebp(Uint8List bytes) { + if (bytes.length < 12 || + !_matchesAscii(bytes, 0, 'RIFF') || + !_matchesAscii(bytes, 8, 'WEBP')) { + throw const FormatException('Invalid WebP signature'); + } + + final declaredLength = _readUint32LittleEndian(bytes, 4); + final inputEnd = declaredLength + 8; + if (inputEnd < 12 || inputEnd > bytes.length) { + throw const FormatException('Invalid WebP container length'); + } + + final chunks = BytesBuilder(copy: false); + var offset = 12; + while (offset < inputEnd) { + if (inputEnd - offset < 8) { + throw const FormatException('Truncated WebP chunk'); + } + final type = ascii.decode(bytes.sublist(offset, offset + 4)); + final payloadLength = _readUint32LittleEndian(bytes, offset + 4); + final payloadStart = offset + 8; + final paddedLength = payloadLength + (payloadLength.isOdd ? 1 : 0); + final chunkEnd = payloadStart + paddedLength; + if (chunkEnd > inputEnd) { + throw const FormatException('Invalid WebP chunk length'); + } + + if (type == 'EXIF') { + final orientation = _readExifOrientation( + bytes, + payloadStart, + payloadLength, + ); + if (orientation != null && orientation >= 2 && orientation <= 8) { + throw const FormatException( + 'Animated WebP EXIF orientation cannot be removed safely', + ); + } + } + if (type == 'ICCP') { + throw const FormatException( + 'Animated WebP ICC profile cannot be removed safely', + ); + } + + if (_allowedWebpChunks.contains(type)) { + if (type == 'VP8X') { + if (payloadLength == 0) { + throw const FormatException('Invalid VP8X chunk'); + } + final payload = BytesBuilder(copy: false) + ..addByte(bytes[payloadStart] & ~_webpMetadataFlags) + ..add( + Uint8List.sublistView( + bytes, + payloadStart + 1, + payloadStart + payloadLength, + ), + ); + _addWebpChunk(chunks, type, payload.takeBytes()); + } else if (type == 'ANMF') { + _addWebpChunk( + chunks, + type, + _scrubAnmfPayload( + Uint8List.sublistView( + bytes, + payloadStart, + payloadStart + payloadLength, + ), + ), + ); + } else { + _addWebpChunk( + chunks, + type, + Uint8List.sublistView( + bytes, + payloadStart, + payloadStart + payloadLength, + ), + ); + } + } + offset = chunkEnd; + } + + final chunkBytes = chunks.takeBytes(); + final output = BytesBuilder(copy: false) + ..add(ascii.encode('RIFF')) + ..add(_uint32LittleEndian(chunkBytes.length + 4)) + ..add(ascii.encode('WEBP')) + ..add(chunkBytes); + return output.takeBytes(); +} + +void _addWebpChunk(BytesBuilder output, String type, Uint8List payload) { + output + ..add(ascii.encode(type)) + ..add(_uint32LittleEndian(payload.length)) + ..add(payload); + if (payload.length.isOdd) output.addByte(0); +} + +Uint8List _scrubAnmfPayload(Uint8List payload) { + const frameHeaderLength = 16; + if (payload.length < frameHeaderLength) { + throw const FormatException('Invalid WebP animation frame'); + } + + final output = BytesBuilder(copy: false) + ..add(Uint8List.sublistView(payload, 0, frameHeaderLength)); + var offset = frameHeaderLength; + var sawAlpha = false; + var sawImage = false; + + while (offset < payload.length) { + if (payload.length - offset < 8) { + throw const FormatException('Truncated WebP animation frame chunk'); + } + final chunkLength = _readUint32LittleEndian(payload, offset + 4); + final chunkStart = offset + 8; + final paddedLength = chunkLength + (chunkLength.isOdd ? 1 : 0); + final chunkEnd = chunkStart + paddedLength; + if (chunkEnd > payload.length) { + throw const FormatException('Invalid WebP animation frame chunk length'); + } + final chunkPayload = Uint8List.sublistView( + payload, + chunkStart, + chunkStart + chunkLength, + ); + + if (_matchesAscii(payload, offset, 'ALPH')) { + if (sawAlpha || sawImage) { + throw const FormatException('Invalid WebP animation frame layout'); + } + _addWebpChunk(output, 'ALPH', chunkPayload); + sawAlpha = true; + } else if (_matchesAscii(payload, offset, 'VP8 ')) { + if (sawImage) { + throw const FormatException('Invalid WebP animation frame layout'); + } + _addWebpChunk(output, 'VP8 ', chunkPayload); + sawImage = true; + } else if (_matchesAscii(payload, offset, 'VP8L')) { + if (sawAlpha || sawImage) { + throw const FormatException('Invalid WebP animation frame layout'); + } + _addWebpChunk(output, 'VP8L', chunkPayload); + sawImage = true; + } + + offset = chunkEnd; + } + + if (!sawImage) { + throw const FormatException('WebP animation frame is missing image data'); + } + return output.takeBytes(); +} + +int? _readExifOrientation( + Uint8List bytes, + int payloadStart, + int payloadLength, +) { + final payloadEnd = payloadStart + payloadLength; + var tiffStart = payloadStart; + if (payloadLength >= 6 && + _matchesAscii(bytes, payloadStart, 'Exif') && + bytes[payloadStart + 4] == 0 && + bytes[payloadStart + 5] == 0) { + tiffStart += 6; + } + if (payloadEnd - tiffStart < 8) return null; + + final endian = switch ((bytes[tiffStart], bytes[tiffStart + 1])) { + (0x49, 0x49) => Endian.little, + (0x4d, 0x4d) => Endian.big, + _ => null, + }; + if (endian == null) return null; + + int? readUint16(int offset) { + if (offset < tiffStart || offset + 2 > payloadEnd) return null; + return ByteData.sublistView(bytes, offset, offset + 2).getUint16(0, endian); + } + + int? readUint32(int offset) { + if (offset < tiffStart || offset + 4 > payloadEnd) return null; + return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0, endian); + } + + if (readUint16(tiffStart + 2) != 42) return null; + final ifdOffset = readUint32(tiffStart + 4); + if (ifdOffset == null) return null; + final ifdStart = tiffStart + ifdOffset; + final entryCount = readUint16(ifdStart); + if (entryCount == null) return null; + + final entriesStart = ifdStart + 2; + for (var index = 0; index < entryCount; index += 1) { + final entryStart = entriesStart + index * 12; + if (readUint16(entryStart) == 0x0112 && + readUint16(entryStart + 2) == 3 && + readUint32(entryStart + 4) == 1) { + return readUint16(entryStart + 8); + } + } + return null; +} + +Uint8List _scrubGif(Uint8List bytes) { + if (bytes.length < 13 || + (!_matchesAscii(bytes, 0, 'GIF87a') && + !_matchesAscii(bytes, 0, 'GIF89a'))) { + throw const FormatException('Invalid GIF signature'); + } + + var offset = 13; + final packed = bytes[10]; + if (packed & 0x80 != 0) { + final tableLength = 3 << ((packed & 0x07) + 1); + offset += tableLength; + if (offset > bytes.length) { + throw const FormatException('Truncated GIF color table'); + } + } + + final segments = <Uint8List?>[Uint8List.sublistView(bytes, 0, offset)]; + final pendingGraphicControls = <int>[]; + + while (offset < bytes.length) { + switch (bytes[offset]) { + case 0x2c: + final start = offset; + if (bytes.length - offset < 10) { + throw const FormatException('Truncated GIF image descriptor'); + } + final imagePacked = bytes[offset + 9]; + offset += 10; + if (imagePacked & 0x80 != 0) { + offset += 3 << ((imagePacked & 0x07) + 1); + if (offset > bytes.length) { + throw const FormatException('Truncated GIF local color table'); + } + } + if (offset >= bytes.length) { + throw const FormatException('Missing GIF LZW code size'); + } + offset = _gifSubBlocksEnd(bytes, offset + 1); + segments.add(Uint8List.sublistView(bytes, start, offset)); + pendingGraphicControls.clear(); + case 0x21: + final start = offset; + if (bytes.length - offset < 2) { + throw const FormatException('Truncated GIF extension'); + } + final label = bytes[offset + 1]; + offset += 2; + switch (label) { + case 0xf9: + if (bytes.length - offset < 6 || + bytes[offset] != 4 || + bytes[offset + 5] != 0) { + throw const FormatException('Invalid GIF graphic control'); + } + offset += 6; + segments.add(Uint8List.sublistView(bytes, start, offset)); + pendingGraphicControls.add(segments.length - 1); + case 0xff: + if (bytes.length - offset < 12 || bytes[offset] != 11) { + throw const FormatException('Invalid GIF application extension'); + } + final isLoopExtension = + _matchesAscii(bytes, offset + 1, 'NETSCAPE2.0') || + _matchesAscii(bytes, offset + 1, 'ANIMEXTS1.0'); + final dataStart = offset + 12; + offset = _gifSubBlocksEnd(bytes, dataStart); + if (isLoopExtension) { + if (bytes.length - dataStart < 5 || + bytes[dataStart] != 3 || + bytes[dataStart + 1] != 1) { + throw const FormatException('Invalid GIF loop extension'); + } + segments + ..add(Uint8List.sublistView(bytes, start, dataStart + 4)) + ..add(Uint8List(1)); + } + case 0x01: + offset = _gifSubBlocksEnd(bytes, offset); + for (final segmentIndex in pendingGraphicControls) { + segments[segmentIndex] = null; + } + pendingGraphicControls.clear(); + default: + offset = _gifSubBlocksEnd(bytes, offset); + } + case 0x3b: + segments.add(Uint8List.sublistView(bytes, offset, offset + 1)); + final output = BytesBuilder(copy: false); + for (final segment in segments) { + if (segment != null) output.add(segment); + } + return output.takeBytes(); + default: + throw const FormatException('Invalid GIF block'); + } + } + + throw const FormatException('GIF is missing trailer'); +} + +int _gifSubBlocksEnd(Uint8List bytes, int offset) { + while (offset < bytes.length) { + final blockLength = bytes[offset]; + offset += 1; + if (blockLength == 0) return offset; + offset += blockLength; + if (offset > bytes.length) { + throw const FormatException('Truncated GIF data block'); + } + } + throw const FormatException('GIF data blocks are missing a terminator'); +} + +bool _startsWith(Uint8List bytes, List<int> prefix) { + if (bytes.length < prefix.length) return false; + for (var index = 0; index < prefix.length; index += 1) { + if (bytes[index] != prefix[index]) return false; + } + return true; +} + +bool _matchesAscii(Uint8List bytes, int offset, String value) { + final expected = ascii.encode(value); + if (bytes.length - offset < expected.length) return false; + for (var index = 0; index < expected.length; index += 1) { + if (bytes[offset + index] != expected[index]) return false; + } + return true; +} + +int _readUint32BigEndian(Uint8List bytes, int offset) { + return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0); +} + +int _readUint32LittleEndian(Uint8List bytes, int offset) { + return ByteData.sublistView( + bytes, + offset, + offset + 4, + ).getUint32(0, Endian.little); +} + +Uint8List _uint32LittleEndian(int value) { + return Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little); +} diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index be84f548ce..b868fc2845 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -9,6 +9,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import 'animated_image_sanitizer.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; import 'relay_provider.dart'; @@ -37,16 +38,14 @@ final _mediaUploadPlatformChannel = MethodChannel( _mediaUploadPlatformChannelName, ); -const _allowedImageMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; +const _allowedImageMimeTypes = { + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +}; const _allowedVideoMimeTypes = {'video/mp4'}; const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB -const _unsupportedAnimatedImageMimeTypes = {'image/gif'}; -const _unsupportedGifUploadMessage = - 'GIF uploads are not supported on mobile yet'; -const _unsupportedAnimatedPngUploadMessage = - 'Animated PNG uploads are not supported on mobile yet'; -const _unsupportedAnimatedWebpUploadMessage = - 'Animated WebP uploads are not supported on mobile yet'; const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future<XFile?> Function(); @@ -179,7 +178,10 @@ class MediaUploadService { Future<BlobDescriptor> uploadImage(XFile image) async { final preparedImage = await _prepareUploadImage(image); - return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); + return _uploadPreparedBytes( + preparedImage.bytes, + mimeType: preparedImage.mimeType, + ); } Future<bool> clipboardHasImage() async { @@ -236,7 +238,22 @@ class MediaUploadService { Uint8List bytes, { required String mimeType, }) async { - _validateUpload(bytes, mimeType); + if (mimeType == 'image/gif' || + (mimeType == 'image/png' && _isAnimatedPng(bytes)) || + (mimeType == 'image/webp' && _isAnimatedWebp(bytes))) { + try { + bytes = sanitizeAnimatedImageForUpload(bytes, mimeType); + } on FormatException { + throw Exception('failed to sanitize image for upload'); + } + } + return _uploadPreparedBytes(bytes, mimeType: mimeType); + } + + Future<BlobDescriptor> _uploadPreparedBytes( + Uint8List bytes, { + required String mimeType, + }) async { if (!_allowedImageMimeTypes.contains(mimeType) && !_allowedVideoMimeTypes.contains(mimeType)) { throw Exception('unsupported file type: $mimeType'); @@ -360,7 +377,6 @@ class MediaUploadService { Uint8List bytes, String mimeType, ) async { - _validateUpload(bytes, mimeType); final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType); return _buildPreparedUploadImage(preparedBytes); } @@ -383,6 +399,16 @@ class MediaUploadService { Uint8List bytes, String mimeType, ) async { + if (mimeType == 'image/gif' || + (mimeType == 'image/png' && _isAnimatedPng(bytes)) || + (mimeType == 'image/webp' && _isAnimatedWebp(bytes))) { + try { + return sanitizeAnimatedImageForUpload(bytes, mimeType); + } on FormatException { + throw Exception('failed to sanitize image for upload'); + } + } + if (!_shouldSanitizePickedImage(mimeType)) { return bytes; } @@ -408,18 +434,6 @@ String? _tryDetectImageMimeType(Uint8List bytes) { } } -void _validateUpload(Uint8List bytes, String mimeType) { - if (_unsupportedAnimatedImageMimeTypes.contains(mimeType)) { - throw Exception(_unsupportedGifUploadMessage); - } - if (mimeType == 'image/png' && _isAnimatedPng(bytes)) { - throw Exception(_unsupportedAnimatedPngUploadMessage); - } - if (mimeType == 'image/webp' && _isAnimatedWebp(bytes)) { - throw Exception(_unsupportedAnimatedWebpUploadMessage); - } -} - String _detectImageMimeType(Uint8List bytes) { if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) { return 'image/jpeg'; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index b8efada026..dd629dcf88 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -41,66 +41,88 @@ final _pngBytes = Uint8List.fromList([ ]); final _gifBytes = Uint8List.fromList([ - 0x47, - 0x49, - 0x46, - 0x38, - 0x39, - 0x61, - 0x01, - 0x00, - 0x01, - 0x00, -]); - -final _apngBytes = Uint8List.fromList([ - 0x89, - 0x50, - 0x4e, - 0x47, - 0x0d, - 0x0a, - 0x1a, - 0x0a, - 0x00, + ...ascii.encode('GIF89a'), + 0x02, 0x00, + 0x02, 0x00, - 0x08, - 0x61, - 0x63, - 0x54, - 0x4c, + 0x80, 0x00, 0x00, 0x00, - 0x02, 0x00, 0x00, + 0xff, + 0xff, + 0xff, + 0x21, + 0xfe, + 0x05, + ...ascii.encode('hello'), 0x00, + 0x21, + 0xff, + 0x0b, + ...ascii.encode('NETSCAPE2.0'), + 0x03, + 0x01, 0x00, 0x00, 0x00, + 0x21, + 0xf9, + 0x04, 0x00, + 0x0a, 0x00, 0x00, 0x00, + 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, + 0x02, 0x00, 0x00, - 0x49, - 0x45, - 0x4e, + 0x02, + 0x02, 0x44, + 0x01, 0x00, - 0x00, - 0x00, - 0x00, + 0x3b, ]); +final _apngBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ..._testPngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]), + ..._testPngChunk('IEND', const []), +]); + +List<int> _testPngChunk(String type, List<int> payload) { + return [ + payload.length >> 24 & 0xff, + payload.length >> 16 & 0xff, + payload.length >> 8 & 0xff, + payload.length & 0xff, + ...ascii.encode(type), + ...payload, + 0, + 0, + 0, + 0, + ]; +} + const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload'); void _setMockMediaUploadPlatformHandler( @@ -858,12 +880,25 @@ void main() { }); } - testWidgets('shows a clean error when a GIF is picked', (tester) async { + testWidgets('adds a sanitized GIF attachment', (tester) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/animated.gif', + 'sha256': + '4444444444444444444444444444444444444444444444444444444444444444', + 'size': request.bodyBytes.length, + 'type': 'image/gif', + 'uploaded': 1, + }), + 200, + ); + }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), @@ -887,7 +922,11 @@ void main() { await tester.pumpAndSettle(); expect( - find.textContaining('GIF uploads are not supported on mobile yet'), + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/animated.gif', + ), + ), findsOneWidget, ); }); @@ -1067,14 +1106,25 @@ void main() { expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty); }); - testWidgets('shows a clean error when an animated PNG is picked', ( - tester, - ) async { + testWidgets('adds a sanitized animated PNG attachment', (tester) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/animated.png', + 'sha256': + '5555555555555555555555555555555555555555555555555555555555555555', + 'size': request.bodyBytes.length, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), @@ -1098,7 +1148,11 @@ void main() { await tester.pumpAndSettle(); expect( - find.textContaining('Animated PNG uploads are not supported on mobile'), + find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/animated.png', + ), + ), findsOneWidget, ); }); diff --git a/mobile/test/shared/relay/animated_image_sanitizer_test.dart b/mobile/test/shared/relay/animated_image_sanitizer_test.dart new file mode 100644 index 0000000000..515a81b483 --- /dev/null +++ b/mobile/test/shared/relay/animated_image_sanitizer_test.dart @@ -0,0 +1,469 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/relay/animated_image_sanitizer.dart'; + +void main() { + test('strips APNG metadata without changing animation chunks', () { + final clean = _animatedPng(metadata: false); + final dirty = _animatedPng(metadata: true) + ..addAll(utf8.encode('trailing metadata')); + + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/png'), + clean, + ); + }); + + test('strips identity APNG orientation but rejects display transforms', () { + final clean = _animatedPng(metadata: false); + expect( + sanitizeAnimatedImageForUpload( + Uint8List.fromList(_animatedPng(metadata: false, orientation: 1)), + 'image/png', + ), + clean, + ); + + for (final endian in [Endian.little, Endian.big]) { + expect( + () => sanitizeAnimatedImageForUpload( + Uint8List.fromList( + _animatedPng( + metadata: false, + orientation: 6, + orientationEndian: endian, + ), + ), + 'image/png', + ), + throwsA( + isA<FormatException>().having( + (error) => error.message, + 'message', + contains('orientation'), + ), + ), + ); + } + }); + + test('rejects APNG ICC profiles that affect color rendering', () { + expect( + () => sanitizeAnimatedImageForUpload( + Uint8List.fromList(_animatedPng(metadata: false, iccProfile: true)), + 'image/png', + ), + throwsA( + isA<FormatException>().having( + (error) => error.message, + 'message', + contains('ICC profile'), + ), + ), + ); + }); + + test('strips animated WebP metadata and clears metadata flags', () { + final clean = _animatedWebp(metadata: false); + final dirty = _animatedWebp(metadata: true) + ..addAll(utf8.encode('trailing metadata')); + + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/webp'), + clean, + ); + }); + + test('strips identity WebP orientation but rejects display transforms', () { + final clean = _animatedWebp(metadata: false); + expect( + sanitizeAnimatedImageForUpload( + Uint8List.fromList(_animatedWebp(metadata: false, orientation: 1)), + 'image/webp', + ), + clean, + ); + + for (final endian in [Endian.little, Endian.big]) { + expect( + () => sanitizeAnimatedImageForUpload( + Uint8List.fromList( + _animatedWebp( + metadata: false, + orientation: 6, + orientationEndian: endian, + ), + ), + 'image/webp', + ), + throwsA( + isA<FormatException>().having( + (error) => error.message, + 'message', + contains('orientation'), + ), + ), + ); + } + }); + + test('rejects animated WebP ICC profiles that affect color rendering', () { + expect( + () => sanitizeAnimatedImageForUpload( + Uint8List.fromList(_animatedWebp(metadata: false, iccProfile: true)), + 'image/webp', + ), + throwsA( + isA<FormatException>().having( + (error) => error.message, + 'message', + contains('ICC profile'), + ), + ), + ); + }); + + test('removes metadata chunks nested inside animated WebP frames', () { + expect( + sanitizeAnimatedImageForUpload( + Uint8List.fromList( + _animatedWebp(metadata: false, nestedMetadata: true), + ), + 'image/webp', + ), + _animatedWebp(metadata: false), + ); + }); + + test('strips GIF metadata without changing animation blocks', () { + final clean = _minimalGif(); + final dirty = <int>[ + ...clean.sublist(0, 19), + ..._gifCommentExtension(), + ..._gifApplicationExtension('XMP DataXMP', utf8.encode('<x/>')), + ...clean.sublist(19), + ...utf8.encode('trailing metadata'), + ]; + + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'), + clean, + ); + }); + + test('canonicalizes GIF loop extensions with hidden sub-blocks', () { + final clean = _minimalGif(); + final cleanLoop = _gifLoopExtension(); + final dirty = <int>[ + ...clean.sublist(0, 19), + ..._gifLoopExtension(extra: utf8.encode('location')), + ...clean.sublist(19 + cleanLoop.length), + ]; + + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'), + clean, + ); + }); + + test('drops GIF applications with binary authentication codes', () { + final clean = _minimalGif(); + final dirty = <int>[ + ...clean.sublist(0, 19), + ..._gifApplicationExtensionBytes([ + ...ascii.encode('FOREIGN1'), + 0xff, + 0x80, + 0x00, + ], utf8.encode('private')), + ...clean.sublist(19), + ]; + + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'), + clean, + ); + }); + + test('removes a GIF graphic control consumed by stripped plain text', () { + final clean = _minimalGif(); + final dirty = <int>[ + ...clean.sublist(0, 19), + 0x21, + 0xf9, + 4, + 0x09, + 0x1e, + 0, + 1, + 0, + ..._gifPlainTextExtension(), + ...clean.sublist(19), + ]; + + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'), + clean, + ); + }); + + test('keeps clean animated containers byte-identical', () { + for (final (mimeType, bytes) in [ + ('image/png', _animatedPng(metadata: false)), + ('image/webp', _animatedWebp(metadata: false)), + ('image/gif', _minimalGif()), + ]) { + expect( + sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType), + bytes, + ); + } + }); + + test('fails closed for malformed animated containers', () { + for (final (mimeType, bytes) in [ + ('image/png', <int>[0x89, 0x50, 0x4e, 0x47]), + ('image/webp', ascii.encode('RIFFxxxxWEBP')), + ('image/gif', ascii.encode('GIF89a')), + ]) { + expect( + () => + sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType), + throwsFormatException, + ); + } + }); +} + +List<int> _pngChunk(String type, List<int> payload) { + return [ + ..._uint32BigEndian(payload.length), + ...ascii.encode(type), + ...payload, + 0, + 0, + 0, + 0, + ]; +} + +List<int> _animatedPng({ + required bool metadata, + int? orientation, + Endian orientationEndian = Endian.little, + bool iccProfile = false, +}) { + return [ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ..._pngChunk('IHDR', List.filled(13, 0)), + ..._pngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]), + if (iccProfile) ..._pngChunk('iCCP', utf8.encode('profile')), + if (orientation != null) + ..._pngChunk( + 'eXIf', + _exifOrientation( + orientation, + orientationEndian, + includePreamble: false, + ), + ), + if (metadata) ..._pngChunk('tEXt', utf8.encode('Location\u0000secret')), + if (metadata) ..._pngChunk('pHYs', List.filled(9, 0)), + ..._pngChunk('fcTL', List.filled(26, 0)), + ..._pngChunk('IDAT', [1, 2, 3]), + ..._pngChunk('fdAT', [0, 0, 0, 1, 4, 5]), + ..._pngChunk('IEND', const []), + ]; +} + +List<int> _webpChunk(String type, List<int> payload) { + return [ + ...ascii.encode(type), + ..._uint32LittleEndian(payload.length), + ...payload, + if (payload.length.isOdd) 0, + ]; +} + +List<int> _animatedWebp({ + required bool metadata, + int? orientation, + Endian orientationEndian = Endian.little, + bool iccProfile = false, + bool nestedMetadata = false, +}) { + final hasExif = metadata || orientation != null; + final frame = <int>[ + ...List.filled(16, 0), + ..._webpChunk('VP8 ', [1, 2, 3]), + if (nestedMetadata) ..._webpChunk('JUNK', utf8.encode('location')), + ]; + final chunks = <int>[ + ..._webpChunk('VP8X', [ + 0x02 | (hasExif ? 0x0c : 0) | (iccProfile ? 0x20 : 0), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]), + ..._webpChunk('ANIM', List.filled(6, 0)), + if (iccProfile) ..._webpChunk('ICCP', utf8.encode('profile')), + if (orientation != null) + ..._webpChunk( + 'EXIF', + _exifOrientation(orientation, orientationEndian, includePreamble: true), + ) + else if (metadata) + ..._webpChunk('EXIF', utf8.encode('location')), + if (metadata) ..._webpChunk('XMP ', utf8.encode('<xmp/>')), + if (metadata) ..._webpChunk('JUNK', utf8.encode('private')), + ..._webpChunk('ANMF', frame), + ]; + return [ + ...ascii.encode('RIFF'), + ..._uint32LittleEndian(chunks.length + 4), + ...ascii.encode('WEBP'), + ...chunks, + ]; +} + +List<int> _exifOrientation( + int orientation, + Endian endian, { + required bool includePreamble, +}) { + final bytes = BytesBuilder(); + if (includePreamble) { + bytes + ..add(ascii.encode('Exif')) + ..add(const [0, 0]); + } + final tiff = ByteData(26); + if (endian == Endian.little) { + tiff.setUint8(0, 0x49); + tiff.setUint8(1, 0x49); + } else { + tiff.setUint8(0, 0x4d); + tiff.setUint8(1, 0x4d); + } + tiff.setUint16(2, 42, endian); + tiff.setUint32(4, 8, endian); + tiff.setUint16(8, 1, endian); + tiff.setUint16(10, 0x0112, endian); + tiff.setUint16(12, 3, endian); + tiff.setUint32(14, 1, endian); + tiff.setUint16(18, orientation, endian); + tiff.setUint32(22, 0, endian); + bytes.add(tiff.buffer.asUint8List()); + return bytes.takeBytes(); +} + +List<int> _minimalGif() { + return [ + ...ascii.encode('GIF89a'), + 2, + 0, + 2, + 0, + 0x80, + 0, + 0, + 0, + 0, + 0, + 0xff, + 0xff, + 0xff, + ..._gifLoopExtension(), + 0x21, + 0xf9, + 4, + 0, + 10, + 0, + 0, + 0, + 0x2c, + 0, + 0, + 0, + 0, + 2, + 0, + 2, + 0, + 0, + 2, + 2, + 0x44, + 1, + 0, + 0x3b, + ]; +} + +List<int> _gifLoopExtension({List<int> extra = const []}) { + return [ + 0x21, + 0xff, + 11, + ...ascii.encode('NETSCAPE2.0'), + 3, + 1, + 0, + 0, + if (extra.isNotEmpty) ...[extra.length, ...extra], + 0, + ]; +} + +List<int> _gifCommentExtension() { + return [0x21, 0xfe, 5, ...ascii.encode('hello'), 0]; +} + +List<int> _gifApplicationExtension(String identifier, List<int> payload) { + return _gifApplicationExtensionBytes(ascii.encode(identifier), payload); +} + +List<int> _gifApplicationExtensionBytes( + List<int> identifier, + List<int> payload, +) { + return [0x21, 0xff, 11, ...identifier, payload.length, ...payload, 0]; +} + +List<int> _gifPlainTextExtension() { + return [0x21, 0x01, 12, 0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 1, 0, 1, 0x78, 0]; +} + +List<int> _uint32BigEndian(int value) { + return [ + value >> 24 & 0xff, + value >> 16 & 0xff, + value >> 8 & 0xff, + value & 0xff, + ]; +} + +List<int> _uint32LittleEndian(int value) { + return [ + value & 0xff, + value >> 8 & 0xff, + value >> 16 & 0xff, + value >> 24 & 0xff, + ]; +} diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 94b23c9f79..dc901eba38 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -69,66 +69,88 @@ final _heicBytes = Uint8List.fromList([ ]); final _gifBytes = Uint8List.fromList([ - 0x47, - 0x49, - 0x46, - 0x38, - 0x39, - 0x61, - 0x01, - 0x00, - 0x01, - 0x00, -]); - -final _apngBytes = Uint8List.fromList([ - 0x89, - 0x50, - 0x4e, - 0x47, - 0x0d, - 0x0a, - 0x1a, - 0x0a, - 0x00, + ...ascii.encode('GIF89a'), + 0x02, 0x00, + 0x02, 0x00, - 0x08, - 0x61, - 0x63, - 0x54, - 0x4c, + 0x80, 0x00, 0x00, 0x00, - 0x02, 0x00, 0x00, + 0xff, + 0xff, + 0xff, + 0x21, + 0xfe, + 0x05, + ...ascii.encode('hello'), 0x00, + 0x21, + 0xff, + 0x0b, + ...ascii.encode('NETSCAPE2.0'), + 0x03, + 0x01, 0x00, 0x00, 0x00, + 0x21, + 0xf9, + 0x04, 0x00, + 0x0a, 0x00, 0x00, 0x00, + 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, + 0x02, 0x00, 0x00, - 0x49, - 0x45, - 0x4e, + 0x02, + 0x02, 0x44, + 0x01, 0x00, - 0x00, - 0x00, - 0x00, + 0x3b, +]); + +final _apngBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ..._testPngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]), + ..._testPngChunk('IEND', const []), ]); +List<int> _testPngChunk(String type, List<int> payload) { + return [ + payload.length >> 24 & 0xff, + payload.length >> 16 & 0xff, + payload.length >> 8 & 0xff, + payload.length & 0xff, + ...ascii.encode(type), + ...payload, + 0, + 0, + 0, + 0, + ]; +} + final _staticPngWithActlPayloadBytes = Uint8List.fromList([ 0x89, 0x50, @@ -588,29 +610,40 @@ void main() { expect(descriptor.type, 'image/png'); }); - test( - 'rejects GIF clipboard bytes through the shared validation path', - () async { - final service = MediaUploadService( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryVideo: () async => null, - pickGalleryImage: () async => null, - readClipboardImage: () async => _gifBytes, - ); + test('sanitizes and uploads GIF clipboard bytes', () async { + http.Request? capturedRequest; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/clipboard.gif', + 'sha256': + '3333333333333333333333333333333333333333333333333333333333333333', + 'size': request.bodyBytes.length, + 'type': 'image/gif', + 'uploaded': 1, + }), + 200, + ); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + readClipboardImage: () async => _gifBytes, + ); - expect( - service.readAndUploadClipboardImage, - throwsA( - isA<Exception>().having( - (error) => error.toString(), - 'message', - contains('GIF uploads are not supported on mobile yet'), - ), - ), - ); - }, - ); + final descriptor = await service.readAndUploadClipboardImage(); + + expect(descriptor.type, 'image/gif'); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/gif'); + expect( + ascii.decode(capturedRequest!.bodyBytes, allowInvalid: true), + isNot(contains('hello')), + ); + }); test('rejects empty clipboard image bytes', () async { final service = MediaUploadService( @@ -945,55 +978,76 @@ void main() { expect(capturedRequest!.bodyBytes, _pngBytes); }); - test('rejects GIF gallery files before upload', () async { + test('sanitizes and uploads GIF gallery files', () async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; + http.Request? capturedRequest; final service = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, + httpClient: http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/animated.gif', + 'sha256': + '4444444444444444444444444444444444444444444444444444444444444444', + 'size': request.bodyBytes.length, + 'type': 'image/gif', + 'uploaded': 1, + }), + 200, + ); + }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), ); + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/gif'); + expect(capturedRequest!.headers['Content-Type'], 'image/gif'); expect( - service.pickAndUploadImage(), - throwsA( - isA<Exception>().having( - (error) => error.toString(), - 'message', - contains('GIF uploads are not supported on mobile yet'), - ), - ), + ascii.decode(capturedRequest!.bodyBytes, allowInvalid: true), + isNot(contains('hello')), ); }); - test('rejects animated PNG gallery files before upload', () async { + test('sanitizes and uploads animated PNG gallery files', () async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; + http.Request? capturedRequest; final service = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, - httpClient: http_testing.MockClient( - (request) async => http.Response('{}', 200), - ), + httpClient: http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/animated.png', + 'sha256': + '5555555555555555555555555555555555555555555555555555555555555555', + 'size': request.bodyBytes.length, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), ); - expect( - service.pickAndUploadImage(), - throwsA( - isA<Exception>().having( - (error) => error.toString(), - 'message', - contains('Animated PNG uploads are not supported on mobile yet'), - ), - ), - ); + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/png'); + expect(capturedRequest!.bodyBytes, _apngBytes); }); test('uploads static PNG when acTL appears only in chunk payload', () async { @@ -1034,31 +1088,38 @@ void main() { expect(capturedRequest!.bodyBytes, _staticPngWithActlPayloadBytes); }); - test('rejects animated WebP gallery files before upload', () async { + test('sanitizes and uploads animated WebP gallery files', () async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; + http.Request? capturedRequest; final service = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, - httpClient: http_testing.MockClient( - (request) async => http.Response('{}', 200), - ), + httpClient: http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/animated.webp', + 'sha256': + '6666666666666666666666666666666666666666666666666666666666666666', + 'size': request.bodyBytes.length, + 'type': 'image/webp', + 'uploaded': 1, + }), + 200, + ); + }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_animatedWebpBytes, name: 'animated.webp'), ); - expect( - service.pickAndUploadImage(), - throwsA( - isA<Exception>().having( - (error) => error.toString(), - 'message', - contains('Animated WebP uploads are not supported on mobile yet'), - ), - ), - ); + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/webp'); + expect(capturedRequest!.bodyBytes, _animatedWebpBytes); }); test('rejects unsupported gallery files before upload', () async {