//! What a flare is allowed to carry. //! //! A flare is feedback a player files from the match screen. It becomes an //! `app.userinput.discussion` in the player's own repository, pointing at the //! lance.blue board's space on userinput.app, so every limit here is the //! published lexicon's; sending something the lexicon refuses would fail at //! the PDS with a worse message. use std::io::Cursor; pub type Rejected = &'static str; /// The lexicon's `title`: 600 bytes, 300 graphemes. Chars over-count /// graphemes, so holding chars to the grapheme limit stays inside it. const TITLE_MAX_BYTES: usize = 600; const TITLE_MAX_CHARS: usize = 300; /// The lexicon's `body` is 20,000 bytes and 10,000 graphemes. The cap here /// leaves room for the match footer this service appends after validation. const BODY_MAX_BYTES: usize = 19_000; const BODY_MAX_CHARS: usize = 9_500; /// The lexicon's cap on an attached image blob. pub const IMAGE_MAX_BYTES: usize = 1_000_000; /// The lexicon's `tags`: at most 8, each at most 64 characters. Which values /// are allowed is not a limit the lexicon states — they are "drawn from the /// space's tag list", so only the board can answer that, and `create_flare` /// asks it while it is fetching the space record anyway. const TAGS_MAX: usize = 8; const TAG_MAX_CHARS: usize = 64; /// A screenshot is a whole match screen, not an 84x72 camo. const DECODE_BYTE_LIMIT: usize = 64 * 1024 * 1024; const MAX_DIMENSION: u32 = 4096; pub fn clean_title(raw: &str) -> Result { let title = raw.trim(); if title.is_empty() { return Err("Give the flare a title."); } if title.len() > TITLE_MAX_BYTES || title.chars().count() > TITLE_MAX_CHARS { return Err("That title is too long."); } if title.chars().any(|c| c.is_control()) { return Err("That title contains characters that are not allowed."); } Ok(title.to_owned()) } /// The player's own words. Empty is fine — a screenshot can speak for /// itself — and line breaks are what a description is made of, so control /// characters other than `\n`, `\r` and `\t` are the only ones refused. pub fn clean_body(raw: &str) -> Result { let body = raw.trim(); if body.len() > BODY_MAX_BYTES || body.chars().count() > BODY_MAX_CHARS { return Err("That description is too long."); } if body .chars() .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) { return Err("That description contains characters that are not allowed."); } Ok(body.to_owned()) } /// What the player ticked, held to the lexicon's shape. /// /// Duplicates are dropped rather than refused: two boxes cannot be ticked /// twice in either form, so a repeat means a hand-made request, and one tag /// listed once is what it was asking for either way. Order is the form's. pub fn clean_tags(raw: &[String]) -> Result, Rejected> { let mut tags: Vec = Vec::with_capacity(raw.len().min(TAGS_MAX)); for tag in raw { let tag = tag.trim(); if tag.is_empty() { continue; } if tag.chars().count() > TAG_MAX_CHARS { return Err("That tag is too long."); } if tag.chars().any(|c| c.is_control()) { return Err("That tag contains characters that are not allowed."); } if !tags.iter().any(|kept| kept == tag) { tags.push(tag.to_owned()); } } if tags.len() > TAGS_MAX { return Err("That is more tags than a report can carry."); } Ok(tags) } /// Decodes a screenshot PNG and writes a fresh one from the pixels. /// /// Same rule as a camo upload: the bytes go on to other people's browsers /// via the board, so they are re-encoded rather than passed through, and /// metadata does not survive the trip. Unlike a camo the dimensions are /// whatever the match screen was; only absurd ones are refused. pub fn reencode_screenshot(bytes: &[u8]) -> Result, Rejected> { let mut decoder = png::Decoder::new(Cursor::new(bytes)); decoder.set_limits(png::Limits { bytes: DECODE_BYTE_LIMIT, }); decoder.set_transformations( png::Transformations::normalize_to_color8() | png::Transformations::ALPHA, ); let mut reader = decoder .read_info() .map_err(|_| "That screenshot could not be read as a PNG.")?; let info = reader.info(); let (width, height) = (info.width, info.height); if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION { return Err("That screenshot's dimensions are not usable."); } let mut buffer = vec![0u8; reader.output_buffer_size().unwrap_or(0)]; let frame = reader .next_frame(&mut buffer) .map_err(|_| "That screenshot could not be decoded.")?; if frame.width != width || frame.height != height || frame.bit_depth != png::BitDepth::Eight { return Err("That screenshot could not be decoded."); } let rgba = crate::png_util::to_rgba( &buffer[..frame.buffer_size()], frame.color_type, width, height, ) .ok_or("That screenshot 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 screenshot could not be re-encoded.")?; writer .write_image_data(&rgba) .map_err(|_| "The screenshot could not be re-encoded.")?; drop(writer); // The lexicon's cap is on the stored blob. The match screen downscales // before sending, so arriving here means that step failed or was skipped. if out.len() > IMAGE_MAX_BYTES { return Err("That screenshot is too large. Try a smaller one."); } Ok(out) } #[cfg(test)] mod tests { use super::*; use rand::{RngCore, SeedableRng}; fn png_of(width: u32, height: u32) -> Vec { 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().unwrap(); writer .write_image_data(&vec![0u8; (width * height * 4) as usize]) .unwrap(); drop(writer); out } #[test] fn titles_are_held_to_the_lexicon() { assert_eq!( clean_title(" Stuck on deployment ").unwrap(), "Stuck on deployment" ); assert!(clean_title(" ").is_err()); assert!(clean_title(&"a".repeat(TITLE_MAX_CHARS)).is_ok()); assert!(clean_title(&"a".repeat(TITLE_MAX_CHARS + 1)).is_err()); assert!(clean_title(&"é".repeat(TITLE_MAX_BYTES / 2 + 1)).is_err()); assert!(clean_title("a\u{0000}b").is_err()); } #[test] fn bodies_keep_their_line_breaks() { assert_eq!(clean_body("one\ntwo").unwrap(), "one\ntwo"); assert_eq!(clean_body("").unwrap(), ""); assert!(clean_body("a\u{0007}b").is_err()); assert!(clean_body(&"a".repeat(BODY_MAX_CHARS + 1)).is_err()); } #[test] fn tags_are_held_to_the_lexicon() { assert_eq!( clean_tags(&["bug".into(), " feature ".into()]).unwrap(), ["bug", "feature"] ); // Two boxes cannot be ticked twice, so a repeat is a hand-made // request asking for the one tag it named. assert_eq!(clean_tags(&["bug".into(), "bug".into()]).unwrap(), ["bug"]); assert_eq!( clean_tags(&["".into(), " ".into()]).unwrap(), Vec::::new() ); assert!(clean_tags(&["a".repeat(TAG_MAX_CHARS)]).is_ok()); assert!(clean_tags(&["a".repeat(TAG_MAX_CHARS + 1)]).is_err()); assert!(clean_tags(&["bu\u{0000}g".into()]).is_err()); let many: Vec = (0..=TAGS_MAX).map(|n| n.to_string()).collect(); assert!(clean_tags(&many).is_err()); } #[test] fn screenshots_reencode_at_their_own_size() { let out = reencode_screenshot(&png_of(320, 200)).unwrap(); let decoder = png::Decoder::new(Cursor::new(&out[..])); let reader = decoder.read_info().unwrap(); assert_eq!((reader.info().width, reader.info().height), (320, 200)); } #[test] fn absurd_screenshots_are_refused() { assert!(reencode_screenshot(&png_of(MAX_DIMENSION + 1, 8)).is_err()); assert!(reencode_screenshot(b"not a png").is_err()); } /// Dimensions alone (`absurd_screenshots_are_refused`) do not catch this: /// a flat-colour image of the same size would compress under the cap and /// pass. Random pixel data does not deflate away, so the re-encoded PNG /// stays close to its raw size and lands over `IMAGE_MAX_BYTES`. #[test] fn oversized_after_reencode_is_refused() { let (width, height) = (700u32, 700u32); let mut pixels = vec![0u8; (width * height * 4) as usize]; rand::rngs::StdRng::seed_from_u64(0xf1a4e).fill_bytes(&mut pixels); 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(); writer.write_image_data(&pixels).unwrap(); drop(writer); assert!( source.len() > IMAGE_MAX_BYTES, "fixture must already be over the cap going in: {}", source.len() ); assert_eq!( reencode_screenshot(&source), Err("That screenshot is too large. Try a smaller one.") ); } }