Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
10 kB · 261 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262//! Making a player-supplied PNG safe to hand back out, and checking the name//! that goes with it.//!//! These bytes are decoded by other players' browsers during a match, so the//! re-encode is a security boundary rather than a formatting step: the file//! that leaves here was written by this code from a pixel buffer, and shares//! nothing with the file that arrived but the pixels. Anything a decoder//! could be talked into by a crafted ancillary chunk is gone, and so is the//! EXIF-shaped metadata an imported photo carries. arena's//! container/init/30-assets.sh states the other half of the same rule://! sanitising is headquarters' job at upload time, and the container only//! checks the digest.
use std::io::Cursor;
/// The camo size a match draws at, and the only size accepted here.pub const WIDTH: u32 = 84;pub const HEIGHT: u32 = 72;
/// `blue.lance.camo`'s `name` is capped at 640 bytes and 64 graphemes. Only/// the byte cap is checked here: counting graphemes needs a segmentation/// crate this service does not carry, and the PDS enforces the schema anyway./// Being laxer than the schema is the safe direction — it never rejects a/// name the record would have accepted.const MAX_NAME_BYTES: usize = 640;
/// A decoder allowance far above what an 84x72 image needs, so a crafted/// header cannot ask for an allocation before the size check runs.const DECODE_BYTE_LIMIT: usize = 4 * 1024 * 1024;
/// Every failure is a sentence for the player. Nothing here is worth/// distinguishing programmatically, and a message the browser can show/// beats a code the browser has to translate.pub type Rejected = &'static str;
/// Trims, then holds the name to what the record can carry.pub fn clean_name(raw: &str) -> Result<String, Rejected> { let name = raw.trim(); if name.is_empty() { return Err("Give the camo a name."); } if name.len() > MAX_NAME_BYTES { return Err("That name is too long."); } // A display name is one line. Control characters in one are either a // paste accident or an attempt to make a list of camo read wrongly. if name.chars().any(|c| c.is_control()) { return Err("That name contains characters that are not allowed."); } Ok(name.to_owned())}
/// Decodes `bytes` as an 84x72 PNG and writes a fresh one from the pixels.////// The output is always 8-bit RGBA. Normalising rather than preserving the/// input's colour type is deliberate: one output shape is one thing to reason/// about, and at this size the file is a few kilobytes either way.pub fn reencode(bytes: &[u8]) -> Result<Vec<u8>, Rejected> { let mut decoder = png::Decoder::new(Cursor::new(bytes)); decoder.set_limits(png::Limits { bytes: DECODE_BYTE_LIMIT, }); // Palettes, 16-bit samples and bit-packed grayscale all collapse to 8-bit // channels here, so the match below only has four cases to cover. decoder.set_transformations( png::Transformations::normalize_to_color8() | png::Transformations::ALPHA, );
let mut reader = decoder .read_info() .map_err(|_| "That file could not be read as a PNG.")?; let info = reader.info(); if info.width != WIDTH || info.height != HEIGHT { return Err("A camo must be exactly 84x72 pixels."); }
let mut buffer = vec![0u8; reader.output_buffer_size().unwrap_or(0)]; let frame = reader .next_frame(&mut buffer) .map_err(|_| "That PNG could not be decoded.")?; // The header said 84x72; the frame is what was actually decoded, and an // APNG's first frame can be smaller than the canvas. if frame.width != WIDTH || frame.height != HEIGHT { return Err("A camo must be exactly 84x72 pixels."); } if frame.bit_depth != png::BitDepth::Eight { return Err("That PNG could not be decoded."); }
let rgba = crate::png_util::to_rgba( &buffer[..frame.buffer_size()], frame.color_type, WIDTH, HEIGHT, ) .ok_or("That PNG is in a colour format this does not read.")?;
let mut out = Vec::new(); let mut encoder = png::Encoder::new(&mut out, WIDTH, HEIGHT); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder .write_header() .map_err(|_| "The camo could not be re-encoded.")?; writer .write_image_data(&rgba) .map_err(|_| "The camo could not be re-encoded.")?; drop(writer); Ok(out)}
#[cfg(test)]mod tests { use super::*;
/// A PNG of the given size in the given colour type, so the tests feed /// `reencode` real files rather than fixtures checked into the tree. fn png_of(width: u32, height: u32, color: png::ColorType, depth: png::BitDepth) -> Vec<u8> { let channels = match color { png::ColorType::Grayscale | png::ColorType::Indexed => 1, png::ColorType::GrayscaleAlpha => 2, png::ColorType::Rgb => 3, png::ColorType::Rgba => 4, }; let bytes_per_sample = if depth == png::BitDepth::Sixteen { 2 } else { 1 }; let mut out = Vec::new(); let mut encoder = png::Encoder::new(&mut out, width, height); encoder.set_color(color); encoder.set_depth(depth); if color == png::ColorType::Indexed { encoder.set_palette(vec![10, 20, 30, 40, 50, 60]); } let mut writer = encoder.write_header().unwrap(); let data = vec![0x40u8; (width * height) as usize * channels * bytes_per_sample]; writer.write_image_data(&data).unwrap(); drop(writer); out }
fn camo(color: png::ColorType, depth: png::BitDepth) -> Vec<u8> { png_of(WIDTH, HEIGHT, color, depth) }
/// The output must be a PNG of exactly the camo size, whatever went in. fn assert_is_camo(bytes: &[u8]) { let decoder = png::Decoder::new(Cursor::new(bytes)); let reader = decoder.read_info().expect("output is a PNG"); let info = reader.info(); assert_eq!((info.width, info.height), (WIDTH, HEIGHT)); assert_eq!(info.color_type, png::ColorType::Rgba); assert_eq!(info.bit_depth, png::BitDepth::Eight); }
#[test] fn every_colour_type_comes_out_as_rgba() { for (color, depth) in [ (png::ColorType::Rgba, png::BitDepth::Eight), (png::ColorType::Rgb, png::BitDepth::Eight), (png::ColorType::Grayscale, png::BitDepth::Eight), (png::ColorType::GrayscaleAlpha, png::BitDepth::Eight), (png::ColorType::Indexed, png::BitDepth::Eight), (png::ColorType::Rgba, png::BitDepth::Sixteen), (png::ColorType::Rgb, png::BitDepth::Sixteen), ] { let out = reencode(&camo(color, depth)) .unwrap_or_else(|e| panic!("{color:?}/{depth:?} rejected: {e}")); assert_is_camo(&out); } }
#[test] fn wrong_size_is_refused() { assert!(reencode(&png_of(84, 71, png::ColorType::Rgba, png::BitDepth::Eight)).is_err()); assert!( reencode(&png_of( 168, 144, png::ColorType::Rgba, png::BitDepth::Eight )) .is_err() ); assert!(reencode(&png_of(1, 1, png::ColorType::Rgba, png::BitDepth::Eight)).is_err()); }
#[test] fn non_png_is_refused() { assert!(reencode(b"").is_err()); assert!(reencode(b"GIF89a").is_err()); // A valid signature and nothing behind it. assert!(reencode(b"\x89PNG\r\n\x1a\n").is_err()); }
/// The whole point of the re-encode: chunks the input carried do not /// survive into the output. #[test] fn metadata_is_dropped() { let mut out = Vec::new(); let mut encoder = png::Encoder::new(&mut out, WIDTH, HEIGHT); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); encoder .add_text_chunk("Comment".into(), "smuggled-payload".into()) .unwrap(); let mut writer = encoder.write_header().unwrap(); writer .write_image_data(&vec![0x40u8; (WIDTH * HEIGHT) as usize * 4]) .unwrap(); drop(writer);
assert!( out.windows(16).any(|w| w == b"smuggled-payload"), "the fixture should contain the text chunk" ); let clean = reencode(&out).unwrap(); assert!( !clean.windows(16).any(|w| w == b"smuggled-payload"), "the re-encode must not carry the text chunk through" ); assert_is_camo(&clean); }
/// Pixels survive: a re-encode that silently blanked the image would pass /// every check above. #[test] fn pixels_survive() { let mut source = Vec::new(); let mut encoder = png::Encoder::new(&mut source, WIDTH, HEIGHT); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().unwrap(); let mut data = vec![0u8; (WIDTH * HEIGHT) as usize * 4]; data[0..4].copy_from_slice(&[1, 2, 3, 4]); data[4..8].copy_from_slice(&[250, 251, 252, 253]); writer.write_image_data(&data).unwrap(); drop(writer);
let clean = reencode(&source).unwrap(); let mut reader = png::Decoder::new(Cursor::new(&clean)).read_info().unwrap(); let mut buffer = vec![0u8; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut buffer).unwrap(); assert_eq!(&buffer[0..8], &[1, 2, 3, 4, 250, 251, 252, 253]); }
#[test] fn names_are_trimmed_and_bounded() { assert_eq!(clean_name(" Highland ").unwrap(), "Highland"); assert!(clean_name("").is_err()); assert!(clean_name(" ").is_err()); assert!(clean_name("\t\n").is_err()); assert!(clean_name("Two\nlines").is_err()); assert!(clean_name(&"a".repeat(MAX_NAME_BYTES)).is_ok()); assert!(clean_name(&"a".repeat(MAX_NAME_BYTES + 1)).is_err()); // The cap is bytes, so a multi-byte name hits it sooner. assert!(clean_name(&"é".repeat(MAX_NAME_BYTES / 2 + 1)).is_err()); }}