4806209761
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>
168 lines
9.0 KiB
Rust
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());
|
|
}
|