//! Chat file attachments: the compact descriptor that rides a gossip chat //! message, plus the pure validation/sanitization seams for the file-transfer //! plane. //! //! Attachment **bytes do not travel over gossip** — gossip is a small-frame //! broadcast plane (see `avatar` for why image bytes there are hard-capped to //! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor* //! (name, size, kind, id); the sender serves the actual bytes over the dedicated //! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients //! fetch them point-to-point. Everything in this module is dependency-light and //! pure so it can be unit-tested away from the network and the GUI. use serde::{Deserialize, Serialize}; /// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can /// make us hold (when fetching) or serve, and the time a transfer can take. /// 25 MiB comfortably covers phone photos and ordinary documents. pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; /// Max decoded pixels per side for an inline image preview. Defends against a /// decode-bomb (a small file that expands to an enormous bitmap), independent of /// the byte cap. Applied via `image::Limits` when validating/decoding. pub const MAX_IMAGE_PX: u32 = 4096; /// Max total decoded pixels, applied on top of the per-side [`MAX_IMAGE_PX`] /// limit. The per-side cap alone still admits a 4096×4096 ≈ 16.8 MP bitmap /// (~64 MiB transient RGBA); this bounds the worst-case decode allocation while /// still clearing common 12 MP phone photos (4032×3024 ≈ 12.2 MP). pub const MAX_IMAGE_TOTAL_PIXELS: u64 = 14_000_000; /// Max pixels per side of the downscaled inline preview handed to the renderer. /// Original bytes are kept only for Save; the chat column never needs more than /// this (it displays at ~260 px, and the lightbox at window size). pub const IMAGE_PREVIEW_MAX_SIDE: u32 = 1600; /// Largest declared size an image attachment may auto-fetch at. Anything larger /// (or any skipped/evicted image) renders a "Load image" button instead; a /// manual click may use the full [`MAX_ATTACHMENT_BYTES`] cap. pub const MAX_AUTO_IMAGE_BYTES: u64 = 4 * 1024 * 1024; /// Longest filename we keep and display. Keeps the gossip descriptor compact and /// the UI tidy; the real bytes are unaffected. pub const MAX_FILENAME_LEN: usize = 96; /// A 32-byte opaque id identifying one attachment for the fetch request. Minted /// randomly per attachment by the sender (see core); the transfer itself is /// authenticated + encrypted + room-member gated, so the id only needs to be a /// hard-to-guess handle into the sender's serve store, not a content hash. pub type AttachmentId = [u8; 32]; /// How the receiver should present an attachment. A *hint* derived from the /// sender's content sniff — never trusted for a safety decision. The receiver /// re-validates image bytes itself before decoding, and falls back to a file /// chip if an "Image" doesn't actually decode. #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] pub enum AttachmentKind { Image, File, } /// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it /// holds no file bytes, only what the UI needs to render a placeholder/chip and /// what a fetch needs to pull the bytes. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct ChatAttachment { /// Sanitized display filename (already path-stripped — see /// [`sanitize_filename`]). Never used as a filesystem path on receipt without /// the user choosing a save location. pub name: String, /// Byte length of the file. Bounds the fetch read; must be /// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]). pub size: u64, /// Presentation hint (image vs. generic file). pub kind: AttachmentKind, /// Opaque handle the receiver writes on the file plane to request the bytes. pub id: AttachmentId, } /// Sanitize an arbitrary (possibly hostile) filename for display and as a /// save-dialog default. Strips any directory component (both `/` and `\`), /// removes control characters, collapses whitespace, trims, caps the length /// while trying to preserve a short extension, and rejects the `.`/`..` traps. /// Always returns a non-empty, path-component-free name (falls back to `file`). pub fn sanitize_filename(raw: &str) -> String { // Take only the final *non-empty* path component, defeating // `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes // (`a/b/c/` → `c`). let base = raw .rsplit(['/', '\\']) .find(|s| !s.trim().is_empty()) .unwrap_or("") .trim(); // Drop control chars and the same bidi/zero-width spoofing format chars // stripped from display names (a U+202E override can visually reverse an // extension, e.g. "photo\u{202E}gnp.exe" renders as "photoexe.png"). // Ordinary non-ASCII filenames pass through untouched. let cleaned: String = base .chars() .filter(|c| !c.is_control() && !crate::sanitize::is_spoofing_format_char(*c)) .collect(); let collapsed = cleaned.split_whitespace().collect::>().join(" "); let collapsed = collapsed.trim_matches('.').trim(); if collapsed.is_empty() { return "file".to_string(); } if collapsed.chars().count() <= MAX_FILENAME_LEN { return collapsed.to_string(); } // Too long: keep the extension (if short + sane) and truncate the stem. if let Some((stem, ext)) = collapsed.rsplit_once('.') && !ext.is_empty() && ext.chars().count() <= 8 && ext.chars().all(|c| c.is_ascii_alphanumeric()) { let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1); let truncated: String = stem.chars().take(keep).collect(); return format!("{truncated}.{ext}"); } collapsed.chars().take(MAX_FILENAME_LEN).collect() } /// Whether a declared/observed size is within the transfer cap and non-zero. /// Used both when sending (reject before serving) and when fetching (reject a /// descriptor before opening a stream). pub fn size_within_cap(size: u64) -> bool { size > 0 && size <= MAX_ATTACHMENT_BYTES } /// Sniff the leading bytes for a known image container, to set the attachment /// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a /// presentation hint only — actual inline rendering still depends on the bytes /// decoding (we only build image features for PNG/JPEG), with a file-chip /// fallback otherwise. pub fn is_probably_image(bytes: &[u8]) -> bool { let b = bytes; let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]); let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]); let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a"); let bmp = b.starts_with(b"BM"); let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP"; png || jpeg || gif || bmp || webp } /// Sniff the leading bytes for an audio container supported by the inline clip /// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side /// check confirms that a filename-based player hint actually contains WAV, MP3, /// Ogg Vorbis, or FLAC data before playback is attempted. pub fn is_probably_audio(bytes: &[u8]) -> bool { let flac = bytes.starts_with(b"fLaC"); let ogg = bytes.starts_with(b"OggS"); let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE"; let mp3_id3 = bytes.starts_with(b"ID3"); let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0; flac || ogg || wav || mp3_id3 || mp3_frame } /// Whether a sanitized attachment name has an extension supported by the /// inline audio player. This is only a pre-fetch presentation hint; fetched /// bytes are confirmed with [`is_probably_audio`] before being decoded. pub fn looks_like_audio_name(name: &str) -> bool { let Some((_, extension)) = name.rsplit_once('.') else { return false; }; matches!( extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac" ) } /// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it /// sniffs as an image container, else [`AttachmentKind::File`]. pub fn classify(bytes: &[u8]) -> AttachmentKind { if is_probably_image(bytes) { AttachmentKind::Image } else { AttachmentKind::File } } /// Defensively decode image bytes under strict pixel limits to confirm they're a /// real, sane image before we hand them to the renderer. Returns the decoded /// dimensions on success. Guards against decode-bombs (small file → huge bitmap) /// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our /// `image` feature set; anything else returns `None` and the caller shows a chip. pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> { let img = decode_image_bounded(bytes)?; Some((img.width(), img.height())) } /// Shared bounded decode: header-check the dimensions (per-side AND total-pixel /// limits) BEFORE decoding, then decode under `image::Limits` as defense in /// depth. The precheck reads only the container header, so an over-limit bomb is /// rejected without paying its decode cost. fn decode_image_bounded(bytes: &[u8]) -> Option { let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .ok()?; let (w, h) = reader.into_dimensions().ok()?; if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX { return None; } if u64::from(w) * u64::from(h) > MAX_IMAGE_TOTAL_PIXELS { return None; } let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_IMAGE_PX); limits.max_image_height = Some(MAX_IMAGE_PX); let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .ok()?; reader.limits(limits); let img = reader.decode().ok()?; // Decoded size must match the header the precheck approved. if img.width() != w || img.height() != h { return None; } Some(img) } /// A decoded, display-ready inline preview: RGBA pixels downscaled so neither /// side exceeds [`IMAGE_PREVIEW_MAX_SIDE`]. `rgba.len() == width * height * 4`, /// which is also the preview's decoded-budget weight in the attachment cache. pub struct ImagePreview { pub width: u32, pub height: u32, pub rgba: Vec, } /// Decode image bytes under the same limits as [`validate_image_bytes`] and /// build the downscaled inline preview. The full-resolution bitmap exists only /// transiently here; the renderer is never handed more than /// [`IMAGE_PREVIEW_MAX_SIDE`]² pixels. Returns `None` for anything that fails /// validation (caller falls back to a chip / failure row). pub fn decode_preview(bytes: &[u8]) -> Option { let img = decode_image_bounded(bytes)?; let img = if img.width() > IMAGE_PREVIEW_MAX_SIDE || img.height() > IMAGE_PREVIEW_MAX_SIDE { // `thumbnail` preserves aspect ratio within the bounding box. img.thumbnail(IMAGE_PREVIEW_MAX_SIDE, IMAGE_PREVIEW_MAX_SIDE) } else { img }; let rgba = img.into_rgba8(); let (width, height) = (rgba.width(), rgba.height()); Some(ImagePreview { width, height, rgba: rgba.into_raw(), }) } /// Estimated decoded RGBA cost of a preview, the weight counted against the /// attachment cache's decoded-byte budget (`width * height * 4`). pub fn preview_rgba_cost(width: u32, height: u32) -> usize { (width as usize) .saturating_mul(height as usize) .saturating_mul(4) } /// Read at most [`MAX_ATTACHMENT_BYTES`] bytes from `r`. Returns `Ok(None)` if /// the source holds even one byte more (detected by reading cap + 1), so a huge /// or unbounded source is never fully buffered. Pure over `Read` for tests; the /// picker wraps it via [`read_file_capped`]. pub fn read_capped(r: R) -> std::io::Result>> { use std::io::Read as _; let mut buf = Vec::new(); let mut limited = r.take(MAX_ATTACHMENT_BYTES + 1); limited.read_to_end(&mut buf)?; if buf.len() as u64 > MAX_ATTACHMENT_BYTES { return Ok(None); } Ok(Some(buf)) } /// Read a picked file, bounded by [`MAX_ATTACHMENT_BYTES`]. Checks metadata /// first to reject an obviously-oversized file without opening it, but keeps the /// bounded read regardless — metadata can race (the file can grow after the /// check) or be unavailable through a portal. `Ok(None)` = over the cap. pub fn read_file_capped(path: &std::path::Path) -> std::io::Result>> { if let Ok(meta) = std::fs::metadata(path) && meta.len() > MAX_ATTACHMENT_BYTES { return Ok(None); } read_capped(std::fs::File::open(path)?) } /// Cap on how many blobs the session serve store retains at once (sent chat /// attachments plus the current/next broadcast music tracks). pub const SERVED_FILES_MAX_ENTRIES: usize = 16; /// Byte budget for the serve store. Without it, a sender's own session could /// grow unbounded at up to [`MAX_ATTACHMENT_BYTES`] per send (Phase 3C). pub const SERVED_FILES_MAX_BYTES: usize = 128 * 1024 * 1024; /// Count- and byte-budgeted FIFO store of blobs we serve to room members over /// the file plane. Evicting an id makes a later request for it read as an empty /// body — the existing "sender no longer has the file" response — never stale /// or aliased bytes. Pure (no locks/IO) so budgets are unit-testable; the /// transport wraps it in its own mutex. #[derive(Debug, Default)] pub struct ServeStore { entries: std::collections::HashMap>>, /// Present ids in insertion order; the front is the eviction candidate. order: std::collections::VecDeque, total_bytes: usize, } impl ServeStore { /// Insert or replace a blob, evicting oldest entries until the count and /// byte budgets fit. Replacement keeps the id's age and subtracts the old /// bytes before the new ones are counted. Returns `false` for a blob that /// alone exceeds the byte budget (not stored; an existing entry under the /// id is dropped rather than left stale). pub fn insert(&mut self, id: AttachmentId, bytes: std::sync::Arc>) -> bool { if let Some(old) = self.entries.get(&id) { self.total_bytes -= old.len(); } if bytes.len() > SERVED_FILES_MAX_BYTES { if self.entries.remove(&id).is_some() { self.order.retain(|k| k != &id); } return false; } let replacing = self.entries.contains_key(&id); loop { let count_full = !replacing && self.entries.len() >= SERVED_FILES_MAX_ENTRIES; let bytes_full = self.total_bytes + bytes.len() > SERVED_FILES_MAX_BYTES; if !count_full && !bytes_full { break; } let Some(victim) = self.order.iter().find(|k| **k != id).copied() else { break; }; self.remove(&victim); } if !replacing { self.order.push_back(id); } self.total_bytes += bytes.len(); self.entries.insert(id, bytes); true } pub fn get(&self, id: &AttachmentId) -> Option>> { self.entries.get(id).cloned() } pub fn remove(&mut self, id: &AttachmentId) { if let Some(old) = self.entries.remove(id) { self.total_bytes -= old.len(); self.order.retain(|k| k != id); } } pub fn clear(&mut self) { self.entries.clear(); self.order.clear(); self.total_bytes = 0; } #[cfg(test)] fn len(&self) -> usize { self.entries.len() } } /// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32 /// bytes). Anything else is rejected so a peer can't send a malformed/oversized /// request frame. Pure half of the serve handler. pub fn parse_request(bytes: &[u8]) -> Option { if bytes.len() != 32 { return None; } let mut id = [0u8; 32]; id.copy_from_slice(bytes); Some(id) } /// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip. pub fn human_size(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = 1024 * KB; if bytes >= MB { format!("{:.1} MB", bytes as f64 / MB as f64) } else if bytes >= KB { format!("{:.0} KB", bytes as f64 / KB as f64) } else { format!("{bytes} B") } } #[cfg(test)] mod tests { use super::*; #[test] fn sanitize_strips_directory_traversal() { assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png"); assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc"); assert_eq!(sanitize_filename("a/b/c/"), "c"); } #[test] fn sanitize_rejects_dot_traps_and_empty() { assert_eq!(sanitize_filename(""), "file"); assert_eq!(sanitize_filename("."), "file"); assert_eq!(sanitize_filename(".."), "file"); assert_eq!(sanitize_filename(" "), "file"); assert_eq!(sanitize_filename("/"), "file"); } #[test] fn sanitize_removes_control_chars_and_collapses_ws() { // Control chars (incl. tab/newline) are stripped entirely. assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png"); assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt"); // Real spaces are collapsed but preserved. assert_eq!(sanitize_filename("my photo .png"), "my photo .png"); } #[test] fn sanitize_caps_length_preserving_extension() { let long_stem = "x".repeat(200); let name = format!("{long_stem}.png"); let out = sanitize_filename(&name); assert!( out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count() ); assert!(out.ends_with(".png"), "extension preserved: {out}"); } #[test] fn size_cap_bounds() { assert!(!size_within_cap(0)); assert!(size_within_cap(1)); assert!(size_within_cap(MAX_ATTACHMENT_BYTES)); assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1)); } #[test] fn image_sniffing_recognizes_containers() { assert!(is_probably_image(&[ 0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0 ])); assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0])); assert!(is_probably_image(b"GIF89a....")); let mut webp = b"RIFF".to_vec(); webp.extend_from_slice(&[0, 0, 0, 0]); webp.extend_from_slice(b"WEBP"); assert!(is_probably_image(&webp)); assert!(!is_probably_image(b"%PDF-1.7")); assert!(!is_probably_image(b"")); } #[test] fn audio_sniffing_recognizes_supported_containers() { assert!(is_probably_audio(b"fLaC\0\0\0\x22")); assert!(is_probably_audio(b"OggS\0\x02")); let mut wav = b"RIFF".to_vec(); wav.extend_from_slice(&[0, 0, 0, 0]); wav.extend_from_slice(b"WAVE"); assert!(is_probably_audio(&wav)); assert!(is_probably_audio(b"ID3\x04\0\0")); assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64])); } #[test] fn audio_sniffing_disambiguates_wav_from_webp() { let mut wav = b"RIFF".to_vec(); wav.extend_from_slice(&[0, 0, 0, 0]); wav.extend_from_slice(b"WAVE"); assert!(is_probably_audio(&wav)); assert!(!is_probably_image(&wav)); let mut webp = b"RIFF".to_vec(); webp.extend_from_slice(&[0, 0, 0, 0]); webp.extend_from_slice(b"WEBP"); assert!(is_probably_image(&webp)); assert!(!is_probably_audio(&webp)); } #[test] fn audio_sniffing_rejects_non_audio() { assert!(!is_probably_audio(b"%PDF-1.7")); assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G'])); assert!(!is_probably_audio(&[])); assert!(!is_probably_audio(&[0xFF])); } #[test] fn audio_name_detection_is_case_insensitive() { for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] { assert!(looks_like_audio_name(name), "{name}"); } assert!(looks_like_audio_name("VOICE.MP3")); assert!(looks_like_audio_name("mix.FlAc")); assert!(!looks_like_audio_name("recording")); assert!(!looks_like_audio_name("notes.pdf")); assert!(!looks_like_audio_name("photo.webp")); } #[test] fn classify_maps_sniff_to_kind() { assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image); assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File); assert_eq!(classify(b"plain text"), AttachmentKind::File); } #[test] fn parse_request_requires_exact_32_bytes() { assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32])); assert_eq!(parse_request(&[7u8; 31]), None); assert_eq!(parse_request(&[7u8; 33]), None); assert_eq!(parse_request(&[]), None); } #[test] fn validate_image_rejects_garbage() { assert_eq!(validate_image_bytes(b"not an image"), None); assert_eq!(validate_image_bytes(&[]), None); } #[test] fn validate_image_accepts_a_real_png() { // Encode a tiny PNG in-memory, then validate it. let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30])); let mut buf = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(img) .write_to(&mut buf, image::ImageFormat::Png) .unwrap(); assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3))); } #[test] fn sanitize_strips_bidi_and_zero_width_spoofing_chars() { // U+202E would visually reverse the tail, disguising the extension. assert_eq!(sanitize_filename("photo\u{202E}gnp.exe"), "photognp.exe"); assert_eq!(sanitize_filename("a\u{200B}b\u{FEFF}.txt"), "ab.txt"); // Ordinary Unicode filenames pass through. assert_eq!(sanitize_filename("família_fotos.png"), "família_fotos.png"); assert_eq!(sanitize_filename("日本語.pdf"), "日本語.pdf"); } /// Encode a solid PNG of the given dimensions for limit tests. fn png_bytes(w: u32, h: u32) -> Vec { let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30])); let mut buf = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(img) .write_to(&mut buf, image::ImageFormat::Png) .unwrap(); buf.into_inner() } #[test] fn validate_image_rejects_excessive_total_pixels() { // Both sides within MAX_IMAGE_PX, but 4096 * 4096 > MAX_IMAGE_TOTAL_PIXELS. assert!(u64::from(MAX_IMAGE_PX) * u64::from(MAX_IMAGE_PX) > MAX_IMAGE_TOTAL_PIXELS); assert_eq!(validate_image_bytes(&png_bytes(4096, 4096)), None); // A 12 MP phone-photo shape passes both limits. assert_eq!( validate_image_bytes(&png_bytes(4032, 3024)), Some((4032, 3024)) ); } #[test] fn preview_downscales_to_max_side_preserving_aspect() { // Wide: 3200x400 → 1600x200. let p = decode_preview(&png_bytes(3200, 400)).unwrap(); assert_eq!((p.width, p.height), (1600, 200)); assert_eq!(p.rgba.len(), preview_rgba_cost(1600, 200)); // Tall: 400x3200 → 200x1600. let p = decode_preview(&png_bytes(400, 3200)).unwrap(); assert_eq!((p.width, p.height), (200, 1600)); // Square over the side cap: 2000x2000 → 1600x1600. let p = decode_preview(&png_bytes(2000, 2000)).unwrap(); assert_eq!((p.width, p.height), (1600, 1600)); // At/under the cap is untouched. let p = decode_preview(&png_bytes(1600, 900)).unwrap(); assert_eq!((p.width, p.height), (1600, 900)); let p = decode_preview(&png_bytes(4, 3)).unwrap(); assert_eq!((p.width, p.height), (4, 3)); assert_eq!(p.rgba.len(), preview_rgba_cost(4, 3)); } #[test] fn preview_rejects_what_validation_rejects() { assert!(decode_preview(b"not an image").is_none()); assert!(decode_preview(&png_bytes(4096, 4096)).is_none()); } #[test] fn read_capped_stops_at_cap_plus_one() { // Under the cap: full read. let small = vec![7u8; 1024]; assert_eq!( read_capped(std::io::Cursor::new(&small)) .unwrap() .as_deref(), Some(&small[..]) ); // Exactly at the cap: accepted. `repeat` is endless, `take` proves the // reader is bounded rather than draining the source. let at_cap = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES); let got = read_capped(at_cap).unwrap().unwrap(); assert_eq!(got.len() as u64, MAX_ATTACHMENT_BYTES); // One byte over: rejected, and only cap + 1 bytes were ever buffered // (an unbounded source returns instead of allocating forever). let over = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES + 1); assert_eq!(read_capped(over).unwrap(), None); let endless = std::io::repeat(1); assert_eq!(read_capped(endless).unwrap(), None); } #[test] fn human_size_units() { assert_eq!(human_size(40), "40 B"); assert_eq!(human_size(2048), "2 KB"); assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB"); } #[test] fn serve_store_count_and_byte_eviction_fifo() { use std::sync::Arc; let mut s = ServeStore::default(); let blob = |n: u8, len: usize| ([n; 32], Arc::new(vec![n; len])); // Count cap: entry 0 is evicted when the 17th arrives. for n in 0..=SERVED_FILES_MAX_ENTRIES as u8 { let (id, b) = blob(n, 8); assert!(s.insert(id, b)); } assert_eq!(s.len(), SERVED_FILES_MAX_ENTRIES); assert!(s.get(&[0u8; 32]).is_none(), "oldest evicted by count"); assert!(s.get(&[1u8; 32]).is_some()); // Byte budget: two ~half-budget blobs evict everything older. let half = SERVED_FILES_MAX_BYTES / 2; let (a, ab) = blob(100, half); let (b, bb) = blob(101, half); assert!(s.insert(a, ab)); assert!(s.insert(b, bb)); assert!(s.get(&a).is_some()); assert!(s.get(&b).is_some()); assert!(s.get(&[1u8; 32]).is_none(), "evicted for byte budget"); // A third half-budget blob evicts `a` (oldest), keeps `b`. let (c, cb) = blob(102, half); assert!(s.insert(c, cb)); assert!(s.get(&a).is_none()); assert!(s.get(&b).is_some()); assert!(s.get(&c).is_some()); } #[test] fn serve_store_replacement_accounting_and_remove_clear() { use std::sync::Arc; let mut s = ServeStore::default(); let id = [9u8; 32]; assert!(s.insert(id, Arc::new(vec![1; SERVED_FILES_MAX_BYTES - 10]))); // Replacing the near-budget blob must subtract its old bytes first — // otherwise this same-id replacement would evict itself. assert!(s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES - 5]))); assert_eq!(s.get(&id).unwrap()[0], 2); assert_eq!(s.len(), 1); s.remove(&id); assert!(s.get(&id).is_none()); // Removed bytes were released: the budget admits a full-size blob again. assert!(s.insert(id, Arc::new(vec![3; SERVED_FILES_MAX_BYTES]))); s.clear(); assert_eq!(s.len(), 0); assert!(s.insert(id, Arc::new(vec![4; SERVED_FILES_MAX_BYTES]))); } #[test] fn serve_store_rejects_individually_overweight_blob() { use std::sync::Arc; let mut s = ServeStore::default(); let id = [7u8; 32]; assert!(s.insert(id, Arc::new(vec![1; 8]))); assert!(!s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES + 1]))); // The stale small blob is gone too — a fetch reads "no longer has it", // never old bytes under a replaced id. assert!(s.get(&id).is_none()); assert_eq!(s.len(), 0); } #[test] fn attachment_descriptor_round_trips_json() { let a = ChatAttachment { name: "photo.png".to_string(), size: 12345, kind: AttachmentKind::Image, id: [9u8; 32], }; let bytes = serde_json::to_vec(&a).unwrap(); let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap(); assert_eq!(a, back); } }