5d4a2c8fe1
- Explorer: every non-hidden file is listed with its own icon and tab kind (image, video, audio, pdf, code, text, other); settings show_all_types, hidden_extensions and show_annotation_files; kinds without a viewer open a placeholder card, never the text editor - crates/annotate: non-destructive image annotations (strokes, lines, rectangles, ellipses, text) with strict sidecar JSON validation, hit testing, zoom/pan transforms and undo - engine: file.info, file.hash, file.neighbours, image.annotations.load and save (a corrupt sidecar is kept as .bad, the image is never modified), header-only image sizing for PNG, JPEG, GIF, WebP, BMP and SVG - sync server: publish notes or folders as static HTML (raw HTML escaped, strict CSP, publish: false honoured, no attachments) and expiring revocable share links, with per-route rate limits and audit rows - ui-egui: "Last edit - name, time ago" in the status bar from server.file_meta - specs and designs: file viewers spec, 20 PaperCraft exports rated 8.2 of 10 (design/files/RATING.md), annotate and viewer briefs Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
141 lines
6.0 KiB
Rust
141 lines
6.0 KiB
Rust
//! 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::<Vec<_>>().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::<Vec<_>>().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);
|
|
}
|