Files
2026-10-14 23:16:33 -03:00

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(&registry, &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(&registry, &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(&registry, &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:?}");
}