Merge PR #3511: fix(engine): validate bookmark names by character count and character classes

This commit is contained in:
aiongg
2026-10-22 01:19:56 +00:00
+45 -2
View File
@@ -467,9 +467,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 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 {
!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() || 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.
@@ -1185,6 +1187,47 @@ 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 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:
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: 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": "_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());
}
/// 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() {