Files
Marcos Paulo 4806209761 feat: whiteboard upgrade (.draw format, zoom bar, layers, SVG export) and vault-only engine exports
Whiteboard: zoom bar and dot grid, layers panel, appear motion, sound cues, focus rings
and AccessKit labels; .draw files with legacy .excalidraw still readable;
notescraft-cli export whiteboard.

Security: export.pdf and file.export_whiteboard_svg accept vault-relative out paths only
(they are reachable by the assistant and the control channel). The CLI places absolute
--out files itself via the new notescraft_engine::render_pdf; the UI export worker renders
and the live session writes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 11:12:33 -03:00

168 lines
9.0 KiB
Rust

//! `export.pdf` (SPEC 16): the vault write, the assets the exporter reads,
//! the page options and the honest `print_unavailable` report.
mod common;
use std::path::Path;
use common::{seed, temp_vault};
use notescraft_engine::{Registry, Session, register_core};
use serde_json::json;
/// A writable session over a seeded vault plus the core registry.
fn fixture(name: &str, files: &[(&str, &str)]) -> (common::TempVault, Session, Registry<Session>) {
let vault = temp_vault(name);
seed(vault.path(), files);
let session = Session::open(vault.path()).unwrap_or_else(|error| panic!("open: {error}"));
let mut registry = Registry::new();
register_core(&mut registry).unwrap_or_else(|error| panic!("register: {error}"));
(vault, session, registry)
}
/// The `PDF` acceptance row: a fixture note exports, the file opens as a PDF
/// and the command reports its path, page count and size.
#[test]
fn a_note_exports_to_a_pdf_beside_itself() {
let (vault, mut session, registry) = fixture("export-basic", &[("Note.md", "# Title\n\nBody with **bold**.\n")]);
let result = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md" }));
let value = result.unwrap_or_else(|error| panic!("{error:?}"));
assert_eq!(value["path"], json!("Note.pdf"));
assert_eq!(value["pages"], json!(1));
assert!(value["bytes"].as_u64().unwrap_or_default() > 1_000, "the file carries the embedded fonts: {value}");
let written = vault.path().join("Note.pdf");
let bytes = std::fs::read(&written).unwrap_or_else(|error| panic!("read: {error}"));
assert!(bytes.starts_with(b"%PDF-1.7\n"), "the file is a PDF 1.7");
assert!(bytes.ends_with(b"%%EOF\n"), "and ends with its trailer");
assert_eq!(bytes.len() as u64, value["bytes"].as_u64().unwrap_or_default(), "the report matches the file");
}
/// Page size and margins reach the document, and bad values are refused.
#[test]
fn page_options_reach_the_document_and_bad_ones_fail() {
let (_vault, mut session, registry) = fixture("export-page", &[("Note.md", "# Title\n")]);
let letter = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "page": "letter" }));
assert!(letter.is_ok(), "{:?}", letter.err());
let bad_page = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "page": "a3" }));
assert!(bad_page.is_err(), "an unknown page size is refused");
let bad_margin = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "margins_mm": 500 }));
assert!(bad_margin.is_err(), "out-of-range margins are refused, not clamped");
let ok_margin = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "margins_mm": 15 }));
assert!(ok_margin.is_ok(), "{:?}", ok_margin.err());
}
/// `out` is vault-relative: engine commands are reachable by the assistant and the control channel,
/// so they never write to an absolute host path (the CLI places those files itself).
#[test]
fn out_stays_inside_the_vault_and_host_paths_are_refused() {
let (vault, mut session, registry) = fixture("export-out", &[("Note.md", "# Title\n")]);
let inside = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "out": "exports/report.pdf" }));
assert!(inside.is_ok(), "{:?}", inside.err());
assert!(vault.path().join("exports/report.pdf").exists(), "the folder was created inside the vault");
let outside = vault.path().join("elsewhere.pdf");
let hostile = [
outside.display().to_string(),
String::from("/etc/evil.pdf"),
String::from("C:\\evil.pdf"),
String::from("\\\\server\\share\\x.pdf"),
String::from("../escape.pdf"),
String::from(".notescraft/x.pdf"),
String::from(".obsidian/x.pdf"),
];
for out in hostile {
let result = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "out": out }));
assert!(result.is_err(), "`{out}` must be refused");
}
assert!(!outside.exists(), "nothing was written outside the vault folder structure");
assert!(!vault.path().join("../escape.pdf").exists());
}
/// The whiteboard SVG command has the same rule and never overwrites.
#[test]
fn whiteboard_svg_is_vault_relative_and_never_overwrites() {
let (vault, mut session, registry) = fixture("export-svg", &[]);
let ok = registry.run("file.export_whiteboard_svg", &mut session, &json!({ "out": "boards/a.svg", "svg": "<svg/>" }));
assert!(ok.is_ok(), "{:?}", ok.err());
assert!(vault.path().join("boards/a.svg").exists());
let again = registry.run("file.export_whiteboard_svg", &mut session, &json!({ "out": "boards/a.svg", "svg": "<svg/>" }));
assert!(again.is_err(), "an existing file is refused");
for out in ["/tmp/notescraft-evil.svg", "C:\\evil.svg", "../escape.svg", ".notescraft/x.svg", ".obsidian/x.svg", ""] {
let result = registry.run("file.export_whiteboard_svg", &mut session, &json!({ "out": out, "svg": "<svg/>" }));
assert!(result.is_err(), "`{out}` must be refused");
}
assert!(!Path::new("/tmp/notescraft-evil.svg").exists());
}
/// `render_pdf` is the trusted entry for the CLI: bytes out, nothing written.
#[test]
fn render_pdf_returns_bytes_without_writing() {
let (vault, session, _registry) = fixture("export-render", &[("Note.md", "# Title\n")]);
let (bytes, pages) = notescraft_engine::render_pdf(&session, &json!({ "path": "Note.md" })).unwrap_or_else(|error| panic!("{error:?}"));
assert!(bytes.starts_with(b"%PDF"));
assert_eq!(pages, 1);
assert!(!vault.path().join("Note.pdf").exists(), "rendering alone writes nothing");
}
/// Images and embedded notes come from the vault through the index.
#[test]
fn attachments_and_embedded_notes_come_from_the_vault() {
let png: &[u8] = include_bytes!("../../../crates/pdf/tests/fixtures/png-rgba.png");
let (vault, mut session, registry) = fixture("export-assets", &[("Note.md", "![[Child.md]]\n\n![[pic.png]]\n")]);
std::fs::write(vault.path().join("Child.md"), "## Child\n\nChild body.\n").unwrap_or_else(|error| panic!("{error}"));
std::fs::write(vault.path().join("pic.png"), png).unwrap_or_else(|error| panic!("{error}"));
let result = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md" }));
let value = result.unwrap_or_else(|error| panic!("{error:?}"));
assert_eq!(value["pages"], json!(1));
let bytes = std::fs::read(vault.path().join("Note.pdf")).unwrap_or_default();
assert!(bytes.starts_with(b"%PDF-1.7\n"));
assert!(bytes.len() > 10_000, "the fonts and the image are embedded");
}
/// A note that embeds itself is exported, not looped on.
#[test]
fn a_self_embedding_note_terminates() {
let (_vault, mut session, registry) = fixture("export-cycle", &[("Note.md", "![[Note.md]]\n\nBody.\n")]);
let result = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md" }));
assert!(result.is_ok(), "a cycle is a placeholder, not a failure: {:?}", result.err());
}
/// `print: true` is accepted and honestly reported until the app wires
/// `craft-print`.
#[test]
fn print_reports_print_unavailable() {
let (_vault, mut session, registry) = fixture("export-print", &[("Note.md", "# Title\n")]);
let value = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md", "print": true })).unwrap_or_else(|error| panic!("{error:?}"));
assert_eq!(value["print"], json!("print_unavailable"));
let quiet = registry.run("export.pdf", &mut session, &json!({ "path": "Note.md" })).unwrap_or_else(|error| panic!("{error:?}"));
assert!(quiet.get("print").is_none(), "without the flag there is nothing to report");
}
/// The command is writable-gated and refuses a missing note.
#[test]
fn missing_notes_and_read_only_sessions_fail() {
let (vault, mut session, registry) = fixture("export-errors", &[("Note.md", "# Title\n")]);
let missing = registry.run("export.pdf", &mut session, &json!({ "path": "Nope.md" }));
assert!(missing.is_err(), "a missing note is an error");
let escaping = registry.run("export.pdf", &mut session, &json!({ "path": "../outside.md" }));
assert!(escaping.is_err(), "a path outside the vault is refused");
let mut read_only = Session::open_read_only(vault.path()).unwrap_or_else(|error| panic!("open read-only: {error}"));
let result = registry.run("export.pdf", &mut read_only, &json!({ "path": "Note.md" }));
assert!(result.is_err(), "a read-only session cannot write the export");
}
/// The command appears in the registry list with its parameters.
#[test]
fn the_command_is_registered_with_its_params() {
let (_vault, mut session, registry) = fixture("export-registered", &[("Note.md", "# Title\n")]);
let spec = registry.get("export.pdf");
assert!(spec.is_some(), "export.pdf is registered");
if let Some(spec) = spec {
assert_eq!(spec.id, "export.pdf");
let names: Vec<&str> = spec.params.iter().map(|param| param.name.as_str()).collect();
assert!(names.contains(&"out"), "the `out` parameter is declared: {names:?}");
assert!(names.contains(&"print"), "the `print` parameter is declared: {names:?}");
}
let _ = &mut session;
assert!(Path::new("/tmp").exists());
}