Files
peerspeak/src/files.rs
T
molluskandClaude Fable 5 554b613466
CI / check (push) Successful in 2m33s
chat: attachment cache, download, and transfer hardening (Phase 3)
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn
into unbounded memory, bandwidth, decoder, or task pressure (S15 closed;
S14's filename half closed).

Cache and image cost (3A): AttachmentCache now carries encoded- and
decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with
per-entry weights, replacement accounting, and oldest-first eviction; an
individually over-budget fetch services any pending Save/Play from the
bytes in hand and is exposed as Evicted instead of retained.
validate_image_bytes prechecks header dimensions (per-side AND a new
14 MP total-pixel limit) before any decode; the renderer only ever
receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts
against the decoded budget — originals stay encoded-only for Save.
sanitize_filename strips the bidi/zero-width spoofing set (RTL-override
extension spoof).

Download policy and state (3B): images auto-fetch only when roster-
authored AND declared ≤4 MiB, gated by a new deterministic
AutoFetchBudget (per-author and session request+byte token buckets,
check-then-take, bounded author map) alongside the existing dedup and
four-permit bound. Attachment state is now explicit — absence/Loading/
Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so
skipped or evicted images render a "Load image" button instead of an
indefinite "loading…", and repeated clicks can never spawn duplicate
fetch tasks.

Exact transfers and serve store (3C): fetch_blob requires the received
length to equal the declared size (short = local error, overlong =
bounded-read reject, empty keeps meaning "sender no longer has it");
the file picker's unbounded read is replaced by a metadata-prechecked
cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command
queue, and serve store; served_files is a count- and byte-budgeted FIFO
ServeStore (16 entries / 128 MiB).

37 new tests (568 lib total) including a real two-endpoint loopback
exercising exact/short/overlong/unknown-id transfers. Plan checkboxes
ticked and constant deviations decision-logged. Tests-green-only: the
plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:09:30 -04:00

706 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::<Vec<_>>().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<image::DynamicImage> {
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<u8>,
}
/// 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<ImagePreview> {
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: std::io::Read>(r: R) -> std::io::Result<Option<Vec<u8>>> {
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<Option<Vec<u8>>> {
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<AttachmentId, std::sync::Arc<Vec<u8>>>,
/// Present ids in insertion order; the front is the eviction candidate.
order: std::collections::VecDeque<AttachmentId>,
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<Vec<u8>>) -> 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<std::sync::Arc<Vec<u8>>> {
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<AttachmentId> {
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<u8> {
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);
}
}