From 05827d20fc1be7d3106d9e652cd663d7754621c4 Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Thu, 22 Oct 2026 00:12:31 +0530 Subject: [PATCH 1/2] fix(engine): validate bookmark names by character count and character classes In bookmark_name_ok, validate length by Unicode character count (at most 40 characters) rather than byte count, require an initial alphabetic letter, and restrict characters to alphanumeric and underscore. Closes #3491 --- crates/engine/src/cmd/insert.rs | 45 +++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index cf59390ad..6793c3458 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -456,9 +456,11 @@ fn link(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// Whether `name` can name a bookmark: no spaces, at most 40 characters, not starting with a digit. +/// Whether `name` can name a bookmark: starts with a letter, contains only alphanumeric characters +/// or underscores, and is at most 40 characters (#3491). pub fn bookmark_name_ok(name: &str) -> bool { - !name.is_empty() && !name.contains(' ') && name.len() <= 40 && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) + let mut chars = name.chars(); + chars.next().is_some_and(|c| c.is_alphabetic()) && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40 } /// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark. @@ -1173,6 +1175,45 @@ mod tests { assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta")); } + /// #3491: Bookmark names are validated by Unicode character count and character classes. + #[test] + fn bookmark_name_validation_rules() { + // Valid names: + assert!(bookmark_name_ok("Mark1")); + assert!(bookmark_name_ok("A")); + assert!(bookmark_name_ok("Alpha_Beta_123")); + // Valid 27-character Unicode Cyrillic name (51 UTF-8 bytes): + assert!(bookmark_name_ok("быстрый_доступ_к_документам")); + // Valid 40-character name: + let exactly_40 = "a".repeat(40); + assert!(bookmark_name_ok(&exactly_40)); + let exactly_40_unicode = "я".repeat(40); + assert!(bookmark_name_ok(&exactly_40_unicode)); + + // Invalid: empty + assert!(!bookmark_name_ok("")); + // Invalid: leading digit + assert!(!bookmark_name_ok("1mark")); + // Invalid: leading underscore + assert!(!bookmark_name_ok("_foo")); + // Invalid: symbols / punctuation + assert!(!bookmark_name_ok("a&b")); + assert!(!bookmark_name_ok("a!b")); + assert!(!bookmark_name_ok("a-b")); + assert!(!bookmark_name_ok("a.b")); + assert!(!bookmark_name_ok("foo bar")); + // Invalid: more than 40 characters + assert!(!bookmark_name_ok(&"a".repeat(41))); + assert!(!bookmark_name_ok(&"я".repeat(41))); + + // Verification through `insert.bookmark` command + let mut s = Session::new(wordcraft_doc::Document::from_text("Test content")); + assert!(s.run("insert.bookmark", &json!({"name": "быстрый_доступ_к_документам"})).is_ok()); + assert!(s.run("insert.bookmark", &json!({"name": "_foo"})).is_err()); + assert!(s.run("insert.bookmark", &json!({"name": "a&b"})).is_err()); + assert!(s.run("insert.bookmark", &json!({"name": "a!b"})).is_err()); + } + /// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after. #[test] fn a_mixed_symbol_string_keeps_symbols_and_text_apart() { From 4f924ca5186cbaa38dc3d91d5f2fb19eaf26e08c Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Thu, 22 Oct 2026 01:49:33 +0530 Subject: [PATCH 2/2] fix(engine): allow leading underscore for hidden bookmarks in name validation --- crates/engine/src/cmd/insert.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 6793c3458..0dcd6ed3c 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -456,11 +456,11 @@ fn link(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// Whether `name` can name a bookmark: starts with a letter, contains only alphanumeric characters -/// or underscores, and is at most 40 characters (#3491). +/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters +/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref). pub fn bookmark_name_ok(name: &str) -> bool { let mut chars = name.chars(); - chars.next().is_some_and(|c| c.is_alphabetic()) && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40 + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40 } /// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark. @@ -1182,6 +1182,9 @@ mod tests { assert!(bookmark_name_ok("Mark1")); assert!(bookmark_name_ok("A")); assert!(bookmark_name_ok("Alpha_Beta_123")); + // Valid leading underscore (Word hidden bookmarks, e.g. _Toc, _Ref, _Hidden): + assert!(bookmark_name_ok("_Hidden")); + assert!(bookmark_name_ok("_foo")); // Valid 27-character Unicode Cyrillic name (51 UTF-8 bytes): assert!(bookmark_name_ok("быстрый_доступ_к_документам")); // Valid 40-character name: @@ -1194,8 +1197,6 @@ mod tests { assert!(!bookmark_name_ok("")); // Invalid: leading digit assert!(!bookmark_name_ok("1mark")); - // Invalid: leading underscore - assert!(!bookmark_name_ok("_foo")); // Invalid: symbols / punctuation assert!(!bookmark_name_ok("a&b")); assert!(!bookmark_name_ok("a!b")); @@ -1209,7 +1210,8 @@ mod tests { // Verification through `insert.bookmark` command let mut s = Session::new(wordcraft_doc::Document::from_text("Test content")); assert!(s.run("insert.bookmark", &json!({"name": "быстрый_доступ_к_документам"})).is_ok()); - assert!(s.run("insert.bookmark", &json!({"name": "_foo"})).is_err()); + assert!(s.run("insert.bookmark", &json!({"name": "_Hidden"})).is_ok()); + assert!(s.run("insert.bookmark", &json!({"name": "1mark"})).is_err()); assert!(s.run("insert.bookmark", &json!({"name": "a&b"})).is_err()); assert!(s.run("insert.bookmark", &json!({"name": "a!b"})).is_err()); }