diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs index 82c4927f62..2a06d912f4 100644 --- a/crates/automation/src/bookmark_io.rs +++ b/crates/automation/src/bookmark_io.rs @@ -12,6 +12,9 @@ use serde_json::{Value, json}; /// Deepest nesting a bookmark tree may have (the listing reaches 33 levels). const MAX_DEPTH: usize = 33; +/// The keys a node may have: the ones [`node_json`] writes. +const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"]; + impl Automation { pub(crate) fn bookmark_export(&self, a: &Args) -> Result { let id = self.doc(a)?.id; @@ -134,6 +137,9 @@ fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result Result { let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?; + if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) { + return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", ")))); + } let field = |k: &str| o.get(k).filter(|x| !x.is_null()); // An empty title is kept: files have them, and export writes them as "". let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b25a653a83..553f79b12d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -336,7 +336,7 @@ pub fn tools() -> Vec { t( "bookmark_import", "Import bookmarks", - "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.", + "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing; a key a node does not have is refused. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.", ) .destructive() .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 4a36e3630a..2d6c92fdc1 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10742,6 +10742,20 @@ fn odd_bookmarks_round_trip_and_a_cut_tree_says_so() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn a_misspelt_bookmark_key_is_refused_not_dropped() { + let dir = workdir("bookmark-io-key"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let tree = json!([{ "title": "Parent", "children": [{ "title": "Red", "page": 1, "colour": [1, 0, 0] }] }]); + match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": tree })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("[1, 1]") && m.contains("\"colour\""), "{m}"), + other => panic!("expected InvalidArgs, got {other:?}"), + } + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { let dir = workdir("bookmark-io-bad");