Files
Marcos Paulo 8d450b6fd6 feat(vault): wave 2 fix-vault
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:11:18 -03:00

101 lines
4.9 KiB
Rust

//! Reserved folders: no public API writes `.obsidian/` or `.notescraft/` (SPEC §4.1).
mod common;
use std::fs;
use notescraft_vault::{Error, Vault, WriteOutcome};
/// Every mutating entry point refuses both reserved folders.
#[test]
fn writes_rename_trash_and_mkdir_refuse_reserved_paths() {
let (_dir, vault) = common::vault("reserved-write");
for rel in
[".obsidian", ".obsidian/app.json", ".obsidian/workspace.json", "./.obsidian/a/b", ".notescraft", ".notescraft/lock", ".notescraft/themes/x.toml"]
{
let err = vault.write(rel, b"x").expect_err(rel);
assert!(matches!(err, Error::Reserved(_)), "{rel}: {err:?}");
let err = vault.create_dir(rel).expect_err(rel);
assert!(matches!(err, Error::Reserved(_)), "{rel}: {err:?}");
let err = vault.delete_to_trash(rel).expect_err(rel);
assert!(matches!(err, Error::Reserved(_)), "{rel}: {err:?}");
let err = vault.rename(rel, "ok.md").expect_err(rel);
assert!(matches!(err, Error::Reserved(_)), "{rel} as source: {err:?}");
let err = vault.rename("ok.md", rel).expect_err(rel);
assert!(matches!(err, Error::Reserved(_)), "{rel} as destination: {err:?}");
}
assert!(!vault.root().join(".obsidian").exists());
assert!(!vault.root().join(".obsidian/app.json").exists());
}
/// A malformed path is still a traversal, not a reserved path.
#[test]
fn a_traversal_out_of_a_reserved_folder_is_reported_as_outside_the_vault() {
let (_dir, vault) = common::vault("reserved-traversal");
for rel in [".notescraft/../x", ".obsidian/../../etc/passwd", ".obsidian/\0"] {
let err = vault.write(rel, b"x").expect_err(rel);
assert!(matches!(err, Error::OutsideVault(_)), "{rel}: {err:?}");
}
}
/// A name that merely starts like a reserved folder is a normal note.
#[test]
fn paths_outside_the_reserved_folders_are_written_as_usual() {
let (_dir, vault) = common::vault("reserved-allowed");
for rel in ["note.md", "sub/.hidden/note.md", ".obsidianx/a.md", "obsidian/a.md", "a/.obsidian/b.md", "sub/notescraft.md"] {
assert_eq!(vault.write(rel, b"x").unwrap(), WriteOutcome::Written, "{rel}");
assert_eq!(vault.read(rel).unwrap(), b"x", "{rel}");
}
assert!(vault.create_dir("sub/dir").is_ok());
assert!(vault.rename("note.md", "sub/moved.md").is_ok());
assert!(vault.delete_to_trash("sub/moved.md").is_ok());
assert!(vault.read("sub/moved.md").is_err());
}
/// Reading is how `.obsidian/app.json` is imported, so it stays open.
#[test]
fn reading_and_resolving_reserved_paths_stay_allowed() {
let (dir, vault) = common::vault("reserved-read");
fs::create_dir_all(dir.path().join(".obsidian")).unwrap();
fs::write(dir.path().join(".obsidian/app.json"), br#"{"attachmentFolderPath":"assets"}"#).unwrap();
fs::write(dir.path().join(".notescraft/lock"), b"1 0").unwrap();
assert_eq!(vault.read(".obsidian/app.json").unwrap(), br#"{"attachmentFolderPath":"assets"}"#);
assert_eq!(vault.read(".notescraft/lock").unwrap(), b"1 0");
assert_eq!(vault.resolve(".obsidian/app.json").unwrap(), vault.root().join(".obsidian/app.json"));
// Hidden folders are never listed, reserved or not.
let listed: Vec<String> = vault.list().unwrap().into_iter().map(|e| e.path).collect();
assert!(listed.is_empty(), "{listed:?}");
}
/// `write_private` is the one way under `.notescraft/`, and never into `.obsidian/`.
#[test]
fn private_state_can_be_written_under_notescraft_only() {
let (_dir, vault) = common::vault("reserved-private");
assert_eq!(vault.write_private(".notescraft/bookmarks.json", b"[]").unwrap(), WriteOutcome::Written);
assert_eq!(vault.read(".notescraft/bookmarks.json").unwrap(), b"[]");
assert_eq!(vault.write_private(".notescraft/recovery/note.md", b"draft").unwrap(), WriteOutcome::Written);
assert_eq!(vault.read(".notescraft/recovery/note.md").unwrap(), b"draft");
assert_eq!(vault.write_private(".notescraft/themes/a.toml", b"x").unwrap(), WriteOutcome::Written);
assert_eq!(vault.write_private("plugins/notes/x.js", b"x").unwrap(), WriteOutcome::Written);
for rel in [".obsidian", ".obsidian/app.json", "./.obsidian/a/b"] {
let err = vault.write_private(rel, b"x").expect_err(rel);
assert!(matches!(err, Error::Reserved(_)), "{rel}: {err:?}");
}
for bad in ["../x", ".notescraft/../x", "/etc/passwd", ""] {
let err = vault.write_private(bad, b"x").expect_err(bad);
assert!(matches!(err, Error::OutsideVault(_)), "{bad}: {err:?}");
}
}
/// A read-only vault refuses private writes too.
#[test]
fn a_read_only_vault_refuses_both_write_paths() {
let dir = common::TempDir::new("reserved-ro");
let vault = Vault::open_read_only(dir.path()).unwrap();
assert!(matches!(vault.write("note.md", b"x"), Err(Error::ReadOnly)));
assert!(matches!(vault.write_private(".notescraft/lock", b"x"), Err(Error::ReadOnly)));
assert!(matches!(vault.write(".obsidian/app.json", b"x"), Err(Error::ReadOnly)));
}