From abd6d3d150ba57c32801367ccfb26167adc79060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 28 Mar 2026 13:04:48 -0300 Subject: [PATCH 1/6] feat!: sticker pack sending support Add proto-level helpers for creating and sending sticker pack messages, following the same pattern as album messages (no dedicated send method). New modules: - wacore/src/zip.rs: minimal store-only ZIP writer (no deps) - wacore/src/webp.rs: animated WebP detection via RIFF/VP8X parsing - wacore/src/sticker_pack.rs: create_sticker_pack_zip(), build_sticker_pack_message(), types Breaking changes: - Client::upload() now takes UploadOptions as third parameter - MediaType enum gains StickerPackThumbnail variant and #[non_exhaustive] --- src/lib.rs | 5 +- src/upload.rs | 46 ++++- tests/e2e/tests/media.rs | 56 +++-- tests/e2e/tests/newsletter.rs | 5 +- wacore/src/download.rs | 10 + wacore/src/lib.rs | 5 + wacore/src/send.rs | 3 + wacore/src/sticker_pack.rs | 378 ++++++++++++++++++++++++++++++++++ wacore/src/upload.rs | 19 +- wacore/src/webp.rs | 103 +++++++++ wacore/src/zip.rs | 168 +++++++++++++++ 11 files changed, 769 insertions(+), 29 deletions(-) create mode 100644 wacore/src/sticker_pack.rs create mode 100644 wacore/src/webp.rs create mode 100644 wacore/src/zip.rs diff --git a/src/lib.rs b/src/lib.rs index 5cfa0a36b..ec875644e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ -pub use wacore::{iq::privacy as privacy_settings, proto_helpers, store::traits}; +pub use wacore::{ + iq::privacy as privacy_settings, proto_helpers, sticker_pack, store::traits, webp, +}; pub use wacore_binary::builder::NodeBuilder; pub use wacore_binary::jid::Jid; pub use waproto; @@ -43,6 +45,7 @@ pub mod socket; pub mod store; pub mod transport; pub mod upload; +pub use upload::UploadOptions; pub mod pdo; pub mod prekeys; diff --git a/src/upload.rs b/src/upload.rs index 0c60eca70..fae432d44 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -241,21 +241,61 @@ pub struct UploadResponse { pub media_key_timestamp: i64, } +impl From for wacore::sticker_pack::MediaUploadInfo { + fn from(r: UploadResponse) -> Self { + Self::new( + r.direct_path, + r.media_key, + r.file_sha256, + r.file_enc_sha256, + r.file_length, + r.media_key_timestamp, + ) + } +} + #[derive(Deserialize)] struct RawUploadResponse { url: String, direct_path: String, } +#[non_exhaustive] +#[derive(Default, Clone, Debug)] +pub struct UploadOptions { + /// Reuse an existing media key instead of generating a fresh one. + pub media_key: Option>, +} + +impl UploadOptions { + pub fn new() -> Self { + Self::default() + } + + pub fn with_media_key(mut self, key: Vec) -> Self { + self.media_key = Some(key); + self + } +} + impl Client { - /// Encrypts and uploads media to WhatsApp's CDN with a fresh key. + /// Encrypts and uploads media to WhatsApp's CDN. /// /// Only needed for new or modified media. To forward existing media unchanged, /// reuse the original message's CDN fields directly, no round-trip required. - pub async fn upload(&self, data: Vec, media_type: MediaType) -> Result { + pub async fn upload( + &self, + data: Vec, + media_type: MediaType, + options: UploadOptions, + ) -> Result { let file_length = data.len() as u64; let enc = wacore::runtime::blocking(&*self.runtime, move || { - wacore::upload::encrypt_media(&data, media_type) + let key_ref = options + .media_key + .as_ref() + .and_then(|k| <&[u8; 32]>::try_from(k.as_slice()).ok()); + wacore::upload::encrypt_media_with_key(&data, media_type, key_ref) }) .await?; diff --git a/tests/e2e/tests/media.rs b/tests/e2e/tests/media.rs index 7fa36d570..e69bd3eec 100644 --- a/tests/e2e/tests/media.rs +++ b/tests/e2e/tests/media.rs @@ -97,7 +97,10 @@ async fn test_upload_image() -> anyhow::Result<()> { let data = vec![0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; // fake JPEG header - let resp = client.client.upload(data.clone(), MediaType::Image).await?; + let resp = client + .client + .upload(data.clone(), MediaType::Image, Default::default()) + .await?; info!( "Upload response: url={}, direct_path={}", @@ -131,7 +134,10 @@ async fn test_upload_video() -> anyhow::Result<()> { let client = TestClient::connect("e2e_upload_vid").await?; let data = vec![0u8; 64]; // fake video bytes - let resp = client.client.upload(data.clone(), MediaType::Video).await?; + let resp = client + .client + .upload(data.clone(), MediaType::Video, Default::default()) + .await?; assert!(!resp.url.is_empty()); assert!(!resp.direct_path.is_empty()); @@ -150,7 +156,7 @@ async fn test_upload_document() -> anyhow::Result<()> { let data = b"%PDF-1.4 fake pdf content for testing purposes".to_vec(); let resp = client .client - .upload(data.clone(), MediaType::Document) + .upload(data.clone(), MediaType::Document, Default::default()) .await?; assert!(!resp.url.is_empty()); @@ -168,7 +174,10 @@ async fn test_upload_audio() -> anyhow::Result<()> { let client = TestClient::connect("e2e_upload_aud").await?; let data = vec![0u8; 64]; // fake audio bytes - let resp = client.client.upload(data.clone(), MediaType::Audio).await?; + let resp = client + .client + .upload(data.clone(), MediaType::Audio, Default::default()) + .await?; assert!(!resp.url.is_empty()); assert!(!resp.direct_path.is_empty()); @@ -189,7 +198,7 @@ async fn test_upload_then_download_image() -> anyhow::Result<()> { let original = b"JPEG image content for round-trip test".to_vec(); let upload = client .client - .upload(original.clone(), MediaType::Image) + .upload(original.clone(), MediaType::Image, Default::default()) .await?; info!("Uploaded: direct_path={}", upload.direct_path); @@ -225,7 +234,7 @@ async fn test_upload_then_download_video() -> anyhow::Result<()> { let original = vec![0xAB; 64]; // fake video let upload = client .client - .upload(original.clone(), MediaType::Video) + .upload(original.clone(), MediaType::Video, Default::default()) .await?; let downloaded = client @@ -255,7 +264,7 @@ async fn test_upload_then_download_document() -> anyhow::Result<()> { let original = b"PDF document content for testing".to_vec(); let upload = client .client - .upload(original.clone(), MediaType::Document) + .upload(original.clone(), MediaType::Document, Default::default()) .await?; let downloaded = client @@ -285,7 +294,7 @@ async fn test_upload_then_download_via_downloadable_trait() -> anyhow::Result<() let original = b"Testing Downloadable trait with ImageMessage".to_vec(); let upload = client .client - .upload(original.clone(), MediaType::Image) + .upload(original.clone(), MediaType::Image, Default::default()) .await?; // Build an ImageMessage (which implements Downloadable) @@ -319,7 +328,7 @@ async fn test_upload_then_download_to_writer() -> anyhow::Result<()> { let original = b"Streaming download test content".to_vec(); let upload = client .client - .upload(original.clone(), MediaType::Image) + .upload(original.clone(), MediaType::Image, Default::default()) .await?; let cursor = std::io::Cursor::new(Vec::::new()); @@ -362,7 +371,7 @@ async fn test_send_image_message() -> anyhow::Result<()> { let original = b"Image bytes sent from A to B".to_vec(); let upload = client_a .client - .upload(original.clone(), MediaType::Image) + .upload(original.clone(), MediaType::Image, Default::default()) .await?; // A sends the image to B @@ -428,7 +437,7 @@ async fn test_send_video_message() -> anyhow::Result<()> { let original = vec![0xBB; 64]; let upload = client_a .client - .upload(original.clone(), MediaType::Video) + .upload(original.clone(), MediaType::Video, Default::default()) .await?; let msg = build_video_message(&upload, Some("Cool video"), 15); @@ -478,7 +487,7 @@ async fn test_send_document_message() -> anyhow::Result<()> { let original = b"Important document content".to_vec(); let upload = client_a .client - .upload(original.clone(), MediaType::Document) + .upload(original.clone(), MediaType::Document, Default::default()) .await?; let msg = build_document_message(&upload, "report.pdf", "application/pdf"); @@ -527,7 +536,7 @@ async fn test_send_audio_message() -> anyhow::Result<()> { let original = vec![0xCC; 64]; let upload = client_a .client - .upload(original.clone(), MediaType::Audio) + .upload(original.clone(), MediaType::Audio, Default::default()) .await?; let msg = build_audio_message(&upload, false, 30); @@ -576,7 +585,7 @@ async fn test_send_ptt_voice_message() -> anyhow::Result<()> { let original = vec![0xDD; 64]; let upload = client_a .client - .upload(original.clone(), MediaType::Audio) + .upload(original.clone(), MediaType::Audio, Default::default()) .await?; let msg = build_audio_message(&upload, true, 5); @@ -635,7 +644,7 @@ async fn test_send_image_bidirectional() -> anyhow::Result<()> { let data_a = b"Image from A to B".to_vec(); let upload_a = client_a .client - .upload(data_a.clone(), MediaType::Image) + .upload(data_a.clone(), MediaType::Image, Default::default()) .await?; let msg_a = build_image_message(&upload_a, Some("From A")); client_a.client.send_message(jid_b.clone(), msg_a).await?; @@ -661,7 +670,7 @@ async fn test_send_image_bidirectional() -> anyhow::Result<()> { let data_b = b"Image from B to A".to_vec(); let upload_b = client_b .client - .upload(data_b.clone(), MediaType::Image) + .upload(data_b.clone(), MediaType::Image, Default::default()) .await?; let msg_b = build_image_message(&upload_b, Some("From B")); client_b.client.send_message(jid_a.clone(), msg_b).await?; @@ -708,7 +717,7 @@ async fn test_send_multiple_media_types() -> anyhow::Result<()> { let img_data = b"image data for multi-type test".to_vec(); let img_upload = client_a .client - .upload(img_data.clone(), MediaType::Image) + .upload(img_data.clone(), MediaType::Image, Default::default()) .await?; let img_msg = build_image_message(&img_upload, Some("Photo")); client_a.client.send_message(jid_b.clone(), img_msg).await?; @@ -732,7 +741,7 @@ async fn test_send_multiple_media_types() -> anyhow::Result<()> { let doc_data = b"document data for multi-type test".to_vec(); let doc_upload = client_a .client - .upload(doc_data.clone(), MediaType::Document) + .upload(doc_data.clone(), MediaType::Document, Default::default()) .await?; let doc_msg = build_document_message(&doc_upload, "file.txt", "text/plain"); client_a.client.send_message(jid_b.clone(), doc_msg).await?; @@ -756,7 +765,7 @@ async fn test_send_multiple_media_types() -> anyhow::Result<()> { let aud_data = b"audio data for multi-type test".to_vec(); let aud_upload = client_a .client - .upload(aud_data.clone(), MediaType::Audio) + .upload(aud_data.clone(), MediaType::Audio, Default::default()) .await?; let aud_msg = build_audio_message(&aud_upload, true, 10); client_a.client.send_message(jid_b.clone(), aud_msg).await?; @@ -793,7 +802,7 @@ async fn test_upload_download_large_file() -> anyhow::Result<()> { let original = vec![0x42u8; 256]; let upload = client .client - .upload(original.clone(), MediaType::Document) + .upload(original.clone(), MediaType::Document, Default::default()) .await?; let downloaded = client @@ -826,7 +835,10 @@ async fn test_multiple_uploads_reuse_media_conn() -> anyhow::Result<()> { // Upload multiple files in sequence - media_conn should be cached for i in 0..3 { let data = format!("Upload number {i}").into_bytes(); - let resp = client.client.upload(data.clone(), MediaType::Image).await?; + let resp = client + .client + .upload(data.clone(), MediaType::Image, Default::default()) + .await?; info!("Upload {i}: direct_path={}", resp.direct_path); assert!(!resp.direct_path.is_empty()); @@ -868,7 +880,7 @@ async fn test_send_image_no_caption() -> anyhow::Result<()> { let original = b"Image without caption".to_vec(); let upload = client_a .client - .upload(original.clone(), MediaType::Image) + .upload(original.clone(), MediaType::Image, Default::default()) .await?; let msg = build_image_message(&upload, None); diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index 0b8dbc987..e4bbeb694 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -267,7 +267,10 @@ async fn test_newsletter_send_media_message() -> anyhow::Result<()> { // Upload a fake image let data = vec![0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; - let upload = client.client.upload(data, MediaType::Image).await?; + let upload = client + .client + .upload(data, MediaType::Image, Default::default()) + .await?; // Build and send an image message let message = wa::Message { diff --git a/wacore/src/download.rs b/wacore/src/download.rs index 1c44c1505..866bd5391 100644 --- a/wacore/src/download.rs +++ b/wacore/src/download.rs @@ -23,6 +23,7 @@ pub enum MediaDecryptionError { Other(#[from] anyhow::Error), } +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaType { Image, @@ -33,6 +34,7 @@ pub enum MediaType { AppState, Sticker, StickerPack, + StickerPackThumbnail, LinkThumbnail, /// Product catalog image — unencrypted, uploads to `/product/image`. /// WA Web: CreateMediaKeys.js throws for this type (no encryption). @@ -50,6 +52,7 @@ impl MediaType { MediaType::AppState => "WhatsApp App State Keys", MediaType::Sticker => "WhatsApp Image Keys", MediaType::StickerPack => "WhatsApp Sticker Pack Keys", + MediaType::StickerPackThumbnail => "WhatsApp Sticker Pack Thumbnail Keys", MediaType::LinkThumbnail => "WhatsApp Link Thumbnail Keys", // Unencrypted: app_info unused, but keep a value for the type system. MediaType::ProductCatalogImage => "WhatsApp Image Keys", @@ -67,6 +70,7 @@ impl MediaType { MediaType::History => "md-msg-hist", MediaType::AppState => "md-app-state", MediaType::StickerPack => "sticker-pack", + MediaType::StickerPackThumbnail => "thumbnail-sticker-pack", MediaType::LinkThumbnail => "thumbnail-link", MediaType::ProductCatalogImage => "product-catalog-image", } @@ -82,6 +86,7 @@ impl MediaType { MediaType::History => "/mms/md-msg-hist", MediaType::AppState => "/mms/md-app-state", MediaType::StickerPack => "/mms/sticker-pack", + MediaType::StickerPackThumbnail => "/mms/thumbnail-sticker-pack", MediaType::LinkThumbnail => "/mms/thumbnail-link", MediaType::ProductCatalogImage => "/product/image", } @@ -196,6 +201,11 @@ impl_downloadable!( ); impl_downloadable!(wa::message::AudioMessage, MediaType::Audio, file_length); impl_downloadable!(wa::message::StickerMessage, MediaType::Sticker, file_length); +impl_downloadable!( + wa::message::StickerPackMessage, + MediaType::StickerPack, + file_length +); impl_downloadable!(ExternalBlobReference, MediaType::AppState, file_size_bytes); impl_downloadable!(HistorySyncNotification, MediaType::History, file_length); diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 795ad9adf..eb3ac87e7 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -32,10 +32,15 @@ pub mod runtime; pub mod send; pub mod session; pub mod stanza; +pub mod sticker_pack; + pub mod store; pub mod time; pub mod types; pub mod upload; pub mod usync; +pub mod webp; + pub mod version; pub mod xml; +mod zip; diff --git a/wacore/src/send.rs b/wacore/src/send.rs index 2edaf1563..b6bcf9297 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -157,6 +157,9 @@ pub fn media_type_from_message(msg: &wa::Message) -> Option<&'static str> { if msg.sticker_message.is_some() { return Some("sticker"); } + if msg.sticker_pack_message.is_some() { + return Some("sticker_pack"); + } if let Some(ref loc) = msg.location_message { return if loc.is_live == Some(true) { Some("livelocation") diff --git a/wacore/src/sticker_pack.rs b/wacore/src/sticker_pack.rs new file mode 100644 index 000000000..0c9c171e4 --- /dev/null +++ b/wacore/src/sticker_pack.rs @@ -0,0 +1,378 @@ +//! Sticker pack creation helpers. +//! +//! Same pattern as album messages: proto-level helpers, no dedicated send method. +//! +//! ```rust,ignore +//! let stickers = vec![ +//! StickerInput::new(&webp_bytes).with_emojis(vec!["😀".into()]), +//! StickerInput::new(&webp_bytes_2), +//! ]; +//! let zip_result = create_sticker_pack_zip("pack-id", &stickers, &cover_webp)?; +//! +//! let zip_upload = client.upload(zip_result.zip_bytes.clone(), MediaType::StickerPack, Default::default()).await?; +//! let thumb_upload = client.upload( +//! thumbnail_jpeg, MediaType::StickerPackThumbnail, +//! UploadOptions::new().with_media_key(zip_upload.media_key.clone()), +//! ).await?; +//! +//! let metadata = StickerPackMetadata::new(pack_id, "My Pack".into(), "Me".into()); +//! let msg = build_sticker_pack_message(&zip_result, &zip_upload.into(), &thumb_upload.into(), metadata); +//! client.send_message(jid, msg).await?; +//! ``` +//! +//! Sticker format requirements (user's responsibility): +//! - Stickers: 512x512 WebP +//! - Cover: WebP, stored in ZIP as `{pack_id}.webp` +//! - Thumbnail: 252x252 JPEG, uploaded separately with the same `media_key` + +use crate::webp; +use crate::zip::ZipWriter; +use anyhow::{Result, bail}; +use sha2::{Digest, Sha256}; +use waproto::whatsapp as wa; + +#[non_exhaustive] +pub struct StickerInput<'a> { + pub data: &'a [u8], + pub emojis: Vec, + pub accessibility_label: Option, +} + +impl<'a> StickerInput<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { + data, + emojis: Vec::new(), + accessibility_label: None, + } + } + + pub fn with_emojis(mut self, emojis: Vec) -> Self { + self.emojis = emojis; + self + } + + pub fn with_accessibility_label(mut self, label: String) -> Self { + self.accessibility_label = Some(label); + self + } +} + +#[non_exhaustive] +pub struct StickerPackZipResult { + pub zip_bytes: Vec, + pub stickers: Vec, + pub tray_icon_file_name: String, +} + +#[non_exhaustive] +pub struct StickerPackMetadata { + pub pack_id: String, + pub name: String, + pub publisher: String, + pub description: Option, +} + +impl StickerPackMetadata { + pub fn new(pack_id: String, name: String, publisher: String) -> Self { + Self { + pack_id, + name, + publisher, + description: None, + } + } + + pub fn with_description(mut self, desc: String) -> Self { + self.description = Some(desc); + self + } +} + +/// Upload result fields for proto construction. +/// The high-level crate provides `From`. +#[non_exhaustive] +pub struct MediaUploadInfo { + pub direct_path: String, + pub media_key: Vec, + pub file_sha256: Vec, + pub file_enc_sha256: Vec, + pub file_length: u64, + pub media_key_timestamp: i64, +} + +impl MediaUploadInfo { + pub fn new( + direct_path: String, + media_key: Vec, + file_sha256: Vec, + file_enc_sha256: Vec, + file_length: u64, + media_key_timestamp: i64, + ) -> Self { + Self { + direct_path, + media_key, + file_sha256, + file_enc_sha256, + file_length, + media_key_timestamp, + } + } +} + +const MAX_STICKERS: usize = 60; + +/// Bundles stickers into a store-only ZIP and builds proto metadata. +/// Filenames use `{base64url(sha256)}.webp`. Identical stickers are deduplicated. +pub fn create_sticker_pack_zip( + pack_id: &str, + stickers: &[StickerInput], + cover: &[u8], +) -> Result { + if stickers.is_empty() { + bail!("sticker pack must contain at least 1 sticker"); + } + if stickers.len() > MAX_STICKERS { + bail!( + "sticker pack exceeds maximum of {} stickers (got {})", + MAX_STICKERS, + stickers.len() + ); + } + + let mut zip = ZipWriter::new(); + let mut proto_stickers = Vec::with_capacity(stickers.len()); + + let tray_icon_file_name = format!("{pack_id}.webp"); + zip.add_file(&tray_icon_file_name, cover); + + let mut seen_hashes = std::collections::HashSet::new(); + + for input in stickers { + let hash = Sha256::digest(input.data); + let file_name = format!("{}.webp", base64url_encode(&hash)); + + if seen_hashes.insert(hash.to_vec()) { + zip.add_file(&file_name, input.data); + } + + let is_animated = webp::is_animated(input.data); + proto_stickers.push(wa::message::sticker_pack_message::Sticker { + file_name: Some(file_name), + is_animated: Some(is_animated), + emojis: input.emojis.clone(), + accessibility_label: input.accessibility_label.clone(), + is_lottie: Some(false), + mimetype: Some("image/webp".to_string()), + premium: None, + }); + } + + Ok(StickerPackZipResult { + zip_bytes: zip.finish(), + stickers: proto_stickers, + tray_icon_file_name, + }) +} + +/// Builds a `wa::Message` with `StickerPackMessage` from upload results. +pub fn build_sticker_pack_message( + zip_result: &StickerPackZipResult, + zip_upload: &MediaUploadInfo, + thumb_upload: &MediaUploadInfo, + metadata: StickerPackMetadata, +) -> wa::Message { + use wa::message::sticker_pack_message::StickerPackOrigin; + + let pack_msg = wa::message::StickerPackMessage { + sticker_pack_id: Some(metadata.pack_id), + name: Some(metadata.name), + publisher: Some(metadata.publisher), + stickers: zip_result.stickers.clone(), + file_length: Some(zip_upload.file_length), + file_sha256: Some(zip_upload.file_sha256.clone()), + file_enc_sha256: Some(zip_upload.file_enc_sha256.clone()), + media_key: Some(zip_upload.media_key.clone()), + direct_path: Some(zip_upload.direct_path.clone()), + caption: None, + context_info: None, + pack_description: metadata.description, + media_key_timestamp: Some(zip_upload.media_key_timestamp), + tray_icon_file_name: Some(zip_result.tray_icon_file_name.clone()), + thumbnail_direct_path: Some(thumb_upload.direct_path.clone()), + thumbnail_sha256: Some(thumb_upload.file_sha256.clone()), + thumbnail_enc_sha256: Some(thumb_upload.file_enc_sha256.clone()), + thumbnail_height: Some(252), + thumbnail_width: Some(252), + image_data_hash: None, + sticker_pack_size: Some(zip_result.zip_bytes.len() as u64), + sticker_pack_origin: Some(StickerPackOrigin::UserCreated as i32), + }; + + wa::Message { + sticker_pack_message: Some(Box::new(pack_msg)), + ..Default::default() + } +} + +fn base64url_encode(data: &[u8]) -> String { + use base64::engine::{Engine, general_purpose::URL_SAFE_NO_PAD}; + URL_SAFE_NO_PAD.encode(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_webp(extra: u8) -> Vec { + // Minimal valid-ish WebP (just RIFF+WEBP+VP8 header, not animated) + let mut buf = Vec::new(); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&0u32.to_le_bytes()); + buf.extend_from_slice(b"WEBP"); + buf.extend_from_slice(b"VP8 "); + buf.extend_from_slice(&4u32.to_le_bytes()); + buf.extend_from_slice(&[extra, 0, 0, 0]); + let riff_size = (buf.len() - 8) as u32; + buf[4..8].copy_from_slice(&riff_size.to_le_bytes()); + buf + } + + #[test] + fn create_zip_basic() { + let s1 = dummy_webp(1); + let s2 = dummy_webp(2); + let cover = dummy_webp(0); + + let stickers = vec![StickerInput::new(&s1), StickerInput::new(&s2)]; + let result = create_sticker_pack_zip("test-pack", &stickers, &cover).unwrap(); + + assert_eq!(result.stickers.len(), 2); + assert_eq!(result.tray_icon_file_name, "test-pack.webp"); + assert!( + result.stickers[0] + .file_name + .as_ref() + .unwrap() + .ends_with(".webp") + ); + assert!( + result.stickers[1] + .file_name + .as_ref() + .unwrap() + .ends_with(".webp") + ); + assert_ne!(result.stickers[0].file_name, result.stickers[1].file_name); + // ZIP should start with local file header magic + assert_eq!(&result.zip_bytes[0..4], &[0x50, 0x4B, 0x03, 0x04]); + } + + #[test] + fn create_zip_deduplication() { + let s1 = dummy_webp(1); + let cover = dummy_webp(0); + + // Two identical stickers + let stickers = vec![StickerInput::new(&s1), StickerInput::new(&s1)]; + let result = create_sticker_pack_zip("dedup-test", &stickers, &cover).unwrap(); + + // Both proto entries exist with same filename + assert_eq!(result.stickers.len(), 2); + assert_eq!(result.stickers[0].file_name, result.stickers[1].file_name); + + // But ZIP should be smaller than if we added both (only 2 entries: cover + 1 sticker) + let s2 = dummy_webp(2); + let non_dedup_stickers = vec![StickerInput::new(&s1), StickerInput::new(&s2)]; + let non_dedup = + create_sticker_pack_zip("dedup-test2", &non_dedup_stickers, &cover).unwrap(); + assert!(result.zip_bytes.len() < non_dedup.zip_bytes.len()); + } + + #[test] + fn create_zip_empty_rejected() { + let cover = dummy_webp(0); + let result = create_sticker_pack_zip("empty", &[], &cover); + assert!(result.is_err()); + } + + #[test] + fn create_zip_too_many_rejected() { + let sticker = dummy_webp(1); + let cover = dummy_webp(0); + let stickers: Vec<_> = (0..61).map(|_| StickerInput::new(&sticker)).collect(); + let result = create_sticker_pack_zip("big", &stickers, &cover); + assert!(result.is_err()); + } + + #[test] + fn create_zip_with_emojis() { + let s1 = dummy_webp(1); + let cover = dummy_webp(0); + + let stickers = vec![ + StickerInput::new(&s1) + .with_emojis(vec!["😀".into(), "🎉".into()]) + .with_accessibility_label("happy face".into()), + ]; + let result = create_sticker_pack_zip("emoji-test", &stickers, &cover).unwrap(); + + let sticker = &result.stickers[0]; + assert_eq!(sticker.emojis, vec!["😀", "🎉"]); + assert_eq!(sticker.accessibility_label.as_deref(), Some("happy face")); + } + + #[test] + fn build_message_fields() { + let s1 = dummy_webp(1); + let cover = dummy_webp(0); + let stickers = vec![StickerInput::new(&s1)]; + let zip_result = create_sticker_pack_zip("msg-test", &stickers, &cover).unwrap(); + + let zip_upload = MediaUploadInfo::new( + "/mms/sticker-pack/abc".into(), + vec![0u8; 32], + vec![1u8; 32], + vec![2u8; 32], + zip_result.zip_bytes.len() as u64, + 1234567890, + ); + let thumb_upload = MediaUploadInfo::new( + "/mms/thumbnail-sticker-pack/def".into(), + vec![0u8; 32], + vec![3u8; 32], + vec![4u8; 32], + 1000, + 1234567890, + ); + + let metadata = StickerPackMetadata::new( + "msg-test".into(), + "Test Pack".into(), + "Test Publisher".into(), + ) + .with_description("A test pack".into()); + + let msg = build_sticker_pack_message(&zip_result, &zip_upload, &thumb_upload, metadata); + let pack = msg.sticker_pack_message.unwrap(); + + assert_eq!(pack.sticker_pack_id.as_deref(), Some("msg-test")); + assert_eq!(pack.name.as_deref(), Some("Test Pack")); + assert_eq!(pack.publisher.as_deref(), Some("Test Publisher")); + assert_eq!(pack.pack_description.as_deref(), Some("A test pack")); + assert_eq!(pack.stickers.len(), 1); + assert_eq!(pack.direct_path.as_deref(), Some("/mms/sticker-pack/abc")); + assert_eq!( + pack.thumbnail_direct_path.as_deref(), + Some("/mms/thumbnail-sticker-pack/def") + ); + assert_eq!(pack.thumbnail_height, Some(252)); + assert_eq!(pack.thumbnail_width, Some(252)); + assert_eq!( + pack.sticker_pack_origin, + Some(wa::message::sticker_pack_message::StickerPackOrigin::UserCreated as i32) + ); + assert_eq!(pack.tray_icon_file_name.as_deref(), Some("msg-test.webp")); + } +} diff --git a/wacore/src/upload.rs b/wacore/src/upload.rs index dfabf52e6..10615bb77 100644 --- a/wacore/src/upload.rs +++ b/wacore/src/upload.rs @@ -5,7 +5,7 @@ use aes::cipher::{Block, BlockEncrypt, KeyInit}; use anyhow::Result; use rand::RngExt; use rand::rng; -use std::io::{Cursor, Read, Write}; +use std::io::{Read, Write}; const BLOCK: usize = 16; @@ -224,8 +224,22 @@ pub fn encrypt_media_streaming( /// Encrypt media in memory. pub fn encrypt_media(plaintext: &[u8], media_type: MediaType) -> Result { + encrypt_media_with_key(plaintext, media_type, None) +} + +/// Like `encrypt_media` but accepts an optional pre-existing key. +pub fn encrypt_media_with_key( + plaintext: &[u8], + media_type: MediaType, + media_key: Option<&[u8; 32]>, +) -> Result { + let mut enc = match media_key { + Some(key) => MediaEncryptor::with_key(*key, media_type)?, + None => MediaEncryptor::new(media_type)?, + }; let mut data_to_upload = Vec::new(); - let info = encrypt_media_streaming(Cursor::new(plaintext), &mut data_to_upload, media_type)?; + enc.update(plaintext, &mut data_to_upload); + let info = enc.finalize(&mut data_to_upload)?; Ok(EncryptedMedia { data_to_upload, media_key: info.media_key, @@ -238,6 +252,7 @@ pub fn encrypt_media(plaintext: &[u8], media_type: MediaType) -> Result bool { + // Minimum: RIFF(4) + size(4) + WEBP(4) + chunk header(8) = 20 + if data.len() < 20 { + return false; + } + if &data[0..4] != b"RIFF" || &data[8..12] != b"WEBP" { + return false; + } + + let mut offset = 12; + while offset + 8 <= data.len() { + let fourcc = &data[offset..offset + 4]; + let chunk_size = u32::from_le_bytes([ + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]) as usize; + + if fourcc == b"VP8X" && offset + 8 < data.len() { + // Animation flag is bit 1 of the first byte after chunk header + if data[offset + 8] & 0x02 != 0 { + return true; + } + } + + if fourcc == b"ANIM" || fourcc == b"ANMF" { + return true; + } + + // Chunks are padded to even size per RIFF spec + offset += 8 + chunk_size + (chunk_size & 1); + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_webp_vp8x(flags: u8) -> Vec { + let mut buf = Vec::new(); + // RIFF header + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&0u32.to_le_bytes()); // placeholder file size + buf.extend_from_slice(b"WEBP"); + // VP8X chunk + buf.extend_from_slice(b"VP8X"); + buf.extend_from_slice(&10u32.to_le_bytes()); // chunk size + buf.push(flags); + buf.extend_from_slice(&[0u8; 9]); // rest of VP8X payload + // Fix RIFF size + let riff_size = (buf.len() - 8) as u32; + buf[4..8].copy_from_slice(&riff_size.to_le_bytes()); + buf + } + + #[test] + fn static_webp() { + let data = make_webp_vp8x(0x00); + assert!(!is_animated(&data)); + } + + #[test] + fn animated_webp_via_flag() { + let data = make_webp_vp8x(0x02); + assert!(is_animated(&data)); + } + + #[test] + fn animated_webp_via_anim_chunk() { + let mut buf = Vec::new(); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&0u32.to_le_bytes()); + buf.extend_from_slice(b"WEBP"); + // VP8X without animation flag + buf.extend_from_slice(b"VP8X"); + buf.extend_from_slice(&10u32.to_le_bytes()); + buf.extend_from_slice(&[0u8; 10]); + // ANIM chunk + buf.extend_from_slice(b"ANIM"); + buf.extend_from_slice(&6u32.to_le_bytes()); + buf.extend_from_slice(&[0u8; 6]); + let riff_size = (buf.len() - 8) as u32; + buf[4..8].copy_from_slice(&riff_size.to_le_bytes()); + + assert!(is_animated(&buf)); + } + + #[test] + fn too_short() { + assert!(!is_animated(&[0; 10])); + } + + #[test] + fn not_webp() { + assert!(!is_animated(b"NOT A WEBP FILE AT ALL!!")); + } +} diff --git a/wacore/src/zip.rs b/wacore/src/zip.rs new file mode 100644 index 000000000..ca1a8d9ff --- /dev/null +++ b/wacore/src/zip.rs @@ -0,0 +1,168 @@ +//! Minimal store-only ZIP writer. No compression, no streaming. + +const LOCAL_FILE_HEADER_SIG: u32 = 0x04034b50; +const CENTRAL_DIR_SIG: u32 = 0x02014b50; +const END_OF_CENTRAL_DIR_SIG: u32 = 0x06054b50; +const VERSION: u16 = 20; + +fn put_u16(buf: &mut Vec, v: u16) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn put_u32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn put_zero_u16s(buf: &mut Vec, n: usize) { + for _ in 0..n { + put_u16(buf, 0); + } +} + +struct Entry { + name: Vec, + offset: u32, + crc32: u32, + size: u32, +} + +pub(crate) struct ZipWriter { + buf: Vec, + entries: Vec, +} + +impl ZipWriter { + pub fn new() -> Self { + Self { + buf: Vec::new(), + entries: Vec::new(), + } + } + + pub fn add_file(&mut self, name: &str, data: &[u8]) { + let name_bytes = name.as_bytes(); + let crc = crc32(data); + let size = data.len() as u32; + let offset = self.buf.len() as u32; + + put_u32(&mut self.buf, LOCAL_FILE_HEADER_SIG); + put_u16(&mut self.buf, VERSION); + put_zero_u16s(&mut self.buf, 3); // flags, method (stored), mod time + put_u16(&mut self.buf, 0); // mod date + put_u32(&mut self.buf, crc); + put_u32(&mut self.buf, size); // compressed + put_u32(&mut self.buf, size); // uncompressed + put_u16(&mut self.buf, name_bytes.len() as u16); + put_u16(&mut self.buf, 0); // extra field len + self.buf.extend_from_slice(name_bytes); + self.buf.extend_from_slice(data); + + self.entries.push(Entry { + name: name_bytes.to_vec(), + offset, + crc32: crc, + size, + }); + } + + pub fn finish(mut self) -> Vec { + let cd_offset = self.buf.len() as u32; + + for e in &self.entries { + put_u32(&mut self.buf, CENTRAL_DIR_SIG); + put_u16(&mut self.buf, VERSION); // made by + put_u16(&mut self.buf, VERSION); // needed + put_zero_u16s(&mut self.buf, 2); // flags, method + put_zero_u16s(&mut self.buf, 2); // mod time, mod date + put_u32(&mut self.buf, e.crc32); + put_u32(&mut self.buf, e.size); // compressed + put_u32(&mut self.buf, e.size); // uncompressed + put_u16(&mut self.buf, e.name.len() as u16); + put_zero_u16s(&mut self.buf, 5); // extra len, comment len, disk start, internal attrs + put_u32(&mut self.buf, 0); // external attrs + put_u32(&mut self.buf, e.offset); + self.buf.extend_from_slice(&e.name); + } + + let cd_size = self.buf.len() as u32 - cd_offset; + let count = self.entries.len() as u16; + + put_u32(&mut self.buf, END_OF_CENTRAL_DIR_SIG); + put_zero_u16s(&mut self.buf, 2); // disk number, disk with cd + put_u16(&mut self.buf, count); + put_u16(&mut self.buf, count); + put_u32(&mut self.buf, cd_size); + put_u32(&mut self.buf, cd_offset); + put_u16(&mut self.buf, 0); // comment len + + self.buf + } +} + +fn crc32(data: &[u8]) -> u32 { + static TABLE: std::sync::LazyLock<[u32; 256]> = std::sync::LazyLock::new(|| { + let mut table = [0u32; 256]; + for i in 0..256u32 { + let mut crc = i; + for _ in 0..8 { + if crc & 1 != 0 { + crc = 0xEDB88320 ^ (crc >> 1); + } else { + crc >>= 1; + } + } + table[i as usize] = crc; + } + table + }); + + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc = TABLE[((crc ^ byte as u32) & 0xFF) as usize] ^ (crc >> 8); + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc32_known_values() { + assert_eq!(crc32(b""), 0x00000000); + assert_eq!(crc32(b"123456789"), 0xCBF43926); + } + + #[test] + fn zip_single_file() { + let mut w = ZipWriter::new(); + w.add_file("hello.txt", b"Hello, world!"); + let zip = w.finish(); + + assert_eq!(&zip[0..4], &LOCAL_FILE_HEADER_SIG.to_le_bytes()); + let eocd_pos = zip.len() - 22; + assert_eq!( + &zip[eocd_pos..eocd_pos + 4], + &END_OF_CENTRAL_DIR_SIG.to_le_bytes() + ); + assert_eq!( + u16::from_le_bytes([zip[eocd_pos + 8], zip[eocd_pos + 9]]), + 1 + ); + } + + #[test] + fn zip_multiple_files() { + let mut w = ZipWriter::new(); + w.add_file("a.bin", &[0xAA; 100]); + w.add_file("b.bin", &[0xBB; 200]); + w.add_file("c.bin", &[0xCC; 50]); + let zip = w.finish(); + + let eocd_pos = zip.len() - 22; + assert_eq!( + u16::from_le_bytes([zip[eocd_pos + 8], zip[eocd_pos + 9]]), + 3 + ); + } +} From b1a55be44deec3abef22d6c571793839515c53ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 28 Mar 2026 13:16:33 -0300 Subject: [PATCH 2/6] fix: address code review findings - Error on invalid media_key length instead of silent fallback - Use checked_add in WebP chunk parser to prevent overflow - Use [u8; 32] instead of Vec in dedup HashSet to avoid allocations - Document 252x252 thumbnail requirement on build_sticker_pack_message --- src/upload.rs | 11 +++++++---- wacore/src/sticker_pack.rs | 5 +++-- wacore/src/webp.rs | 6 ++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/upload.rs b/src/upload.rs index fae432d44..8827967c3 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -291,10 +291,13 @@ impl Client { ) -> Result { let file_length = data.len() as u64; let enc = wacore::runtime::blocking(&*self.runtime, move || { - let key_ref = options - .media_key - .as_ref() - .and_then(|k| <&[u8; 32]>::try_from(k.as_slice()).ok()); + let key_ref = match &options.media_key { + Some(k) => Some( + <&[u8; 32]>::try_from(k.as_slice()) + .map_err(|_| anyhow!("media_key must be exactly 32 bytes"))?, + ), + None => None, + }; wacore::upload::encrypt_media_with_key(&data, media_type, key_ref) }) .await?; diff --git a/wacore/src/sticker_pack.rs b/wacore/src/sticker_pack.rs index 0c9c171e4..8fe1685be 100644 --- a/wacore/src/sticker_pack.rs +++ b/wacore/src/sticker_pack.rs @@ -150,10 +150,10 @@ pub fn create_sticker_pack_zip( let mut seen_hashes = std::collections::HashSet::new(); for input in stickers { - let hash = Sha256::digest(input.data); + let hash: [u8; 32] = Sha256::digest(input.data).into(); let file_name = format!("{}.webp", base64url_encode(&hash)); - if seen_hashes.insert(hash.to_vec()) { + if seen_hashes.insert(hash) { zip.add_file(&file_name, input.data); } @@ -177,6 +177,7 @@ pub fn create_sticker_pack_zip( } /// Builds a `wa::Message` with `StickerPackMessage` from upload results. +/// Caller must supply a 252x252 JPEG thumbnail. pub fn build_sticker_pack_message( zip_result: &StickerPackZipResult, zip_upload: &MediaUploadInfo, diff --git a/wacore/src/webp.rs b/wacore/src/webp.rs index 048401e90..09a5da35d 100644 --- a/wacore/src/webp.rs +++ b/wacore/src/webp.rs @@ -31,8 +31,10 @@ pub fn is_animated(data: &[u8]) -> bool { return true; } - // Chunks are padded to even size per RIFF spec - offset += 8 + chunk_size + (chunk_size & 1); + offset = match offset.checked_add(8 + chunk_size + (chunk_size & 1)) { + Some(next) => next, + None => break, + }; } false From 2e9cce533ea383ee5f32d52822416b2b276ac639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 28 Mar 2026 13:26:14 -0300 Subject: [PATCH 3/6] fix: central directory had extra u16 corrupting ZIP structure put_zero_u16s wrote 5 zero u16s instead of 4 after file_name_length, shifting external_file_attributes and local_header_offset by 2 bytes. Added a test that validates byte-level offsets through the central directory to prevent regressions. --- wacore/src/zip.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/wacore/src/zip.rs b/wacore/src/zip.rs index ca1a8d9ff..611a61659 100644 --- a/wacore/src/zip.rs +++ b/wacore/src/zip.rs @@ -78,7 +78,7 @@ impl ZipWriter { put_u32(&mut self.buf, e.size); // compressed put_u32(&mut self.buf, e.size); // uncompressed put_u16(&mut self.buf, e.name.len() as u16); - put_zero_u16s(&mut self.buf, 5); // extra len, comment len, disk start, internal attrs + put_zero_u16s(&mut self.buf, 4); // extra len, comment len, disk start, internal attrs put_u32(&mut self.buf, 0); // external attrs put_u32(&mut self.buf, e.offset); self.buf.extend_from_slice(&e.name); @@ -165,4 +165,37 @@ mod tests { 3 ); } + + #[test] + fn central_directory_offsets_valid() { + let data = b"test content here"; + let mut w = ZipWriter::new(); + w.add_file("test.bin", data); + let zip = w.finish(); + + // Parse EOCD to find central directory + let eocd_pos = zip.len() - 22; + let cd_offset = + u32::from_le_bytes(zip[eocd_pos + 16..eocd_pos + 20].try_into().unwrap()) as usize; + + // Central directory entry should start with its signature + assert_eq!( + &zip[cd_offset..cd_offset + 4], + &CENTRAL_DIR_SIG.to_le_bytes() + ); + + // Parse central directory entry to extract local header offset (at fixed offset 42) + let local_offset = + u32::from_le_bytes(zip[cd_offset + 42..cd_offset + 46].try_into().unwrap()) as usize; + assert_eq!(local_offset, 0); + + // Local header should contain the original data + let name_len = u16::from_le_bytes( + zip[local_offset + 26..local_offset + 28] + .try_into() + .unwrap(), + ) as usize; + let data_start = local_offset + 30 + name_len; + assert_eq!(&zip[data_start..data_start + data.len()], data); + } } From 100d7ffba9ad280e0018a29448faaa140a0dfdb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 28 Mar 2026 13:28:01 -0300 Subject: [PATCH 4/6] fix: address review findings (round 3) - Redact media_key in UploadOptions Debug impl - Validate pack_id to prevent path traversal in ZIP entry names - Require VP8X chunk_size >= 10 before reading animation flag - Chain checked_add calls to prevent inner arithmetic overflow --- src/upload.rs | 10 +++++++++- wacore/src/sticker_pack.rs | 24 ++++++++++++++++++++++++ wacore/src/webp.rs | 10 +++++++--- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/upload.rs b/src/upload.rs index 8827967c3..4be11c9d3 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -261,12 +261,20 @@ struct RawUploadResponse { } #[non_exhaustive] -#[derive(Default, Clone, Debug)] +#[derive(Default, Clone)] pub struct UploadOptions { /// Reuse an existing media key instead of generating a fresh one. pub media_key: Option>, } +impl std::fmt::Debug for UploadOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UploadOptions") + .field("media_key", &self.media_key.as_ref().map(|_| "")) + .finish() + } +} + impl UploadOptions { pub fn new() -> Self { Self::default() diff --git a/wacore/src/sticker_pack.rs b/wacore/src/sticker_pack.rs index 8fe1685be..c5614b8ff 100644 --- a/wacore/src/sticker_pack.rs +++ b/wacore/src/sticker_pack.rs @@ -130,6 +130,16 @@ pub fn create_sticker_pack_zip( stickers: &[StickerInput], cover: &[u8], ) -> Result { + if pack_id.is_empty() + || pack_id.len() > 128 + || pack_id + .bytes() + .any(|b| b == b'/' || b == b'\\' || b == b'.' || b < 0x20) + { + bail!( + "invalid pack_id: must be non-empty, <= 128 bytes, no path separators or control chars" + ); + } if stickers.is_empty() { bail!("sticker pack must contain at least 1 sticker"); } @@ -324,6 +334,20 @@ mod tests { assert_eq!(sticker.accessibility_label.as_deref(), Some("happy face")); } + #[test] + fn invalid_pack_id_rejected() { + let s = dummy_webp(1); + let cover = dummy_webp(0); + let stickers = vec![StickerInput::new(&s)]; + + assert!(create_sticker_pack_zip("", &stickers, &cover).is_err()); + assert!(create_sticker_pack_zip("../evil", &stickers, &cover).is_err()); + assert!(create_sticker_pack_zip("a/b", &stickers, &cover).is_err()); + assert!(create_sticker_pack_zip("a\\b", &stickers, &cover).is_err()); + assert!(create_sticker_pack_zip("has.dot", &stickers, &cover).is_err()); + assert!(create_sticker_pack_zip("valid-pack_id", &stickers, &cover).is_ok()); + } + #[test] fn build_message_fields() { let s1 = dummy_webp(1); diff --git a/wacore/src/webp.rs b/wacore/src/webp.rs index 09a5da35d..6fec55c62 100644 --- a/wacore/src/webp.rs +++ b/wacore/src/webp.rs @@ -20,8 +20,7 @@ pub fn is_animated(data: &[u8]) -> bool { data[offset + 7], ]) as usize; - if fourcc == b"VP8X" && offset + 8 < data.len() { - // Animation flag is bit 1 of the first byte after chunk header + if fourcc == b"VP8X" && chunk_size >= 10 && offset + 8 < data.len() { if data[offset + 8] & 0x02 != 0 { return true; } @@ -31,7 +30,12 @@ pub fn is_animated(data: &[u8]) -> bool { return true; } - offset = match offset.checked_add(8 + chunk_size + (chunk_size & 1)) { + // Each addition checked to prevent overflow on 32-bit + offset = match offset + .checked_add(8) + .and_then(|v| v.checked_add(chunk_size)) + .and_then(|v| v.checked_add(chunk_size & 1)) + { Some(next) => next, None => break, }; From ece212cab140eb2bffa2adc56b8e9a96f4c7f029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 28 Mar 2026 13:31:22 -0300 Subject: [PATCH 5/6] fix: collapse nested if in webp parser (clippy) --- wacore/src/webp.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/wacore/src/webp.rs b/wacore/src/webp.rs index 6fec55c62..eb3f5b474 100644 --- a/wacore/src/webp.rs +++ b/wacore/src/webp.rs @@ -20,10 +20,12 @@ pub fn is_animated(data: &[u8]) -> bool { data[offset + 7], ]) as usize; - if fourcc == b"VP8X" && chunk_size >= 10 && offset + 8 < data.len() { - if data[offset + 8] & 0x02 != 0 { - return true; - } + if fourcc == b"VP8X" + && chunk_size >= 10 + && offset + 8 < data.len() + && data[offset + 8] & 0x02 != 0 + { + return true; } if fourcc == b"ANIM" || fourcc == b"ANMF" { From 02572b26f68103c048f88489a7bcf4d4e9f33bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 28 Mar 2026 13:36:20 -0300 Subject: [PATCH 6/6] fix: match WA Web proto fields exactly Remove fields WA Web doesn't set on outgoing sticker packs: media_key_timestamp, thumbnail_height/width, sticker_pack_origin, image_data_hash. Add caption field to StickerPackMetadata. Verified against GenerateStickerPackMessageProto.js. --- wacore/src/sticker_pack.rs | 40 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/wacore/src/sticker_pack.rs b/wacore/src/sticker_pack.rs index c5614b8ff..b8cc152ee 100644 --- a/wacore/src/sticker_pack.rs +++ b/wacore/src/sticker_pack.rs @@ -23,7 +23,7 @@ //! Sticker format requirements (user's responsibility): //! - Stickers: 512x512 WebP //! - Cover: WebP, stored in ZIP as `{pack_id}.webp` -//! - Thumbnail: 252x252 JPEG, uploaded separately with the same `media_key` +//! - Thumbnail: JPEG, uploaded separately with the same `media_key` use crate::webp; use crate::zip::ZipWriter; @@ -71,6 +71,7 @@ pub struct StickerPackMetadata { pub name: String, pub publisher: String, pub description: Option, + pub caption: Option, } impl StickerPackMetadata { @@ -80,6 +81,7 @@ impl StickerPackMetadata { name, publisher, description: None, + caption: None, } } @@ -87,6 +89,11 @@ impl StickerPackMetadata { self.description = Some(desc); self } + + pub fn with_caption(mut self, caption: String) -> Self { + self.caption = Some(caption); + self + } } /// Upload result fields for proto construction. @@ -187,15 +194,15 @@ pub fn create_sticker_pack_zip( } /// Builds a `wa::Message` with `StickerPackMessage` from upload results. -/// Caller must supply a 252x252 JPEG thumbnail. +/// +/// Proto fields match WA Web's `GenerateStickerPackMessageProto.js` exactly. +/// Caller must supply a JPEG thumbnail uploaded separately. pub fn build_sticker_pack_message( zip_result: &StickerPackZipResult, zip_upload: &MediaUploadInfo, thumb_upload: &MediaUploadInfo, metadata: StickerPackMetadata, ) -> wa::Message { - use wa::message::sticker_pack_message::StickerPackOrigin; - let pack_msg = wa::message::StickerPackMessage { sticker_pack_id: Some(metadata.pack_id), name: Some(metadata.name), @@ -206,19 +213,14 @@ pub fn build_sticker_pack_message( file_enc_sha256: Some(zip_upload.file_enc_sha256.clone()), media_key: Some(zip_upload.media_key.clone()), direct_path: Some(zip_upload.direct_path.clone()), - caption: None, - context_info: None, + caption: metadata.caption, pack_description: metadata.description, - media_key_timestamp: Some(zip_upload.media_key_timestamp), - tray_icon_file_name: Some(zip_result.tray_icon_file_name.clone()), - thumbnail_direct_path: Some(thumb_upload.direct_path.clone()), thumbnail_sha256: Some(thumb_upload.file_sha256.clone()), thumbnail_enc_sha256: Some(thumb_upload.file_enc_sha256.clone()), - thumbnail_height: Some(252), - thumbnail_width: Some(252), - image_data_hash: None, + thumbnail_direct_path: Some(thumb_upload.direct_path.clone()), sticker_pack_size: Some(zip_result.zip_bytes.len() as u64), - sticker_pack_origin: Some(StickerPackOrigin::UserCreated as i32), + tray_icon_file_name: Some(zip_result.tray_icon_file_name.clone()), + ..Default::default() }; wa::Message { @@ -392,12 +394,12 @@ mod tests { pack.thumbnail_direct_path.as_deref(), Some("/mms/thumbnail-sticker-pack/def") ); - assert_eq!(pack.thumbnail_height, Some(252)); - assert_eq!(pack.thumbnail_width, Some(252)); - assert_eq!( - pack.sticker_pack_origin, - Some(wa::message::sticker_pack_message::StickerPackOrigin::UserCreated as i32) - ); assert_eq!(pack.tray_icon_file_name.as_deref(), Some("msg-test.webp")); + // WA Web doesn't set these on outgoing sticker packs + assert_eq!(pack.thumbnail_height, None); + assert_eq!(pack.thumbnail_width, None); + assert_eq!(pack.sticker_pack_origin, None); + assert_eq!(pack.media_key_timestamp, None); + assert_eq!(pack.image_data_hash, None); } }