Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
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>
2026-10-19 11:21:19 -03:00

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, &params).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, &params) {
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);
}