//! Attachments (SPEC 9): naming, collisions, caps, sniffing, links, gating. mod common; use std::path::PathBuf; use common::temp_vault; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; use notescraft_templates::{Date, DateTime}; /// 4 bytes `abcd` in base64. const ABCD: &str = "YWJjZA=="; /// A fixed afternoon clock: 2026-10-14 15:00:00. fn fixed_now() -> DateTime { let day = Date::new(2026, 10, 14).unwrap_or_else(|error| panic!("{error}")); DateTime::new(day, 15, 0, 0).unwrap_or_else(|error| panic!("{error}")) } /// A writable session plus the core registry. fn fixture(name: &str) -> (common::TempVault, Session, Registry) { let vault = temp_vault(name); 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) } /// Runs a command and unwraps its JSON result. fn run(registry: &Registry, session: &mut Session, id: &str, params: &Value) -> Result { registry.run(id, session, params) } #[test] fn an_unnamed_paste_gets_the_stamped_name() { let (_vault, mut session, _reg) = fixture("attach-stamp"); let saved = session.save_attachment_at(None, "png", b"png-bytes", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert_eq!(saved.path, "Attachments/Pasted image 20261014150000.png"); assert_eq!(saved.link, "![[Pasted image 20261014150000.png]]"); assert!(session.vault().resolve(&saved.path).unwrap_or_default().exists()); assert!(session.index().files().contains(&"Attachments/Pasted image 20261014150000.png".to_string().as_str())); } #[test] fn a_hint_is_sanitised_and_keeps_its_own_extension_dropped() { let (_vault, mut session, _reg) = fixture("attach-hint"); let saved = session.save_attachment_at(Some("../img/pic 1.PNG"), "png", b"x", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert_eq!(saved.path, "Attachments/pic 1.png"); let hostile = session.save_attachment_at(Some(".obsidian/state"), "svg", b"", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert_eq!(hostile.path, "Attachments/state.svg"); } #[test] fn collisions_get_numeric_suffixes_and_never_overwrite() { let (_vault, mut session, _reg) = fixture("attach-collide"); let first = session.save_attachment_at(Some("art"), "png", b"one", fixed_now()).unwrap_or_else(|error| panic!("{error}")); let second = session.save_attachment_at(Some("art"), "png", b"two", fixed_now()).unwrap_or_else(|error| panic!("{error}")); let third = session.save_attachment_at(Some("art"), "png", b"three", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert_eq!(first.path, "Attachments/art.png"); assert_eq!(second.path, "Attachments/art 1.png"); assert_eq!(third.path, "Attachments/art 2.png"); for (path, want) in [(&first.path, "one"), (&second.path, "two"), (&third.path, "three")] { let bytes = session.vault().read(path).unwrap_or_default(); assert_eq!(bytes, want.as_bytes().to_vec(), "{path}"); } } #[test] fn an_oversize_attachment_is_refused_before_any_byte_is_written() { let vault = temp_vault("attach-cap"); let mut config = notescraft_engine::SessionConfig::default(); config.files.max_attachment_mib = 1; let mut session = Session::open_with(vault.path(), config).unwrap_or_else(|error| panic!("{error}")); let two_mib = vec![0_u8; 2 * 1024 * 1024]; match session.save_attachment(Some("big"), "png", &two_mib) { Err(Error::AttachmentTooLarge { max, .. }) => assert_eq!(max, 1024 * 1024), other => panic!("expected a too-large error, got {other:?}"), } assert!(!session.vault().resolve("Attachments/big.png").unwrap_or_default().exists()); let small = session.save_attachment(Some("small"), "png", b"tiny"); assert!(small.is_ok(), "{small:?}"); } #[test] fn the_declared_extension_is_fixed_from_the_magic_bytes() { let (_vault, mut session, _reg) = fixture("attach-sniff"); let jpeg = [0xFF_u8, 0xD8, 0xFF, 0xE0, 1, 2]; let png = [0x89_u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2]; let from_jpeg = session.save_attachment(Some("shot"), "png", &jpeg).unwrap_or_else(|error| panic!("{error}")); assert_eq!(from_jpeg.path, "Attachments/shot.jpeg"); let from_png = session.save_attachment(Some("shot2"), "jpg", &png).unwrap_or_else(|error| panic!("{error}")); assert_eq!(from_png.path, "Attachments/shot2.png"); // A file the user honestly named `.jpg` stays `.jpg`. let honest = session.save_attachment(Some("shot3"), "jpg", &jpeg).unwrap_or_else(|error| panic!("{error}")); assert_eq!(honest.path, "Attachments/shot3.jpg"); // Unsniffable types keep their declared extension. let svg = session.save_attachment(Some("icon"), "svg", b"").unwrap_or_else(|error| panic!("{error}")); assert_eq!(svg.path, "Attachments/icon.svg"); } #[test] fn an_svg_that_is_not_an_svg_is_refused() { let (_vault, mut session, _reg) = fixture("attach-svg"); match session.save_attachment(Some("icon"), "svg", b"hello") { Err(Error::UnsupportedAttachment(message)) => assert!(message.contains("SVG"), "{message}"), other => panic!("expected an unsupported-type error, got {other:?}"), } } #[test] fn an_unknown_type_is_refused() { let (_vault, mut session, _reg) = fixture("attach-exe"); for ext in ["exe", "", "PNGX", "sh"] { match session.save_attachment(Some("x"), ext, b"data") { Err(Error::UnsupportedAttachment(_)) => {} other => panic!("`{ext}` must be refused, got {other:?}"), } } } #[test] fn a_read_only_session_refuses_attachments() { let vault = temp_vault("attach-ro"); let mut session = Session::open_read_only(vault.path()).unwrap_or_else(|error| panic!("{error}")); match session.save_attachment(Some("x"), "png", b"data") { Err(Error::ReadOnly) => {} other => panic!("expected read-only refusal, got {other:?}"), } } #[test] fn the_embed_uses_the_path_when_the_name_is_not_unique() { let vault = temp_vault("attach-link"); common::seed(vault.path(), &[("Other/art.png", "elsewhere")]); let mut session = Session::open(vault.path()).unwrap_or_else(|error| panic!("{error}")); let saved = session.save_attachment_at(Some("art"), "png", b"here", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert_eq!(saved.path, "Attachments/art.png"); assert_eq!(saved.link, "![[Attachments/art.png]]"); // In a vault without the clash the bare name is used. let (_v2, mut session2, _reg) = fixture("attach-link2"); let unique = session2.save_attachment_at(Some("solo"), "png", b"here", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert_eq!(unique.link, "![[solo.png]]"); } #[test] fn the_save_command_decodes_base64_and_gates_writes() { let (vault, mut session, registry) = fixture("attach-cmd"); let result = run(®istry, &mut session, "attachment.save", &json!({"ext": "PNG", "data_base64": ABCD, "name": "note pic"})); match result { Ok(value) => { assert_eq!(value.get("path").and_then(Value::as_str), Some("Attachments/note pic.png"), "{value}"); assert_eq!(value.get("link").and_then(Value::as_str), Some("![[note pic.png]]"), "{value}"); } Err(error) => panic!("{error}"), } let payload = json!({"ext": "png", "data_base64": "not*base64!"}); match run(®istry, &mut session, "attachment.save", &payload) { Err(Error::Command { source, .. }) => assert_eq!(source.code, "invalid_base64", "{source}"), other => panic!("expected a command error, got {other:?}"), } // A second instance opened read-only finds the command disabled. let mut ro = Session::open_read_only(vault.path()).unwrap_or_else(|error| panic!("{error}")); assert!(matches!(run(®istry, &mut ro, "attachment.save", &json!({"ext": "png", "data_base64": ABCD})), Err(Error::Disabled(_)))); } #[test] fn the_save_path_command_copies_a_dropped_file() { let vault = temp_vault("attach-path"); let outside = std::env::temp_dir().join(format!("notescraft-drop-{}.png", std::process::id())); std::fs::write(&outside, b"dropped png").unwrap_or_else(|error| panic!("{error}")); let mut session = Session::open(vault.path()).unwrap_or_else(|error| panic!("{error}")); let saved = session.save_attachment_from_path(&outside, Some("dropped")).unwrap_or_else(|error| panic!("{error}")); assert_eq!(saved.path, "Attachments/dropped.png"); assert_eq!(saved.link, "![[dropped.png]]"); assert_eq!(session.vault().read(&saved.path).unwrap_or_default(), b"dropped png".to_vec()); let _ = std::fs::remove_file(&outside); } #[test] fn a_dropped_file_of_an_unknown_type_is_refused() { let vault = temp_vault("attach-path2"); let outside = std::env::temp_dir().join(format!("notescraft-drop-{}.exe", std::process::id())); std::fs::write(&outside, b"MZ binary").unwrap_or_else(|error| panic!("{error}")); let mut session = Session::open(vault.path()).unwrap_or_else(|error| panic!("{error}")); let saved = session.save_attachment_from_path(&outside, None); assert!(matches!(saved, Err(Error::UnsupportedAttachment(_))), "{saved:?}"); let _ = std::fs::remove_file(&outside); } #[test] fn a_missing_dropped_file_is_not_found() { let (_vault, mut session, _reg) = fixture("attach-path3"); let gone = session.save_attachment_from_path(&PathBuf::from("/definitely/not/here.png"), None); assert!(matches!(gone, Err(Error::NotFound(_))), "{gone:?}"); } #[test] fn saved_attachments_are_registered_in_the_index() { let (_vault, mut session, _reg) = fixture("attach-index"); let saved = session.save_attachment_at(Some("pic"), "png", b"x", fixed_now()).unwrap_or_else(|error| panic!("{error}")); assert!(session.index().files().contains(&saved.path.as_str()), "{:?}", session.index().files()); // The link resolves through the vault index, too. let resolution = session.index().resolve("note.md", "pic.png"); assert!(resolution.is_resolved(), "{resolution:?}"); }