//! `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) { 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": "" })); 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": "" })); 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": "" })); 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()); }