CI / check (push) Successful in 2m33s
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>
706 lines
28 KiB
Rust
706 lines
28 KiB
Rust
//! 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);
|
||
}
|
||
}
|