Files
Marcos Paulo 5d4a2c8fe1
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: all files in the Explorer, publish and share links, image annotation core, last-edit line
- 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>
2026-10-20 14:38:47 -03:00

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);
}