8d450b6fd6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
101 lines
4.9 KiB
Rust
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)));
|
|
}
|