diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 0d03429e06..5f16beb695 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -571,6 +571,24 @@ fn validate_jpeg_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { Err(MediaError::InvalidImage) } +/// tEXt keywords that carry Buzz snapshot manifests (`.agent.png` / +/// `.team.png`). These are deliberate product payloads — agent/team sharing +/// embeds a manifest in a single tEXt chunk — so they are exempt from the +/// metadata ban. Exactly one snapshot chunk is permitted per file; every +/// other textual/metadata chunk remains forbidden. +const PNG_SNAPSHOT_KEYWORDS: [&[u8]; 2] = [b"buzz_agent_snapshot", b"buzz_team_snapshot"]; + +/// Returns true when a raw tEXt chunk payload is a Buzz snapshot manifest: +/// the payload must start with an allowlisted keyword followed by the +/// keyword/text NUL separator. +fn is_snapshot_text_chunk(payload: &[u8]) -> bool { + PNG_SNAPSHOT_KEYWORDS.iter().any(|keyword| { + payload.len() > keyword.len() + && &payload[..keyword.len()] == *keyword + && payload[keyword.len()] == 0 + }) +} + fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { const SIG: &[u8] = b"\x89PNG\r\n\x1a\n"; if !bytes.starts_with(SIG) { @@ -578,6 +596,7 @@ fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { } let mut i = SIG.len(); let mut saw_iend = false; + let mut saw_snapshot_chunk = false; while i < bytes.len() { if i + 12 > bytes.len() { return Err(MediaError::InvalidImage); @@ -589,7 +608,19 @@ fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { .and_then(|v| v.checked_add(len)) .filter(|&v| v <= bytes.len()) .ok_or(MediaError::InvalidImage)?; - if matches!(&kind, b"eXIf" | b"tEXt" | b"zTXt" | b"iTXt" | b"iCCP") { + if &kind == b"tEXt" { + // Buzz agent/team snapshot manifests ride in a single tEXt chunk + // with an allowlisted keyword. Anything else — other keywords, or + // a second snapshot chunk — is a forbidden metadata channel. + let payload = &bytes[i + 8..end - 4]; + if saw_snapshot_chunk || !is_snapshot_text_chunk(payload) { + return Err(MediaError::MetadataForbidden); + } + saw_snapshot_chunk = true; + i = end; + continue; + } + if matches!(&kind, b"eXIf" | b"zTXt" | b"iTXt" | b"iCCP") { return Err(MediaError::MetadataForbidden); } // Unknown ancillary chunks are private metadata channels. Keep only @@ -1230,6 +1261,67 @@ mod tests { )); } + #[test] + fn test_png_snapshot_text_chunks_are_allowed() { + // Agent/team snapshot manifests ride in an allowlisted tEXt chunk; + // the relay must accept exactly one such chunk per file. + let config = test_config(); + for keyword in [b"buzz_agent_snapshot".as_slice(), b"buzz_team_snapshot"] { + let mut payload = keyword.to_vec(); + payload.push(0); + payload.extend_from_slice(b"eyJmb3JtYXQiOiJidXp6In0="); + let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + png.extend_from_slice(&png_chunk(b"tEXt", &payload)); + png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]); + assert_eq!( + validate_content(&png, &config).unwrap_or_else(|error| panic!( + "rejected snapshot tEXt keyword {}: {error}", + String::from_utf8_lossy(keyword) + )), + "image/png" + ); + } + } + + #[test] + fn test_png_snapshot_text_chunk_rejected_when_duplicated_or_spoofed() { + let config = test_config(); + + // Two snapshot chunks: the second is a covert channel. + let mut payload = b"buzz_agent_snapshot".to_vec(); + payload.push(0); + payload.extend_from_slice(b"data"); + let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + png.extend_from_slice(&png_chunk(b"tEXt", &payload)); + png.extend_from_slice(&png_chunk(b"tEXt", &payload)); + png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]); + assert!(matches!( + validate_content(&png, &config), + Err(MediaError::MetadataForbidden) + )); + + // Keyword prefix without the NUL separator, and near-miss keywords, + // stay forbidden. + for payload in [ + b"buzz_agent_snapshotX\0data".as_slice(), + b"buzz_agent_snapshot_extra\0data", + b"buzz_agent_snapshot", // no separator at all + b"Comment\0GPS=37.7,-122.4", + ] { + let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + png.extend_from_slice(&png_chunk(b"tEXt", payload)); + png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]); + assert!( + matches!( + validate_content(&png, &config), + Err(MediaError::MetadataForbidden) + ), + "accepted non-snapshot tEXt payload {:?}", + String::from_utf8_lossy(payload) + ); + } + } + #[test] fn test_rejects_jpeg_app_metadata_comments_and_trailing_payload() { let config = test_config(); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6af56ab4f6..810f921769 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -499,7 +499,11 @@ dependencies = [ "base64 0.22.1", "http", "log", + "rustls", + "serde", + "serde_json", "url", + "webpki-roots 1.0.8", ] [[package]] @@ -556,6 +560,23 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-creds" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3b85155d265df828f84e53886ed9e427aed979dd8a39f5b8b2162c77e142d7" +dependencies = [ + "attohttpc", + "home", + "log", + "quick-xml 0.38.4", + "rust-ini", + "serde", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -579,6 +600,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "aws-region" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838b36c8dc927b6db1b6c6b8f5d05865f2213550b9e83bf92fa99ed6525472c0" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "axum" version = "0.8.9" @@ -854,6 +884,12 @@ dependencies = [ "piper", ] +[[package]] +name = "blurhash" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" + [[package]] name = "bon" version = "3.9.3" @@ -982,6 +1018,7 @@ dependencies = [ "base64 0.22.1", "buzz-agent", "buzz-core", + "buzz-media", "buzz-persona", "buzz-sdk", "bytes", @@ -1051,6 +1088,36 @@ dependencies = [ "zip 8.6.0", ] +[[package]] +name = "buzz-media" +version = "0.1.0" +dependencies = [ + "axum", + "blurhash", + "buzz-core", + "bytes", + "chrono", + "futures-core", + "futures-util", + "hex", + "image", + "imagesize", + "infer", + "mp4", + "nostr", + "rust-s3", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "ulid", + "uuid", +] + [[package]] name = "buzz-persona" version = "0.1.0" @@ -1451,6 +1518,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if 1.0.4", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -2066,7 +2146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3588,6 +3668,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -3693,6 +3782,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -3957,6 +4047,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "1.9.3" @@ -4776,6 +4872,23 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "mdns-sd" version = "0.19.2" @@ -5252,6 +5365,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "minidom" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e394a0e3c7ccc2daea3dffabe82f09857b6b510cb25af87d54bf3e910ac1642d" +dependencies = [ + "rxml", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5388,6 +5510,20 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "muda" version = "0.19.3" @@ -5948,6 +6084,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", ] [[package]] @@ -6941,7 +7078,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -7355,6 +7492,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -7569,7 +7716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ "bitflags 2.13.0", - "compact_str", + "compact_str 0.9.1", "critical-section", "hashbrown 0.17.1", "itertools", @@ -7772,6 +7919,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -7779,6 +7928,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -7788,6 +7938,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.8", ] [[package]] @@ -8021,6 +8172,41 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-s3" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeedb13abdaa7e48d391de05b0569b37fa0a7a64a668dff6ffb2141ad0c2527e" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64 0.22.1", + "bytes", + "cfg-if 1.0.4", + "futures-util", + "hex", + "hmac 0.12.1", + "http", + "log", + "maybe-async", + "md5", + "minidom", + "percent-encoding", + "quick-xml 0.38.4", + "reqwest 0.12.28", + "serde", + "serde_derive", + "serde_json", + "sha2 0.10.9", + "sysinfo 0.37.2", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "url", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -8159,6 +8345,25 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rxml" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc94b580d0f5a6b7a2d604e597513d3c673154b52ddeccd1d5c32360d945ee" +dependencies = [ + "bytes", + "rxml_validation", +] + +[[package]] +name = "rxml_validation" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4" +dependencies = [ + "compact_str 0.7.1", +] + [[package]] name = "ryu" version = "1.0.23" @@ -9332,6 +9537,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "sysinfo" version = "0.38.4" @@ -9903,7 +10122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -10720,6 +10939,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + [[package]] name = "unarray" version = "0.1.4" @@ -12322,7 +12551,7 @@ dependencies = [ "serde", "serde_json", "shellexpand", - "sysinfo", + "sysinfo 0.38.4", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index dcb432b736..7826f44efd 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -126,3 +126,6 @@ strip-ansi-escapes = "0.2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. tokio = { version = "1", features = ["test-util"] } +# The relay's media validation, so the snapshot-sharing tests can prove the +# full export → sanitize → relay-accept → import contract end to end. +buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 5aaa45b4a3..11a4e6d3bc 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -231,6 +231,16 @@ pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, mime: &str) -> Result super::media_snapshot_png::inject_snapshot_text_chunk(sanitized, &chunk), + None => Ok(sanitized), + } } pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs new file mode 100644 index 0000000000..f2593ff9e0 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -0,0 +1,226 @@ +//! Preservation of Buzz snapshot tEXt chunks through the upload sanitizer. +//! +//! Split out of `media.rs` to keep that file under the desktop line-size +//! limit. Agent/team sharing embeds a manifest in a PNG `tEXt` chunk +//! (`buzz_agent_snapshot` / `buzz_team_snapshot`); the sanitizer's re-encode +//! would destroy it and the relay would previously reject it. These helpers +//! extract the chunk before the re-encode and re-inject it afterwards. The +//! relay allowlists exactly these keywords in `buzz-media::validation` — the +//! two lists must stay in sync. + +/// tEXt keywords that carry Buzz snapshot manifests (`.agent.png` / +/// `.team.png`). These chunks are the product payload of agent/team sharing — +/// they must survive the metadata strip. +const PNG_SNAPSHOT_KEYWORDS: [&[u8]; 2] = [b"buzz_agent_snapshot", b"buzz_team_snapshot"]; + +/// Extract the raw bytes of the first Buzz snapshot tEXt chunk (length + type +/// + payload + CRC) from a PNG, or `None` when absent/not a PNG. +/// +/// Walks the chunk structure directly instead of decoding the image so a +/// malformed file simply yields `None` and falls through to the normal +/// sanitize path. +pub(crate) fn extract_snapshot_text_chunk(bytes: &[u8]) -> Option> { + const SIG: &[u8] = b"\x89PNG\r\n\x1a\n"; + if !bytes.starts_with(SIG) { + return None; + } + let mut i = SIG.len(); + while i + 12 <= bytes.len() { + let len = u32::from_be_bytes(bytes[i..i + 4].try_into().ok()?) as usize; + let end = i.checked_add(12)?.checked_add(len)?; + if end > bytes.len() { + return None; + } + let kind = &bytes[i + 4..i + 8]; + if kind == b"tEXt" { + let payload = &bytes[i + 8..i + 8 + len]; + let is_snapshot = PNG_SNAPSHOT_KEYWORDS.iter().any(|keyword| { + payload.len() > keyword.len() + && &payload[..keyword.len()] == *keyword + && payload[keyword.len()] == 0 + }); + if is_snapshot { + return Some(bytes[i..end].to_vec()); + } + } + if kind == b"IEND" { + return None; + } + i = end; + } + None +} + +/// Re-insert a raw snapshot tEXt chunk into a sanitized PNG, immediately +/// after the IHDR chunk. Placement matters: the `png` crate decoder used by +/// the import path only exposes text chunks encountered before IDAT via +/// `read_info()`. The chunk bytes carry their own CRC, which remains valid +/// because the chunk content is unchanged. +pub(crate) fn inject_snapshot_text_chunk(png: Vec, chunk: &[u8]) -> Result, String> { + const SIG_LEN: usize = 8; + // IHDR is always the first chunk of a well-formed PNG. + if png.len() < SIG_LEN + 12 || &png[SIG_LEN + 4..SIG_LEN + 8] != b"IHDR" { + return Err("sanitized PNG is missing IHDR chunk".to_string()); + } + let ihdr_len = u32::from_be_bytes( + png[SIG_LEN..SIG_LEN + 4] + .try_into() + .map_err(|_| "sanitized PNG has malformed IHDR length".to_string())?, + ) as usize; + let ihdr_end = SIG_LEN + .checked_add(12) + .and_then(|v| v.checked_add(ihdr_len)) + .filter(|&v| v <= png.len()) + .ok_or_else(|| "sanitized PNG has malformed IHDR chunk".to_string())?; + let mut out = Vec::with_capacity(png.len() + chunk.len()); + out.extend_from_slice(&png[..ihdr_end]); + out.extend_from_slice(chunk); + out.extend_from_slice(&png[ihdr_end..]); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::super::media::sanitize_image_for_upload; + + #[test] + fn test_sanitizer_preserves_agent_snapshot_text_chunk() { + // Build a real 2×2 PNG carrying an agent-snapshot manifest chunk plus + // a mundane metadata chunk that must NOT survive. + for keyword in ["buzz_agent_snapshot", "buzz_team_snapshot"] { + let manifest = "eyJmb3JtYXQiOiJidXp6LWFnZW50LXNuYXBzaG90In0="; + let mut source = Vec::new(); + { + let mut enc = png::Encoder::new(std::io::Cursor::new(&mut source), 2, 2); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), manifest.to_string()) + .unwrap(); + enc.add_text_chunk("Comment".to_string(), "GPS=37.7,-122.4".to_string()) + .unwrap(); + let mut writer = enc.write_header().unwrap(); + writer.write_image_data(&[0u8; 16]).unwrap(); + } + + let sanitized = sanitize_image_for_upload(source, "image/png").unwrap(); + + // The snapshot manifest survives, readable by the same decoder the + // import path uses. + let decoder = png::Decoder::new(std::io::Cursor::new(&sanitized)); + let reader = decoder.read_info().unwrap(); + let texts = &reader.info().uncompressed_latin1_text; + let snapshot = texts + .iter() + .find(|c| c.keyword == keyword) + .unwrap_or_else(|| panic!("sanitized PNG lost the {keyword} tEXt chunk")); + assert_eq!(snapshot.text, manifest); + + // The mundane metadata chunk is stripped. + assert!( + !texts.iter().any(|c| c.keyword == "Comment"), + "sanitizer kept a non-snapshot tEXt chunk" + ); + } + } + + #[test] + fn test_sanitizer_still_strips_all_text_from_regular_pngs() { + let mut source = Vec::new(); + { + let mut enc = png::Encoder::new(std::io::Cursor::new(&mut source), 2, 2); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + enc.add_text_chunk("Comment".to_string(), "GPS=37.7,-122.4".to_string()) + .unwrap(); + let mut writer = enc.write_header().unwrap(); + writer.write_image_data(&[0u8; 16]).unwrap(); + } + + let sanitized = sanitize_image_for_upload(source, "image/png").unwrap(); + let decoder = png::Decoder::new(std::io::Cursor::new(&sanitized)); + let reader = decoder.read_info().unwrap(); + assert!(reader.info().uncompressed_latin1_text.is_empty()); + } + + #[test] + fn test_agent_snapshot_survives_full_share_pipeline() { + // Cross-contract regression for the production failure: a real + // encoded .agent.png must survive export → client sanitize → + // relay validation → import decode. Each seam is also covered by + // unit tests, but this proves the exact pipeline that broke. + use crate::managed_agents::agent_snapshot::{ + decode_snapshot_png, encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, + AgentSnapshotMemory, AgentSnapshotProfile, MemoryLevel, + }; + + let snapshot = AgentSnapshot { + format: "buzz-agent-snapshot".to_string(), + version: 1, + definition: AgentSnapshotDefinition { + name: "Tree Trunks".to_string(), + system_prompt: Some("You are a helpful agent.".to_string()), + runtime: Some("goose".to_string()), + model: None, + provider: None, + parallelism: Some(1), + respond_to: None, + respond_to_allowlist: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + name_pool: vec![], + }, + profile: AgentSnapshotProfile { + display_name: "Tree Trunks".to_string(), + about: Some("Shared agent".to_string()), + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![], + }, + }; + + // Export with a real avatar body (what the share flow uploads). + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 4, + image::Rgba([200, 120, 40, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + let exported = encode_snapshot_png(&snapshot, Some(avatar_png.get_ref())).unwrap(); + + // Client upload path. + let sanitized = sanitize_image_for_upload(exported, "image/png").unwrap(); + + // Relay ingest path. + let relay_config = buzz_media_pkg::MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + s3_region: "us-east-1".to_string(), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }; + assert_eq!( + buzz_media_pkg::validation::validate_content(&sanitized, &relay_config) + .expect("relay rejected a sanitized agent snapshot PNG"), + "image/png" + ); + + // Recipient import path. + let imported = decode_snapshot_png(&sanitized) + .expect("import failed on a PNG that passed sanitize + relay validation"); + assert_eq!(imported, snapshot); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index cf4cdca91d..0374a7d392 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -25,6 +25,7 @@ mod link_preview; pub(crate) mod media; mod media_download; mod media_gif; +mod media_snapshot_png; mod media_transcode; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm;