The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
375 lines
15 KiB
Rust
375 lines
15 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;
|
|
|
|
/// 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; turn other whitespace into single spaces later.
|
|
let cleaned: String = base.chars().filter(|c| !c.is_control()).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 mut limits = image::Limits::default();
|
|
limits.max_image_width = Some(MAX_IMAGE_PX);
|
|
limits.max_image_height = Some(MAX_IMAGE_PX);
|
|
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
|
.with_guessed_format()
|
|
.ok()?;
|
|
let mut reader = reader;
|
|
reader.limits(limits);
|
|
let img = reader.decode().ok()?;
|
|
let (w, h) = (img.width(), img.height());
|
|
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
|
return None;
|
|
}
|
|
Some((w, h))
|
|
}
|
|
|
|
/// 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 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 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);
|
|
}
|
|
}
|