Follow-up to 5c11947 after Codex's adversarial audit
(tier-c-audit-2026-06-23.md). Fixes a regression the roster cap introduced
and closes F-01's two cheap unbounded-growth vectors. No wire/protocol
change, no new deps.
- Regression (cap × reconnect): a peer reconnecting from a transient drop
sits in `disconnected_peers`, not the live roster, so the new cap could
reject it as "new" at a full 32-peer roster — and the eager
`disconnected_peers.remove()` (before the cap check) then orphaned its
recovery state so a later signed Leave skipped cleanup. Now reconnecting
(and existing) peers are exempt from the cap via the pure
`announce_subject_to_cap`, and the disconnect marker is cleared only after
admission. PeerJoined semantics for reconnects are preserved.
- F-01 replay map: `state_mutations_seen` was uncapped, so signed Leaves
from unlimited generated keys grew it for the room's lifetime. Prune
entries older than the freshness window once past a soft cap
(`prune_stale_mutations`) — stale entries can't gate an in-window message
(verify_gossip rejects the replay first), so replay protection is intact;
the map is now bounded to ~authors-seen-per-window.
- F-01 address lookup: a signed Leave now calls `remove_endpoint_info`, so
cycling identities through Announce→Leave can't grow the iroh lookup
without bound. Re-announce re-populates it.
- F-03 test: added a forced same-hash/different-bytes ByteLru test (via a
hash-injectable inner seam) so collision-safety is regression-tested, not
just code-reviewed.
Deferred follow-ups from the audit (logged): recovery/known_peers identity
cap (needs a design pass, touches reconnect-resilience), F-02 result-cache
LRU, and the (author,id)-vs-id attachment aliasing integrity bug.
416 lib tests (+3), clippy --all-targets clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
421 lines
17 KiB
Rust
421 lines
17 KiB
Rust
//! 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<Vec<u8>> {
|
||
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<Avatar, String> {
|
||
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<V> {
|
||
cap: usize,
|
||
/// `(content hash, content bytes, value)`; back = most recently used.
|
||
entries: Vec<(u64, Vec<u8>, V)>,
|
||
}
|
||
|
||
impl<V: Clone> ByteLru<V> {
|
||
/// 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<u32> = ByteLru::new(4);
|
||
let mut next = 0u32;
|
||
let mut build = |lru: &mut ByteLru<u32>, 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<u32> = ByteLru::new(2);
|
||
let mut n = 0u32;
|
||
let mut ins = |lru: &mut ByteLru<u32>, 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<u32> = 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::<Avatar>(&s).unwrap(), a);
|
||
}
|
||
}
|
||
|
||
/// A valid PNG of the given size, as raw bytes (test helper).
|
||
fn make_png(w: u32, h: u32) -> Vec<u8> {
|
||
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
|
||
}
|
||
}
|