5e8020aa38
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/ video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits, block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
260 lines
12 KiB
Rust
260 lines
12 KiB
Rust
//! Hostile params for `grammar.install`, `grammar.install_text`, `grammar.remove`, `grammar.info`
|
|
//! and `json.format_block` / `json.minify_block`: every bad shape is a clean `Err`, nothing is
|
|
//! written under the grammars folder, the settings keep their grammar list and no note on disk moves.
|
|
|
|
mod common;
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use common::{seed, temp_config, temp_vault};
|
|
use notescraft_engine::{AppSettings, Error, Registry, Session, adversarial_params, register_core};
|
|
use notescraft_syntax::Error as SyntaxError;
|
|
use notescraft_syntax::fetch::Transport;
|
|
use serde_json::json;
|
|
|
|
/// The highlighter's grammar list is process-wide, so these tests take turns.
|
|
static TURN: Mutex<()> = Mutex::new(());
|
|
|
|
fn turn() -> std::sync::MutexGuard<'static, ()> {
|
|
TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
const TINY: &str = "%YAML 1.2\n---\nname: Tiny\nfile_extensions: [tiny]\nscope: source.tiny\ncontexts:\n main:\n - match: '\\bfoo\\b'\n scope: keyword.control.tiny\n";
|
|
|
|
/// The note the `json.*` commands are pointed at: a heading and one JSON code block.
|
|
const JSON_NOTE: &str = "# T\n\n```json\n{\"b\":1,\"a\":[1e5]}\n```\n";
|
|
|
|
/// The command ids under attack.
|
|
const IDS: [&str; 6] = ["grammar.install", "grammar.install_text", "grammar.remove", "grammar.info", "json.format_block", "json.minify_block"];
|
|
|
|
/// Serves canned bodies by URL and records every request; empty by default, so every download fails.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct Fake {
|
|
files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
|
|
asked: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl Transport for Fake {
|
|
fn get(&self, url: &str, _max: usize) -> notescraft_syntax::Result<Vec<u8>> {
|
|
self.asked.lock().expect("lock").push(url.to_owned());
|
|
self.files.lock().expect("lock").get(url).cloned().ok_or(SyntaxError::Offline)
|
|
}
|
|
}
|
|
|
|
struct Setup {
|
|
registry: Registry<Session>,
|
|
session: Session,
|
|
fake: Fake,
|
|
config: common::TempVault,
|
|
vault: common::TempVault,
|
|
}
|
|
|
|
fn setup(name: &str) -> Setup {
|
|
let vault = temp_vault(name);
|
|
seed(vault.path(), &[("n.md", JSON_NOTE), ("code.md", TINY)]);
|
|
let config = temp_config(name);
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register");
|
|
let mut session = Session::open(vault.path()).expect("open");
|
|
session.use_settings_dir(config.path());
|
|
let fake = Fake::default();
|
|
session.set_grammar_transport(Box::new(fake.clone()));
|
|
Setup { registry, session, fake, config, vault }
|
|
}
|
|
|
|
/// The file names sitting in `<config>/grammars/`, sorted; `Vec::new()` when the folder is absent.
|
|
fn saved_files(config: &Path) -> Vec<String> {
|
|
let Ok(entries) = std::fs::read_dir(config.join("grammars")) else { return Vec::new() };
|
|
let mut names: Vec<String> = entries.flatten().map(|entry| entry.file_name().to_string_lossy().into_owned()).collect();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
fn settings(config: &Path) -> AppSettings {
|
|
notescraft_engine::load_at(config).0
|
|
}
|
|
|
|
/// Every hostile id must be refused by every id-taking grammar command, writing nothing.
|
|
#[test]
|
|
fn hostile_grammar_ids_are_refused() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-id");
|
|
let before = settings(s.config.path()).grammars.clone();
|
|
let ids = [
|
|
json!(""),
|
|
json!("../../x"),
|
|
json!("A"),
|
|
json!("a".repeat(33)),
|
|
json!("x\0y"),
|
|
json!("gráma"),
|
|
json!("é".repeat(512)),
|
|
json!("a".repeat(10 * 1024)),
|
|
json!("Bad Id"),
|
|
json!("x\ny"),
|
|
json!(3),
|
|
json!(null),
|
|
json!(["tiny"]),
|
|
json!({ "id": "tiny" }),
|
|
];
|
|
for id in &ids {
|
|
assert!(s.registry.run("grammar.install", &mut s.session, &json!({ "id": id })).is_err(), "install must refuse {id}");
|
|
assert!(s.registry.run("grammar.install_text", &mut s.session, &json!({ "id": id, "text": TINY })).is_err(), "install_text must refuse {id}");
|
|
assert!(s.registry.run("grammar.remove", &mut s.session, &json!({ "id": id })).is_err(), "remove must refuse {id}");
|
|
}
|
|
assert!(s.registry.run("grammar.install", &mut s.session, &json!({})).is_err(), "a missing id is an error");
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, before);
|
|
assert_eq!(common::on_disk(s.vault.path(), "n.md"), JSON_NOTE, "no note may move");
|
|
assert_eq!(common::on_disk(s.vault.path(), "code.md"), TINY, "no note may move");
|
|
}
|
|
|
|
/// A hostile link is refused by the url gate: not one of them reaches the transport.
|
|
#[test]
|
|
fn hostile_urls_are_refused_before_any_download() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-url");
|
|
let urls = [
|
|
json!("http://example.org/a.sublime-syntax"),
|
|
json!("ftp://example.org/a.sublime-syntax"),
|
|
json!("file:///etc/passwd"),
|
|
json!("https://user:pw@h.dev/x"),
|
|
json!("https://[::1]/x"),
|
|
json!("https://127.0.0.1/x"),
|
|
json!("https://2130706433/x"),
|
|
json!("https://localhost/x"),
|
|
json!("https://x.dev:8443/x"),
|
|
json!(format!("https://x.dev/{}", "a".repeat(3000))),
|
|
json!("https://x.dev/a b.sublime-syntax"),
|
|
json!("https://x.dev/a\nb.sublime-syntax"),
|
|
json!("https://"),
|
|
json!(""),
|
|
json!(7),
|
|
json!(null),
|
|
];
|
|
for url in &urls {
|
|
assert!(s.registry.run("grammar.install", &mut s.session, &json!({ "id": "mine", "url": url })).is_err(), "install must refuse {url}");
|
|
}
|
|
assert_eq!(*s.fake.asked.lock().expect("lock"), Vec::<String>::new(), "a refused link must not be fetched");
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, Vec::new());
|
|
}
|
|
|
|
/// Hostile grammar sources never reach the grammars folder.
|
|
#[test]
|
|
fn hostile_grammar_texts_never_reach_the_disk() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-text");
|
|
let texts = [
|
|
json!(""),
|
|
json!(" ".repeat(600 * 1024)),
|
|
json!("<?xml version=\"1.0\"?><plist><dict/></plist>"),
|
|
json!("{}"),
|
|
json!("[]"),
|
|
json!("name: nope\nlist:\n - 1\n - 2\n"),
|
|
json!("\u{0}\u{1}\u{2}\u{3}\u{feff}\u{202e}"),
|
|
json!("\u{fffd}\u{10ffff}\u{7f}"),
|
|
json!(["a"]),
|
|
json!(null),
|
|
json!(12),
|
|
];
|
|
for (case, text) in texts.iter().enumerate() {
|
|
let id = format!("hostile-{case}");
|
|
assert!(s.registry.run("grammar.install_text", &mut s.session, &json!({ "id": id, "text": text })).is_err(), "text must be refused: {text}");
|
|
}
|
|
assert!(s.registry.run("grammar.install_text", &mut s.session, &json!({ "id": "no-text" })).is_err(), "a missing text is an error");
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, Vec::new());
|
|
assert_eq!(common::on_disk(s.vault.path(), "n.md"), JSON_NOTE);
|
|
}
|
|
|
|
/// Removing something that was never installed is an error, and changes nothing.
|
|
#[test]
|
|
fn remove_refuses_everything_that_is_not_installed() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-remove");
|
|
for id in ["tiny", "notescraft", "code", ""] {
|
|
assert!(s.registry.run("grammar.remove", &mut s.session, &json!({ "id": id })).is_err(), "remove must refuse `{id}`");
|
|
}
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, Vec::new());
|
|
}
|
|
|
|
/// `grammar.info` is read-only: any label shape answers, none of them install anything.
|
|
#[test]
|
|
fn grammar_info_answers_any_label_and_installs_nothing() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-info");
|
|
for label in [json!(""), json!("pyhton"), json!("x\0y"), json!(" "), json!("\u{202e}rtl"), json!("a".repeat(4096))] {
|
|
let result = s.registry.run("grammar.info", &mut s.session, &json!({ "label": label })).expect("info answers");
|
|
assert_eq!(result["installed"].as_array().map(Vec::len), Some(0), "label {label}");
|
|
}
|
|
for label in [json!(3), json!(null), json!(["rust"]), json!({ "a": 1 })] {
|
|
assert!(s.registry.run("grammar.info", &mut s.session, &json!({ "label": label })).is_err(), "a non-string label is an error");
|
|
}
|
|
assert!(s.registry.run("grammar.info", &mut s.session, &json!({ "id": "../../x", "text": TINY })).is_err(), "params it does not declare are refused");
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, Vec::new());
|
|
}
|
|
|
|
/// `json.*` refuses a path that leaves the vault and every index that is not a block.
|
|
#[test]
|
|
fn json_block_commands_refuse_hostile_paths_and_indexes() {
|
|
let _turn = turn();
|
|
let mut s = setup("json-block");
|
|
for id in ["json.format_block", "json.minify_block"] {
|
|
for path in ["../x.md", "/etc/passwd", "n\0d.md", "", "nope.md", "../n.md"] {
|
|
assert!(s.registry.run(id, &mut s.session, &json!({ "path": path, "index": 1 })).is_err(), "{id} must refuse `{path}`");
|
|
}
|
|
for index in [json!(-1), json!(99), json!(u64::MAX), json!(1.5), json!("1"), json!(null), json!([1])] {
|
|
assert!(s.registry.run(id, &mut s.session, &json!({ "path": "n.md", "index": index })).is_err(), "{id} must refuse {index}");
|
|
}
|
|
for params in [json!({}), json!({ "path": "n.md" }), json!({ "index": 1 }), json!({ "path": 3, "index": 1 })] {
|
|
assert!(s.registry.run(id, &mut s.session, ¶ms).is_err(), "{id} must refuse {params}");
|
|
}
|
|
}
|
|
assert_eq!(common::on_disk(s.vault.path(), "n.md"), JSON_NOTE, "a refused edit leaves the note alone");
|
|
assert_eq!(common::on_disk(s.vault.path(), "code.md"), TINY);
|
|
}
|
|
|
|
/// The control case: a good link really does install, so the refusals above mean something.
|
|
#[test]
|
|
fn a_good_link_installs_from_the_transport() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-ok");
|
|
s.fake.files.lock().expect("lock").insert("https://example.org/my.sublime-syntax".to_owned(), TINY.as_bytes().to_vec());
|
|
s.registry.run("grammar.install", &mut s.session, &json!({ "id": "mine", "url": "https://example.org/my.sublime-syntax" })).expect("installs");
|
|
assert_eq!(saved_files(s.config.path()), vec!["mine.sublime-syntax".to_owned()]);
|
|
assert_eq!(settings(s.config.path()).grammars.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), ["mine"]);
|
|
s.registry.run("grammar.remove", &mut s.session, &json!({ "id": "mine" })).expect("removes");
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, Vec::new());
|
|
}
|
|
|
|
/// No adversarial params object panics a grammar or json command, nor writes to the vault.
|
|
#[test]
|
|
fn adversarial_params_never_panic_a_grammar_or_json_command() {
|
|
let _turn = turn();
|
|
let mut s = setup("gram-fuzz");
|
|
let previous = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(|_| {}));
|
|
let mut panicked = Vec::new();
|
|
let mut accepted = 0_usize;
|
|
for id in IDS {
|
|
for params in adversarial_params() {
|
|
match s.registry.run(id, &mut s.session, ¶ms) {
|
|
Ok(_) => accepted += 1,
|
|
Err(Error::Panicked(other)) => panicked.push(other),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
}
|
|
std::panic::set_hook(previous);
|
|
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
|
|
assert!(accepted > 0, "at least `grammar.info` answers some fixtures");
|
|
assert_eq!(saved_files(s.config.path()), Vec::<String>::new());
|
|
assert_eq!(settings(s.config.path()).grammars, Vec::new());
|
|
assert_eq!(common::on_disk(s.vault.path(), "n.md"), JSON_NOTE);
|
|
}
|