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>
299 lines
14 KiB
Rust
299 lines
14 KiB
Rust
//! Integration tests of the viewer commands: `file.info`, `file.hash`,
|
|
//! `file.neighbours`, `image.annotations.load` and `image.annotations.save`.
|
|
|
|
mod common;
|
|
|
|
use std::path::Path;
|
|
|
|
use common::{TempVault, temp_vault};
|
|
use notescraft_engine::{Error, Registry, Session, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
/// A registry with the core commands.
|
|
fn registry() -> Registry<Session> {
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register the core commands");
|
|
registry
|
|
}
|
|
|
|
/// A session on a fresh vault.
|
|
fn session(vault: &TempVault) -> Session {
|
|
Session::open(vault.path()).expect("open the vault")
|
|
}
|
|
|
|
/// The header of a PNG of the given size.
|
|
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
|
|
let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13, b'I', b'H', b'D', b'R'];
|
|
bytes.extend_from_slice(&width.to_be_bytes());
|
|
bytes.extend_from_slice(&height.to_be_bytes());
|
|
bytes
|
|
}
|
|
|
|
/// Writes bytes into the vault.
|
|
fn write(vault: &Path, path: &str, bytes: &[u8]) {
|
|
let target = vault.join(path);
|
|
if let Some(parent) = target.parent() {
|
|
std::fs::create_dir_all(parent).expect("create the folder");
|
|
}
|
|
std::fs::write(target, bytes).expect("write the file");
|
|
}
|
|
|
|
/// A sidecar document with one stroke.
|
|
fn sidecar(width: u32, height: u32) -> Value {
|
|
json!({
|
|
"version": 1,
|
|
"image": { "hash": null, "width": width, "height": height },
|
|
"items": [
|
|
{ "type": "stroke", "tool": "pen", "color": { "theme": 0 }, "width": 2.0, "points": [[1.0, 1.0], [3.0, 2.0]] }
|
|
]
|
|
})
|
|
}
|
|
|
|
/// The code a failed command reported.
|
|
fn code(result: &Result<Value, Error>) -> String {
|
|
match result {
|
|
Err(Error::Command { source, .. }) => source.code.clone(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// `file.info` tells what every file of the vault is.
|
|
#[test]
|
|
fn file_info_describes_every_kind_of_file() {
|
|
let vault = temp_vault("viewers-info");
|
|
write(vault.path(), "note.md", b"# Note\n");
|
|
write(vault.path(), "shot.png", &png_bytes(800, 600));
|
|
write(vault.path(), "clip.mp4", b"\x00\x00\x00\x18ftypmp42 rest of the file");
|
|
write(vault.path(), "tool.rs", b"fn main() {}\n");
|
|
write(vault.path(), "data.bin", &[0x00, 0x01, 0x02, 0xFF]);
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let note = registry.run("file.info", &mut session, &json!({ "path": "note.md" })).expect("note.md");
|
|
assert_eq!(note.get("kind"), Some(&json!("note")));
|
|
assert_eq!(note.get("mime"), Some(&json!("text/markdown")));
|
|
assert_eq!(note.get("text"), Some(&json!(true)));
|
|
assert_eq!(note.get("name"), Some(&json!("note.md")));
|
|
assert_eq!(note.get("ext"), Some(&json!("md")));
|
|
|
|
let picture = registry.run("file.info", &mut session, &json!({ "path": "shot.png" })).expect("shot.png");
|
|
assert_eq!(picture.get("kind"), Some(&json!("image")));
|
|
assert_eq!(picture.get("mime"), Some(&json!("image/png")));
|
|
assert_eq!(picture.get("width"), Some(&json!(800)));
|
|
assert_eq!(picture.get("height"), Some(&json!(600)));
|
|
assert_eq!(picture.get("text"), Some(&json!(false)));
|
|
|
|
let video = registry.run("file.info", &mut session, &json!({ "path": "clip.mp4" })).expect("clip.mp4");
|
|
assert_eq!(video.get("kind"), Some(&json!("video")));
|
|
assert_eq!(video.get("mime"), Some(&json!("video/mp4")));
|
|
|
|
let code = registry.run("file.info", &mut session, &json!({ "path": "tool.rs" })).expect("tool.rs");
|
|
assert_eq!(code.get("kind"), Some(&json!("code")));
|
|
assert_eq!(code.get("text"), Some(&json!(true)));
|
|
|
|
let binary = registry.run("file.info", &mut session, &json!({ "path": "data.bin" })).expect("data.bin");
|
|
assert_eq!(binary.get("kind"), Some(&json!("other")));
|
|
assert_eq!(binary.get("text"), Some(&json!(false)));
|
|
assert_eq!(binary.get("mime"), Some(&json!("application/octet-stream")));
|
|
assert!(binary.get("size").and_then(Value::as_u64).is_some_and(|size| size == 4));
|
|
assert!(binary.get("modified_ms").and_then(Value::as_u64).is_some());
|
|
}
|
|
|
|
/// `file.info` refuses a folder, a symlink and a path outside the vault.
|
|
#[test]
|
|
fn file_info_refuses_a_folder_a_symlink_and_a_escape() {
|
|
let vault = temp_vault("viewers-info-refused");
|
|
write(vault.path(), "shot.png", &png_bytes(10, 10));
|
|
std::fs::create_dir_all(vault.path().join("folder")).expect("create the folder");
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "folder" }))), "invalid_path");
|
|
assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "../outside.png" }))), "invalid_path");
|
|
assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "missing.png" }))), "not_found");
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(vault.path().join("shot.png"), vault.path().join("link.png")).expect("make the symlink");
|
|
assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "link.png" }))), "invalid_path");
|
|
}
|
|
}
|
|
|
|
/// `file.hash` returns the SHA-256 of the file.
|
|
#[test]
|
|
fn file_hash_matches_the_known_vector() {
|
|
let vault = temp_vault("viewers-hash");
|
|
write(vault.path(), "abc.txt", b"abc");
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let hash = registry.run("file.hash", &mut session, &json!({ "path": "abc.txt" })).expect("hash");
|
|
assert_eq!(hash.get("sha256"), Some(&json!("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")));
|
|
assert_eq!(hash.get("size"), Some(&json!(3)));
|
|
}
|
|
|
|
/// `file.neighbours` walks the files of the same kind in natural order.
|
|
#[test]
|
|
fn file_neighbours_orders_the_files_of_the_same_kind() {
|
|
let vault = temp_vault("viewers-neighbours");
|
|
for name in ["img1.png", "img2.png", "img10.png", "photo.jpg", "notes.md"] {
|
|
write(vault.path(), name, &png_bytes(10, 10));
|
|
}
|
|
write(vault.path(), "img2.png.annotations.json", b"{}");
|
|
write(vault.path(), "data.json", b"{}");
|
|
write(vault.path(), "data.json.annotations.json", b"{}");
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let neighbours = registry.run("file.neighbours", &mut session, &json!({ "path": "img2.png" })).expect("neighbours");
|
|
assert_eq!(neighbours.get("count"), Some(&json!(4)), "the four pictures count, the note does not");
|
|
assert_eq!(neighbours.get("index"), Some(&json!(1)));
|
|
assert_eq!(neighbours.get("previous"), Some(&json!("img1.png")));
|
|
assert_eq!(neighbours.get("next"), Some(&json!("img10.png")), "the number is read as a number");
|
|
|
|
let first = registry.run("file.neighbours", &mut session, &json!({ "path": "img1.png" })).expect("first");
|
|
assert_eq!(first.get("previous"), None, "nothing comes before the first picture");
|
|
assert_eq!(first.get("next"), Some(&json!("img2.png")));
|
|
|
|
let last = registry.run("file.neighbours", &mut session, &json!({ "path": "photo.jpg" })).expect("last");
|
|
assert_eq!(last.get("next"), None, "nothing comes after the last picture");
|
|
assert_eq!(last.get("previous"), Some(&json!("img10.png")));
|
|
|
|
let data = registry.run("file.neighbours", &mut session, &json!({ "path": "data.json" })).expect("data");
|
|
assert_eq!(data.get("count"), Some(&json!(1)), "the sidecar of a file is not its neighbour");
|
|
}
|
|
|
|
/// The annotations of a picture live in a sidecar next to it.
|
|
#[test]
|
|
fn the_annotations_round_trip_through_the_sidecar() {
|
|
let vault = temp_vault("viewers-annotations");
|
|
let image = png_bytes(400, 300);
|
|
write(vault.path(), "shot.png", &image);
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let missing = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load");
|
|
assert_eq!(missing.get("exists"), Some(&json!(false)));
|
|
assert_eq!(missing.get("annotations"), None);
|
|
|
|
let document = sidecar(400, 300);
|
|
let saved = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": document })).expect("save");
|
|
assert_eq!(saved.get("saved"), Some(&json!(true)));
|
|
assert!(vault.path().join("shot.png.annotations.json").exists(), "the sidecar is there");
|
|
assert_eq!(std::fs::read(vault.path().join("shot.png")).expect("read the picture"), image, "the picture is untouched");
|
|
|
|
let loaded = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load");
|
|
assert_eq!(loaded.get("exists"), Some(&json!(true)));
|
|
assert_eq!(loaded.get("annotations").and_then(|value| value.get("items")).and_then(Value::as_array).map(Vec::len), Some(1));
|
|
|
|
// Writing the same document again changes nothing.
|
|
let again = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": document })).expect("save again");
|
|
assert_eq!(again.get("saved"), Some(&json!(false)), "the bytes are the same");
|
|
}
|
|
|
|
/// A sidecar that does not validate is reported, never touched.
|
|
#[test]
|
|
fn a_corrupt_sidecar_is_reported_and_left_alone() {
|
|
let vault = temp_vault("viewers-corrupt");
|
|
write(vault.path(), "shot.png", &png_bytes(400, 300));
|
|
write(vault.path(), "shot.png.annotations.json", b"{ not json at all");
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let loaded = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load");
|
|
assert_eq!(loaded.get("exists"), Some(&json!(true)));
|
|
assert_eq!(loaded.get("notice"), Some(&json!("invalid")));
|
|
assert_eq!(loaded.get("annotations"), None);
|
|
assert_eq!(std::fs::read(vault.path().join("shot.png.annotations.json")).expect("read the sidecar"), b"{ not json at all");
|
|
}
|
|
|
|
/// Writing over a sidecar that does not validate keeps the old one as `.bad`.
|
|
#[test]
|
|
fn saving_over_a_corrupt_sidecar_keeps_it_as_bad() {
|
|
let vault = temp_vault("viewers-bad");
|
|
write(vault.path(), "shot.png", &png_bytes(400, 300));
|
|
write(vault.path(), "shot.png.annotations.json", b"{ not json at all");
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let saved = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": sidecar(400, 300) })).expect("save");
|
|
assert_eq!(saved.get("saved"), Some(&json!(true)));
|
|
assert_eq!(std::fs::read(vault.path().join("shot.png.annotations.json.bad")).expect("read the bad copy"), b"{ not json at all");
|
|
assert!(vault.path().join("shot.png.annotations.json").exists(), "the new sidecar is there");
|
|
}
|
|
|
|
/// Saving an empty list of items removes the sidecar.
|
|
#[test]
|
|
fn saving_nothing_removes_the_sidecar() {
|
|
let vault = temp_vault("viewers-empty");
|
|
write(vault.path(), "shot.png", &png_bytes(400, 300));
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": sidecar(400, 300) })).expect("save");
|
|
assert!(vault.path().join("shot.png.annotations.json").exists());
|
|
|
|
let empty = json!({ "version": 1, "image": { "hash": null, "width": 400, "height": 300 }, "items": [] });
|
|
let saved = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": empty })).expect("save");
|
|
assert_eq!(saved.get("saved"), Some(&json!(true)));
|
|
assert!(!vault.path().join("shot.png.annotations.json").exists(), "the sidecar is gone");
|
|
}
|
|
|
|
/// A sidecar that belongs to another size of the picture is reported as such.
|
|
#[test]
|
|
fn a_sidecar_of_another_size_is_reported() {
|
|
let vault = temp_vault("viewers-changed");
|
|
write(vault.path(), "shot.png", &png_bytes(400, 300));
|
|
let document = sidecar(800, 600);
|
|
write(vault.path(), "shot.png.annotations.json", &serde_json::to_vec(&document).expect("serialise"));
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
let loaded = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load");
|
|
assert_eq!(loaded.get("exists"), Some(&json!(true)));
|
|
assert_eq!(loaded.get("notice"), Some(&json!("image_changed")));
|
|
assert_eq!(loaded.get("stored"), Some(&json!({ "w": 800, "h": 600 })));
|
|
assert_eq!(loaded.get("actual"), Some(&json!({ "w": 400, "h": 300 })));
|
|
assert_eq!(
|
|
loaded.get("annotations").and_then(|value| value.get("items")).and_then(Value::as_array).map(Vec::len),
|
|
Some(1),
|
|
"the annotations are kept as they are stored"
|
|
);
|
|
}
|
|
|
|
/// A read-only session writes nothing.
|
|
#[test]
|
|
fn a_read_only_session_refuses_to_save() {
|
|
let vault = temp_vault("viewers-readonly");
|
|
write(vault.path(), "shot.png", &png_bytes(400, 300));
|
|
let registry = registry();
|
|
let mut session = Session::open_read_only(vault.path()).expect("open read-only");
|
|
|
|
let result = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": sidecar(400, 300) }));
|
|
assert!(result.is_err(), "the save is refused");
|
|
assert!(!vault.path().join("shot.png.annotations.json").exists(), "nothing was written");
|
|
}
|
|
|
|
/// Hostile params never panic a viewer command and never write anything.
|
|
#[test]
|
|
fn hostile_params_are_refused() {
|
|
let vault = temp_vault("viewers-hostile");
|
|
write(vault.path(), "shot.png", &png_bytes(400, 300));
|
|
let registry = registry();
|
|
let mut session = session(&vault);
|
|
|
|
assert!(registry.run("file.info", &mut session, &json!({ "path": "a\0b.png" })).is_err(), "a NUL in the path");
|
|
assert!(registry.run("file.info", &mut session, &json!({ "path": 7 })).is_err(), "a path that is not a string");
|
|
assert!(registry.run("file.info", &mut session, &json!({ "path": "x".repeat(4096) })).is_err(), "a path that is too long");
|
|
assert!(registry.run("file.hash", &mut session, &json!({ "path": "../secret" })).is_err(), "a path outside the vault");
|
|
assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": "text" })).is_err(), "wrong type");
|
|
assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png" })).is_err(), "no annotations");
|
|
assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": { "version": 9 } })).is_err(), "a bad version");
|
|
|
|
let huge = "x".repeat(9 * 1024 * 1024);
|
|
assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": { "text": huge } })).is_err(), "too many bytes");
|
|
|
|
assert!(!vault.path().join("shot.png.annotations.json").exists(), "nothing was written");
|
|
assert!(registry.run("file.info", &mut session, &json!({ "path": "shot.png" })).is_ok(), "the registry is still usable");
|
|
}
|