From 375001983c0f106e8b50ab53ec7b9f9ccbd82727 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:07:07 +0000 Subject: [PATCH] Strip bidi control characters from names and report reasons Fixes #1126 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/store.rs | 14 ++++++++++++-- apps/figcraft-server/src/store_tests.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..1b9b93e3 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -291,7 +291,17 @@ fn valid_id(s: &str) -> bool { /// A name others may see: trimmed, no control characters, at most 256 characters. fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(256).collect() + s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect() +} + +/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters. +fn clean_reason(s: &str) -> String { + s.trim().chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).take(MAX_REASON).collect() +} + +/// Bidirectional format characters, which could reorder what a terminal shows. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } /// Take the data directory's lock: `None` when someone else holds it. @@ -748,7 +758,7 @@ impl Store { /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect(); + let reason = clean_reason(reason); if reason.is_empty() { return Err("say what is wrong with the file".into()); } diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..52aeb23c 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -313,3 +313,20 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +const BIDI: [char; 12] = + ['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}']; + +#[test] +fn names_and_report_reasons_lose_bidi_controls() { + let dir = TempDir::new("bidi"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for (n, m) in BIDI.into_iter().enumerate() { + let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap(); + let (id, _, _) = publish(&mut store, &format!("a{m}b")); + assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32); + store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap(); + assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32); + store.remove(&id).unwrap(); + } +}