//! 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 { 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, 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 { 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 { 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()); } }