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

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(&registry.run("file.info", &mut session, &json!({ "path": "folder" }))), "invalid_path");
assert_eq!(code(&registry.run("file.info", &mut session, &json!({ "path": "../outside.png" }))), "invalid_path");
assert_eq!(code(&registry.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(&registry.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");
}