208 lines
10 KiB
Rust
208 lines
10 KiB
Rust
//! 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<Session>) {
|
|
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>, session: &mut Session, id: &str, params: &Value) -> Result<Value, Error> {
|
|
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"<svg/>", 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"<svg/>").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:?}");
|
|
}
|