//! Avatar helpers: a participant's chosen [`Avatar`] plus the deterministic //! monogram + colour used as the fallback. //! //! Every participant gets a visual avatar (W4). When they haven't chosen a preset //! or uploaded an image, we fall back to a *monogram*: their initial(s) on a //! colour deterministically derived from a stable key (their node id, or their //! name where the id isn't available, e.g. a chat line). Pure + dependency-free //! so it's unit-testable and needs no image decoding for the common case. use serde::{Deserialize, Serialize}; /// Number of bundled preset avatars (`preset1.png`..`preset6.png`). pub const PRESET_COUNT: u8 = 6; /// The raw PNG bytes of each bundled preset, embedded so they're available /// offline and identical on every peer. Index 0 = preset 1, etc. pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [ include_bytes!("../assets/avatars/preset1.png"), include_bytes!("../assets/avatars/preset2.png"), include_bytes!("../assets/avatars/preset3.png"), include_bytes!("../assets/avatars/preset4.png"), include_bytes!("../assets/avatars/preset5.png"), include_bytes!("../assets/avatars/preset6.png"), ]; /// Longest side a custom avatar is downscaled to on ingest (both our own upload /// and the limit incoming peer images are validated against). Small on purpose: /// avatars render tiny, and it keeps the transported bytes well within the gossip /// frame budget. pub const CUSTOM_MAX_PX: u32 = 128; /// Hard cap on the base64 length of a custom avatar. Bounds the presence payload /// (which rides a 64 KB gossip frame) and the memory a malicious peer can make us /// hold. A 128px PNG is comfortably under this; anything larger is rejected. pub const CUSTOM_MAX_B64: usize = 48 * 1024; /// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`) /// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered /// from name + a colour key, no image). `Preset(i)` selects a bundled preset by /// zero-based index. `Custom` carries a base64-encoded, hard-capped PNG (W4 /// Phase 3): produced by [`process_upload`] for our own uploads, and validated by /// [`sanitize_incoming`] for untrusted peer ones. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum Avatar { /// Initials-on-colour fallback. #[default] Monogram, /// A bundled preset by zero-based index; out-of-range falls back to monogram. Preset(u8), /// A custom image as a base64-encoded PNG (capped, see [`CUSTOM_MAX_B64`]). Custom(String), } impl Avatar { /// The embedded PNG bytes for this avatar, or `None` when it isn't a preset /// (or the preset index is out of range, which renders as a monogram). pub fn preset_png(&self) -> Option<&'static [u8]> { match self { Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(), _ => None, } } /// The decoded PNG bytes for a custom avatar, or `None` for monogram/preset /// (or if the stored base64 is malformed). Used to build an image handle. pub fn custom_png(&self) -> Option> { use base64::Engine; match self { Avatar::Custom(b64) => base64::engine::general_purpose::STANDARD .decode(b64.as_bytes()) .ok(), _ => None, } } /// Validate an avatar that arrived from an untrusted peer (over presence), /// returning a safe value. Monogram/Preset pass through (an out-of-range /// preset just renders as a monogram). A `Custom` is accepted only if its /// base64 is within [`CUSTOM_MAX_B64`], decodes, and the PNG decodes within /// [`CUSTOM_MAX_PX`] bounds (guards against decompression bombs / junk); /// otherwise it's downgraded to a monogram. Re-encode-free: we keep the /// original (already-validated) base64 to avoid re-deriving it every receipt. pub fn sanitize_incoming(self) -> Avatar { match self { Avatar::Custom(ref b64) => { if b64.len() <= CUSTOM_MAX_B64 && custom_b64_is_valid_png(b64) { self } else { Avatar::Monogram } } other => other, } } } /// Decode a base64 PNG and confirm it's a real PNG within [`CUSTOM_MAX_PX`] /// bounds, using the `image` crate's decode limits so a malicious image can't /// allocate unbounded memory. Returns false on any error. fn custom_b64_is_valid_png(b64: &str) -> bool { use base64::Engine; let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()) else { return false; }; let mut limits = image::Limits::default(); // Cap dimensions a bit above our resize target to allow for slight slack. limits.max_image_width = Some(CUSTOM_MAX_PX * 2); limits.max_image_height = Some(CUSTOM_MAX_PX * 2); let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)); reader.set_format(image::ImageFormat::Png); reader.limits(limits); match reader.decode() { Ok(img) => img.width() <= CUSTOM_MAX_PX * 2 && img.height() <= CUSTOM_MAX_PX * 2, Err(_) => false, } } /// Process a user-picked image file (arbitrary png/jpeg bytes) into a capped /// custom [`Avatar`]: decode, downscale so the longest side is [`CUSTOM_MAX_PX`] /// (aspect preserved), re-encode as PNG, base64. Errors (undecodable, or the /// result somehow exceeds the cap) are returned as a message for the UI. pub fn process_upload(raw: &[u8]) -> Result { use base64::Engine; let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?; let small = img.thumbnail(CUSTOM_MAX_PX, CUSTOM_MAX_PX); let mut png = std::io::Cursor::new(Vec::new()); small .write_to(&mut png, image::ImageFormat::Png) .map_err(|e| format!("Couldn't encode image: {e}"))?; let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner()); if b64.len() > CUSTOM_MAX_B64 { return Err("Image is too large even after resizing.".to_string()); } Ok(Avatar::Custom(b64)) } /// Curated palette the monogram background is chosen from. Picked to be distinct /// and legible under the app's (mostly dark) themes; the matching text colour is /// decided per-background by [`use_dark_text_on`]. Order is part of the stable /// mapping — don't reorder without accepting that everyone's colour shifts. pub const PALETTE: [(u8, u8, u8); 12] = [ (0xE5, 0x73, 0x73), // red (0xE5, 0x9E, 0x57), // orange (0xE5, 0xC0, 0x7B), // amber (0x8C, 0xC2, 0x65), // green (0x5E, 0xC8, 0xA0), // teal (0x5C, 0xB3, 0xE5), // blue (0x6E, 0x8C, 0xE5), // azure (0x9A, 0x8C, 0xE5), // indigo (0xC0, 0x7B, 0xE5), // violet (0xE5, 0x7B, 0xC0), // pink (0xB0, 0x8B, 0x6E), // brown (0x8A, 0x9B, 0xA8), // slate ]; /// Pick a stable palette colour for `key` (a node id string, or a name). Uses an /// FNV-1a hash so the same key always maps to the same colour across machines. pub fn color_for_key(key: &str) -> (u8, u8, u8) { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for b in key.as_bytes() { hash ^= u64::from(*b); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } PALETTE[(hash % PALETTE.len() as u64) as usize] } /// Whether dark text is more legible than white on the given background, by /// relative luminance (sRGB-weighted). Light backgrounds → dark text. pub fn use_dark_text_on((r, g, b): (u8, u8, u8)) -> bool { let lum = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b); lum > 150.0 } /// The 1–2 character monogram for a display name: the first letters of its first /// two alphanumeric words, uppercased. Falls back to "?" when nothing usable /// (empty name, or only emoji/punctuation) remains. pub fn initials(name: &str) -> String { let mut firsts = name .split_whitespace() .filter_map(|w| w.chars().find(|c| c.is_alphanumeric())); match (firsts.next(), firsts.next()) { (Some(a), Some(b)) => format!("{}{}", a.to_uppercase(), b.to_uppercase()), (Some(a), None) => a.to_uppercase().to_string(), _ => "?".to_string(), } } /// A small content-addressed LRU cache mapping image bytes to a built value /// (e.g. an `iced` image handle), so the SAME value is reused across redraws /// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain /// hash map: /// /// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on /// overflow, so a peer can't grow the cache without limit by publishing an /// endless stream of distinct valid avatars. /// 2. **Collision-safe** — a hit requires full byte equality, not just a matching /// 64-bit hash, so a hash collision can never return a different image's value. /// /// Linear scan; intended for small `cap` (tens of entries). pub struct ByteLru { cap: usize, /// `(content hash, content bytes, value)`; back = most recently used. entries: Vec<(u64, Vec, V)>, } impl ByteLru { /// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1). pub fn new(cap: usize) -> Self { Self { cap: cap.max(1), entries: Vec::new() } } /// Return the cached value for these exact `bytes`, building and inserting it /// on a miss (evicting the least-recently-used entry once over `cap`). A hit /// verifies full byte equality, so a 64-bit hash collision never returns the /// wrong value. A hit also refreshes the entry's recency. pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hasher); self.get_or_insert_hashed(hasher.finish(), bytes, build) } /// Inner seam with the content `hash` supplied explicitly. Production callers /// use [`get_or_insert`]; tests use this to force a hash collision (different /// bytes, same hash) and exercise the byte-equality guard. fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V { if let Some(idx) = self .entries .iter() .position(|(h, b, _)| *h == hash && b.as_slice() == bytes) { // LRU touch: move the hit entry to the back (most recent). let entry = self.entries.remove(idx); let val = entry.2.clone(); self.entries.push(entry); return val; } let val = build(); if self.entries.len() >= self.cap { self.entries.remove(0); // evict least-recently-used } self.entries.push((hash, bytes.to_vec(), val.clone())); val } #[cfg(test)] fn len(&self) -> usize { self.entries.len() } } #[cfg(test)] mod tests { use super::*; #[test] fn byte_lru_reuses_value_for_identical_bytes() { let mut lru: ByteLru = ByteLru::new(4); let mut next = 0u32; let mut build = |lru: &mut ByteLru, b: &[u8]| { lru.get_or_insert(b, || { next += 1; next }) }; // Same bytes → same value, built only once. assert_eq!(build(&mut lru, b"alice"), 1); assert_eq!(build(&mut lru, b"alice"), 1); // Different bytes → a freshly built value. assert_eq!(build(&mut lru, b"bob"), 2); assert_eq!(lru.len(), 2); } #[test] fn byte_lru_evicts_least_recently_used() { let mut lru: ByteLru = ByteLru::new(2); let mut n = 0u32; let mut ins = |lru: &mut ByteLru, b: &[u8]| { lru.get_or_insert(b, || { n += 1; n }) }; ins(&mut lru, b"a"); // -> 1 ins(&mut lru, b"b"); // -> 2, cache = [a, b] ins(&mut lru, b"a"); // touch a, cache = [b, a] ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c] assert_eq!(lru.len(), 2); // `a` survived (recently touched) → still value 1, not rebuilt. assert_eq!(ins(&mut lru, b"a"), 1); // `b` was evicted → rebuilt with a new value. assert_eq!(ins(&mut lru, b"b"), 4); } #[test] fn byte_lru_byte_equality_survives_a_hash_collision() { // Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a // bare-hash cache would alias — Tier C F-03 collision bug). let mut lru: ByteLru = ByteLru::new(4); assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1); // `bob` collides on the hash but differs in bytes → a MISS, built fresh, // NOT aliased to alice's value. assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2); // Both coexist; each re-lookup returns its own value (build closure unused). assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1); assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2); assert_eq!(lru.len(), 2); } #[test] fn initials_takes_first_two_words() { assert_eq!(initials("Alice"), "A"); assert_eq!(initials("alice bob"), "AB"); assert_eq!(initials(" spaced out name "), "SO"); } #[test] fn initials_handles_unicode_and_skips_non_alnum() { assert_eq!(initials("héllo"), "H"); // Leading emoji word is skipped; the next alphanumeric word is used. assert_eq!(initials("🎙 Mike"), "M"); } #[test] fn initials_falls_back_to_question_mark() { assert_eq!(initials(""), "?"); assert_eq!(initials(" "), "?"); assert_eq!(initials("🎉🎊"), "?"); } #[test] fn color_is_deterministic_and_in_palette() { let c1 = color_for_key("node-abc"); let c2 = color_for_key("node-abc"); assert_eq!(c1, c2, "same key must map to same colour"); assert!(PALETTE.contains(&c1)); // Different keys generally differ; at minimum the function is total. let _ = color_for_key(""); } #[test] fn avatar_default_is_monogram() { assert_eq!(Avatar::default(), Avatar::Monogram); assert!(Avatar::default().preset_png().is_none()); } #[test] fn preset_png_in_range_and_out_of_range() { // Every declared preset index resolves to embedded bytes. for i in 0..PRESET_COUNT { assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing"); } // Out-of-range index gracefully yields None (→ monogram fallback). assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none()); assert!(Avatar::Preset(250).preset_png().is_none()); } #[test] fn avatar_serde_round_trips_and_defaults() { // Round-trips through JSON (it rides presence + persists in config). for a in [Avatar::Monogram, Avatar::Preset(0), Avatar::Preset(5)] { let s = serde_json::to_string(&a).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), a); } } /// A valid PNG of the given size, as raw bytes (test helper). fn make_png(w: u32, h: u32) -> Vec { let img = image::DynamicImage::new_rgba8(w, h); let mut buf = std::io::Cursor::new(Vec::new()); img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); buf.into_inner() } #[test] fn process_upload_resizes_and_round_trips() { // A 400x200 image downscales so the longest side is CUSTOM_MAX_PX. let raw = make_png(400, 200); let avatar = process_upload(&raw).expect("should process"); assert!(matches!(avatar, Avatar::Custom(_))); // The stored base64 decodes back to a PNG within bounds. let png = avatar.custom_png().expect("custom png decodes"); let decoded = image::load_from_memory(&png).unwrap(); assert_eq!(decoded.width().max(decoded.height()), CUSTOM_MAX_PX); assert!(decoded.width() <= CUSTOM_MAX_PX && decoded.height() <= CUSTOM_MAX_PX); } #[test] fn process_upload_rejects_non_image() { assert!(process_upload(b"definitely not an image").is_err()); } #[test] fn sanitize_incoming_passes_monogram_and_presets() { assert_eq!(Avatar::Monogram.sanitize_incoming(), Avatar::Monogram); assert_eq!(Avatar::Preset(2).sanitize_incoming(), Avatar::Preset(2)); } #[test] fn sanitize_incoming_accepts_valid_custom() { let avatar = process_upload(&make_png(64, 64)).unwrap(); assert_eq!(avatar.clone().sanitize_incoming(), avatar); } #[test] fn sanitize_incoming_rejects_junk_and_oversize() { // Not valid base64 / not a PNG → downgraded to monogram. assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram); // Over the byte cap → downgraded without even decoding. let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1)); assert_eq!(huge.sanitize_incoming(), Avatar::Monogram); } #[test] fn dark_text_chosen_on_light_backgrounds() { assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text assert!(!use_dark_text_on((0x10, 0x10, 0x10))); // near-black bg → light text } }