From e701e486bef265b3cf85c551563045f4335ed675 Mon Sep 17 00:00:00 2001 From: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Date: Wed, 22 Jul 2026 16:57:00 -0700 Subject: [PATCH 1/3] fix(sharing): preserve agent/team snapshot tEXt chunks through media sanitization PR #2006 broke .agent.png / .team.png sharing: the desktop sanitizer re-encodes every PNG (dropping all tEXt chunks, including the buzz_agent_snapshot manifest), and the relay rejects any PNG containing a tEXt chunk. Recipients of a shared agent saw 'invalid agent snapshot: PNG does not contain a buzz_agent_snapshot tEXt chunk'. Fix, both sides of the contract: - Relay (buzz-media): allow at most one tEXt chunk per PNG, and only when its keyword is byte-exactly buzz_agent_snapshot or buzz_team_snapshot. Duplicate snapshot chunks, near-miss keywords, and all other metadata chunks remain forbidden. - Desktop: sanitize_image_for_upload extracts the snapshot tEXt chunk before the re-encode and re-injects it immediately after IHDR (the png crate's read_info() only surfaces text chunks that appear before IDAT). All other metadata is still stripped. The relay change is deploy-first safe: it strictly widens acceptance, so all currently-valid uploads remain valid. Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c --- crates/buzz-media/src/validation.rs | 94 ++++++++++++++- desktop/src-tauri/src/commands/media.rs | 147 +++++++++++++++++++++++- 2 files changed, 239 insertions(+), 2 deletions(-) diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 0d03429e06..d1c939f89f 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/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 5aaa45b4a3..1f84527636 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -212,6 +212,78 @@ fn is_animated_image(body: &[u8], mime: &str) -> bool { } } +/// 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. Must stay in sync with the relay's +/// allowlist in `buzz-media::validation`. +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. +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. +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) +} + pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, String> { let format = match mime { "image/jpeg" => image::ImageFormat::Jpeg, @@ -231,6 +303,16 @@ pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, mime: &str) -> Result inject_snapshot_text_chunk(sanitized, &chunk), + None => Ok(sanitized), + } } pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { @@ -914,6 +1000,65 @@ mod tests { ); } + #[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_legacy_upload_retry_statuses_are_narrow() { assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND)); From 358b083a3d21d27f01f2dd5da4d2d9332378db77 Mon Sep 17 00:00:00 2001 From: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Date: Wed, 22 Jul 2026 17:09:18 -0700 Subject: [PATCH 2/3] test(sharing): add cross-contract snapshot pipeline test; fix rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an end-to-end regression test that runs a real encoded .agent.png through the exact pipeline that broke in production: export (encode_snapshot_png) → client sanitize (sanitize_image_for_upload) → relay validation (buzz-media validate_content) → recipient import (decode_snapshot_png). Adds buzz-media as a desktop dev-dependency to prove the client and relay sides of the contract against each other. Also fixes a rustfmt violation in the relay validation tests. Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c --- crates/buzz-media/src/validation.rs | 2 +- desktop/src-tauri/Cargo.lock | 239 +++++++++++++++++++++++- desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/src/commands/media.rs | 82 ++++++++ 4 files changed, 320 insertions(+), 6 deletions(-) diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index d1c939f89f..5f16beb695 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -1305,7 +1305,7 @@ mod tests { 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"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(); 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 1f84527636..5c236f63b9 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -1040,6 +1040,88 @@ mod tests { } } + #[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); + } + #[test] fn test_sanitizer_still_strips_all_text_from_regular_pngs() { let mut source = Vec::new(); From 687b9720ea792d89ca8ccac590200b9b5cacc211 Mon Sep 17 00:00:00 2001 From: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Date: Wed, 22 Jul 2026 17:18:55 -0700 Subject: [PATCH 3/3] refactor(desktop): split snapshot tEXt helpers into media_snapshot_png.rs media.rs crossed the 1000-line desktop file-size limit. Move the snapshot chunk extract/inject helpers and their tests into a dedicated module, following the precedent set by media_gif.rs. No behavior change. Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c --- desktop/src-tauri/src/commands/media.rs | 217 +---------------- .../src/commands/media_snapshot_png.rs | 226 ++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 1 + 3 files changed, 229 insertions(+), 215 deletions(-) create mode 100644 desktop/src-tauri/src/commands/media_snapshot_png.rs diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 5c236f63b9..11a4e6d3bc 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -212,78 +212,6 @@ fn is_animated_image(body: &[u8], mime: &str) -> bool { } } -/// 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. Must stay in sync with the relay's -/// allowlist in `buzz-media::validation`. -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. -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. -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) -} - pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, String> { let format = match mime { "image/jpeg" => image::ImageFormat::Jpeg, @@ -308,7 +236,7 @@ pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, mime: &str) -> Result inject_snapshot_text_chunk(sanitized, &chunk), + Some(chunk) => super::media_snapshot_png::inject_snapshot_text_chunk(sanitized, &chunk), None => Ok(sanitized), } } @@ -1000,147 +928,6 @@ mod tests { ); } - #[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_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); - } - - #[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_legacy_upload_retry_statuses_are_narrow() { assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND)); 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;