//! The hostile-case table for `Annotations::from_json`: one case per validation rule. use std::fmt::Write as _; use notescraft_annotate::{Annotations, Error}; /// What a hostile case must produce. #[derive(Debug, Clone, Copy, PartialEq)] enum Kind { Version(u64), TooManyItems, TooManyStrokePoints, TooManyTotalPoints, NonFinite, OutOfRange, BadWidth, BadText, UnknownType, UnknownKey, TooLarge, TooDeep, ImageChanged(u32, u32), BadField, } /// Does the error match the expectation? fn same(error: &Error, want: &Kind) -> bool { match want { Kind::Version(found) => matches!(error, Error::Version(seen) if seen == found), Kind::ImageChanged(w, h) => matches!( error, Error::ImageChanged { stored_w, stored_h, .. } if (*stored_w, *stored_h) == (*w, *h) ), _ => matches!( (error, want), (Error::TooManyItems(_), Kind::TooManyItems) | (Error::TooManyStrokePoints(_), Kind::TooManyStrokePoints) | (Error::TooManyTotalPoints(_), Kind::TooManyTotalPoints) | (Error::NonFinite, Kind::NonFinite) | (Error::OutOfRange { .. }, Kind::OutOfRange) | (Error::BadWidth(_), Kind::BadWidth) | (Error::BadText(_), Kind::BadText) | (Error::UnknownType(_), Kind::UnknownType) | (Error::UnknownKey { .. }, Kind::UnknownKey) | (Error::TooLarge(_), Kind::TooLarge) | (Error::TooDeep(_), Kind::TooDeep) | (Error::BadField, Kind::BadField) ), } } /// A document with the given items verbatim into an image of 1000x800. fn doc(items: &str) -> String { format!("{{\"version\":1,\"image\":{{\"hash\":null,\"width\":1000,\"height\":800}},\"items\":[{items}]}}") } /// One stroke with the given verbatim points and width. fn stroke(points: &str, width: &str) -> String { format!("{{\"type\":\"stroke\",\"tool\":\"pen\",\"color\":{{\"theme\":0}},\"width\":{width},\"points\":[{points}]}}") } /// A comma-joined run of `[x, y]` pairs along a diagonal. fn points_run(count: usize) -> String { let mut string = String::new(); for index in 0..count { if index > 0 { string.push(','); } let (x, y) = (u64::try_from(index % 900).unwrap_or(0), u64::try_from(index % 700).unwrap_or(0)); if write!(string, "[{x},{y}]").is_err() { return string; } } string } /// A valid text item, with a `text` field patched in. fn text_item(text_field: &str) -> String { format!("{{\"type\":\"text\",\"color\":{{\"theme\":0}},\"size\":2.0,\"at\":[0,0],\"text\":{text_field}}}") } #[test] fn validation_refuses_each_hostile_case() { let too_long_text = format!("\"{}\"", "a".repeat(2001)); let nine_mib_junk = " ".repeat(9 * 1024 * 1024); let nine_mib = format!("{{\"version\":1,\"image\":{{\"hash\":null,\"width\":1000,\"height\":800}},\"items\":[],\"junk\":\"{nine_mib_junk}\"}}"); let cases: Vec<(&str, String, Kind)> = vec![ ("version 2", doc("").replace("\"version\":1", "\"version\":2"), Kind::Version(2)), ("missing version", doc("").replace("\"version\":1,", ""), Kind::Version(0)), ("one item over the cap", doc(&(0..5001).map(|_| text_item("\"x\"")).collect::>().join(",")), Kind::TooManyItems), ("stroke over the point cap", doc(&stroke(&points_run(20_001), "2.0")), Kind::TooManyStrokePoints), ("total over the point cap", doc(&(0..11).map(|_| stroke(&points_run(20_000), "2.0")).collect::>().join(",")), Kind::TooManyTotalPoints), ("x as NaN string", doc(&stroke("[\"NaN\",0]", "2.0")), Kind::NonFinite), ("y as infinity string", doc(&stroke("[0,\"Infinity\"]", "2.0")), Kind::NonFinite), ("x over 5w", doc(&stroke("[5001,0]", "2.0")), Kind::OutOfRange), ("y under -4h", doc(&stroke("[0,-3201]", "2.0")), Kind::OutOfRange), ("width under the range", doc(&stroke("[0,0]", "0.4")), Kind::BadWidth), ("size over the range", doc(&text_item("\"x\"").replace("\"size\":2.0", "\"size\":200.1")), Kind::BadWidth), ("text too long", doc(&text_item(&too_long_text)), Kind::BadText), ("text with control char", doc(&text_item("\"a\\u0007b\"")), Kind::BadText), ("unknown type", doc("{\"type\":\"stamp\",\"color\":{\"theme\":0}}"), Kind::UnknownType), ( "unknown key inside stroke", doc("{\"type\":\"stroke\",\"tool\":\"pen\",\"color\":{\"theme\":0},\"width\":2.0,\"alpha\":1,\"points\":[[0,0]]}"), Kind::UnknownKey, ), ("nine mebibytes", nine_mib, Kind::TooLarge), ("depth 40", "[".repeat(40) + &"]".repeat(40), Kind::TooDeep), ("array root", "[1,2,3]".to_string(), Kind::BadField), ("missing items", doc("").replace(",\"items\":[]", ""), Kind::BadField), ( "size mismatch without a hash", "{\"version\":1,\"image\":{\"hash\":null,\"width\":500,\"height\":400},\"items\":[]}".to_string(), Kind::ImageChanged(500, 400), ), ]; for (name, document, want) in cases { match Annotations::from_json(&document, 1000, 800) { Ok(_) => panic!("accepted: {name}"), Err(error) => assert!(same(&error, &want), "{name}: got {error:?}, want {want:?}"), } } } /// A hash vouches for a stored size that differs from the actual image. #[test] fn validation_tolerates_size_mismatch_when_hashed() { let vouched = "{\"version\":1,\"image\":{\"hash\":\"abc\",\"width\":500,\"height\":400},\"items\":[]}"; let notes = Annotations::from_json(vouched, 1000, 800).unwrap(); assert_eq!((notes.image.width, notes.image.height), (500, 400)); assert_eq!(notes.image.hash.as_deref(), Some("abc")); } /// A new-line inside a text label is legal. #[test] fn validation_allows_newline_in_text() { let document = doc(&text_item("\"a\\nb\"")); let notes = Annotations::from_json(&document, 1000, 800).unwrap(); assert_eq!(notes.items.len(), 1); }