chat: parsed-URL link policy, cached link ranges, history byte budget (Phase 4)
CI / check (push) Failing after 3m10s

Phase 4 of docs/chat-hardening-plan.md — URL and rendering resilience.
Closes the chat-body half of S14 (bidi override strip).

- sanitize: new is_safe_web_url shared link policy (url crate, promoted to a
  direct dependency): http/https scheme + non-empty host + no userinfo;
  candidates failing it stay plain text (their whole whitespace run, interior
  not re-scanned). Scheme detection is now ASCII-case-insensitive.
- sanitize: linkify() -> link_ranges()/segments(): validated byte ranges
  computed once, exact-roundtrip slicing, at most CHAT_MSG_MAX_LINKS (8)
  clickable links per message; the rest stays selectable plain text.
- sanitize_chat: strips bidi overrides/isolates (U+202A-202E, U+2066-2069)
  from message bodies while keeping ZWJ/ZWNJ/LRM/RLM (S14 chat-body half).
- app: ChatEntry caches its link ranges (filled in push_chat), so redraws
  slice instead of rescanning/re-validating; only link spans allocate.
- app: chat history now also bounded by 512 KiB total sanitized text
  (CHAT_HISTORY_MAX_TEXT_BYTES) alongside the 300-entry cap; the attachment
  byte cache is deliberately untouched by history eviction (own budgets).
- app: AppMessage::OpenUrl re-checks the same parsed policy (defence in
  depth) instead of prefix checks - non-web schemes can never reach the
  opener even if the handler is invoked directly.

571 lib tests green (+3 net); clippy -D warnings + fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 04:57:15 -04:00
co-authored by Claude Fable 5
parent 554b613466
commit 1d038be9a0
5 changed files with 399 additions and 165 deletions
Generated
+1
View File
@@ -4894,6 +4894,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"url",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
+4
View File
@@ -75,6 +75,10 @@ serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
# Chat link policy: parse + validate clickable URL candidates (scheme/host/
# userinfo checks in `sanitize::is_safe_web_url`). Already in the tree
# transitively via iroh — this only promotes it to a direct dependency.
url = "2.5"
# --- Platform-specific dependencies ----------------------------------------- # --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the # Audio and the native file-picker backends differ per OS. Everything else in the
+53 -19
View File
@@ -1,15 +1,18 @@
# Chat hardening — ephemeral implementation plan # Chat hardening — ephemeral implementation plan
**Status (2026-07-18):** Phases 13 COMPLETE. Phase 1 = shared text policy in **Status (2026-07-18):** Phases 14 COMPLETE. Phase 1 = shared text policy in
`src/sanitize.rs`, ceilings enforced at UI input, sign point, and gossip ingress. `src/sanitize.rs`, ceilings enforced at UI input, sign point, and gossip ingress.
Phase 2 = roster-bound authorship (`src/core/chatroster.rs`), replay dedup + rate Phase 2 = roster-bound authorship (`src/core/chatroster.rs`), replay dedup + rate
limits (`ChatIngressGate` in `src/network/gossip.rs`). Phase 3 = attachment limits (`ChatIngressGate` in `src/network/gossip.rs`). Phase 3 = attachment
cache/serve-store budgets, downscaled previews, auto-fetch byte/request budgets cache/serve-store budgets, downscaled previews, auto-fetch byte/request budgets
(`src/core/fetchbudget.rs`), exact transfers, bounded local reads. All gates (`src/core/fetchbudget.rs`), exact transfers, bounded local reads. Phase 4 =
green each phase. Phases 45 not started. This is a temporary scope contract for parsed-URL link policy (`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`,
hardening the existing room chat. Update the checkboxes and decision log as work `url` crate), 8-link cap, cached link ranges in `ChatEntry`, 512 KiB history
lands, then delete this file when the work is complete. Do not add link previews text budget, chat-body bidi-override strip (closes S14). All gates green each
as part of this effort. phase. Phase 5 not started. This is a temporary scope contract for hardening
the existing room chat. Update the checkboxes and decision log as work lands,
then delete this file when the work is complete. Do not add link previews as
part of this effort.
## Goal ## Goal
@@ -257,31 +260,34 @@ unbounded memory, bandwidth, decoder, or task pressure.
**Target:** keep clickable links without making malformed/deceptive input or many **Target:** keep clickable links without making malformed/deceptive input or many
small spans an unnecessary UI/launcher surface. small spans an unnecessary UI/launcher surface.
- [ ] Make `url` a direct dependency (already present transitively) and validate - [x] Make `url` a direct dependency (already present transitively) and validate
link candidates with `url::Url`. link candidates with `url::Url`.
- [ ] A clickable URL must have an `http` or `https` scheme and a valid host. - [x] A clickable URL must have an `http` or `https` scheme and a valid host.
- [ ] Treat URLs containing username/password syntax as plain text, or require an - [x] Treat URLs containing username/password syntax as plain text, or require an
explicit confirmation that shows the parsed destination host. Prefer plain text explicit confirmation that shows the parsed destination host. Prefer plain text
for the first implementation. for the first implementation.
- [ ] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`; - [x] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
replace prefix checks with the shared parsed-URL policy. replace prefix checks with the shared parsed-URL policy.
- [ ] Cap clickable candidates at eight per message. Remaining content stays - [x] Cap clickable candidates at eight per message. Remaining content stays
selectable plain text and must still round-trip exactly. selectable plain text and must still round-trip exactly.
- [ ] Refactor linkification to return borrowed ranges/offsets or cache link ranges - [x] Refactor linkification to return borrowed ranges/offsets or cache link ranges
in `ChatEntry`, avoiding allocation and rescanning on every redraw. in `ChatEntry`, avoiding allocation and rescanning on every redraw.
- [ ] Bound retained history by total sanitized text bytes as well as 300 entries. - [x] Bound retained history by total sanitized text bytes as well as 300 entries.
Eviction must keep attachment bookkeeping coherent and should not invalidate an Eviction must keep attachment bookkeeping coherent and should not invalidate an
open Save/Play operation. open Save/Play operation.
- [ ] Do not add metadata fetching, remote images, Markdown, or link previews. - [x] Do not add metadata fetching, remote images, Markdown, or link previews.
- [x] (Folded in from S14, per the security handoff) Strip bidi
overrides/isolates from the chat BODY in `sanitize_chat`, keeping the other
expressive format characters (ZWJ/ZWNJ/LRM/RLM).
### Phase 4 tests ### Phase 4 tests
- [ ] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query, - [x] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
punctuation, credentials/userinfo, and non-web schemes. punctuation, credentials/userinfo, and non-web schemes.
- [ ] Eight-link boundary and many-link adversarial input. - [x] Eight-link boundary and many-link adversarial input.
- [ ] Segment/range reconstruction exactly reproduces the sanitized message. - [x] Segment/range reconstruction exactly reproduces the sanitized message.
- [ ] Entry-count and total-text-budget history eviction. - [x] Entry-count and total-text-budget history eviction.
- [ ] Opener policy cannot launch a non-web scheme even if called directly. - [x] Opener policy cannot launch a non-web scheme even if called directly.
## Phase 5 — Honest local send status ## Phase 5 — Honest local send status
@@ -484,3 +490,31 @@ The plan is complete when:
explicit length compare; short transfers get the explicit explicit length compare; short transfers get the explicit
`len == declared_size` check. Music fetches ride `fetch_blob`, so they `len == declared_size` check. Music fetches ride `fetch_blob`, so they
inherit exactness for free. inherit exactness for free.
- **2026-07-18 (Phase 4):** The S14 chat-body half (bidi strip) landed here per
the security handoff: `sanitize_chat` strips ONLY bidi overrides/isolates
(U+202A202E, U+20662069) — the characters that can visually reorder a
rendered line — while ZWJ/ZWNJ (emoji sequences, joining scripts) and the
LRM/RLM direction *marks* (which cannot reorder) are kept. Labels/filenames
keep the stricter full-format-strip.
- **2026-07-18 (Phase 4):** A link's href is the exact displayed slice of the
message — validation is parse-only, no normalization on open — so what the
user sees IS the argv the opener receives. Consequence: WHATWG slash
collapsing means `http:///path` parses to host `path` (as in browsers) and is
accepted; the empty-host rejects are `http://` and friends that fail parsing.
- **2026-07-18 (Phase 4):** URLs with userinfo syntax went the plan-preferred
plain-text route (no confirmation dialog). A candidate that fails the policy
leaves its WHOLE whitespace-delimited run as plain text without re-scanning
the interior — `http://a@http://b.com` yields zero links, by design.
- **2026-07-18 (Phase 4):** Scheme detection became ASCII-case-insensitive
(`Http://…` from sentence auto-capitalization now linkifies); the policy
check is unaffected since `url` normalizes scheme/host case during parsing.
- **2026-07-18 (Phase 4):** Cached ranges in `ChatEntry.links`, filled inside
`push_chat` (the single history choke point), were chosen over
borrowed-return-per-redraw: redraws now slice cached char-boundary ranges,
and only link spans allocate (their href String).
- **2026-07-18 (Phase 4):** History byte-budget eviction (512 KiB, alongside
the 300-entry cap) deliberately does NOT touch the attachment byte cache:
that cache is bounded by its own Phase 3 budgets, and leaving it alone means
an open Save/Play on an evicted line keeps its bytes-in-hand (the save
dialog falls back to the generic "download" name). The just-pushed entry is
never evicted; a single message's 8 KiB ceiling cannot exceed the budget.
+114 -17
View File
@@ -319,6 +319,11 @@ struct ChatEntry {
/// `AppState.attachments` keyed by `(author, attachment.id)`; the entry only /// `AppState.attachments` keyed by `(author, attachment.id)`; the entry only
/// holds the descriptor so history stays cheap. /// holds the descriptor so history stays cheap.
attachment: Option<crate::files::ChatAttachment>, attachment: Option<crate::files::ChatAttachment>,
/// Byte ranges of `text`'s validated clickable links, computed ONCE by
/// [`push_chat`] (construction sites leave it empty) so each redraw slices
/// cached ranges instead of rescanning and re-validating every message.
/// At most [`crate::sanitize::CHAT_MSG_MAX_LINKS`] entries.
links: Vec<std::ops::Range<usize>>,
} }
/// Fetch state of a chat attachment's bytes (session-only). Absence from the /// Fetch state of a chat attachment's bytes (session-only). Absence from the
@@ -564,6 +569,12 @@ fn build_preview_handle(bytes: &[u8]) -> Option<PreviewHandle> {
/// Cap on retained chat history so a long call can't grow it without bound. /// Cap on retained chat history so a long call can't grow it without bound.
const CHAT_HISTORY_MAX: usize = 300; const CHAT_HISTORY_MAX: usize = 300;
/// Cap on TOTAL retained sanitized chat text bytes, enforced alongside
/// [`CHAT_HISTORY_MAX`] (300 ceiling-length messages would otherwise retain
/// ~2.4 MiB and make every redraw/selection walk it). The entry cap bounds the
/// common case; this bounds a max-length-message flood (chat-hardening Phase 4).
const CHAT_HISTORY_MAX_TEXT_BYTES: usize = 512 * 1024;
/// Which room-screen divider a drag is resizing. /// Which room-screen divider a drag is resizing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DividerKind { pub enum DividerKind {
@@ -2157,6 +2168,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
mine: false, mine: false,
from: Some(from), from: Some(from),
attachment, attachment,
links: Vec::new(),
}, },
); );
} }
@@ -3082,6 +3094,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
mine: true, mine: true,
from: Some(state.self_id.clone()), from: Some(state.self_id.clone()),
attachment: None, attachment: None,
links: Vec::new(),
}, },
); );
let _ = state.controller.send(CoreCommand::SendChat(text)); let _ = state.controller.send(CoreCommand::SendChat(text));
@@ -3166,6 +3179,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
mine: true, mine: true,
from: Some(state.self_id.clone()), from: Some(state.self_id.clone()),
attachment: Some(att.clone()), attachment: Some(att.clone()),
links: Vec::new(),
}, },
); );
let _ = state.controller.send(CoreCommand::SendChatFile { let _ = state.controller.send(CoreCommand::SendChatFile {
@@ -3483,16 +3497,19 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
} }
} }
AppMessage::OpenUrl(url) => { AppMessage::OpenUrl(url) => {
// Defence in depth: only ever hand http(s) URLs to the opener. The // Defence in depth: only ever hand policy-clean web URLs to the
// link span's href came from `linkify`, which only emits http/https, // opener. The link span's href came from `link_ranges`, which only
// but re-check here so this can't be widened into launching arbitrary // emits candidates passing `is_safe_web_url`, but re-check the SAME
// schemes/args. Each opener receives the URL as a single argv entry // parsed policy here (http/https scheme + real host + no userinfo)
// (no shell), so there's no injection surface: // so this handler can't be widened into launching arbitrary
// schemes/args even if called directly. Each opener receives the
// URL as a single argv entry (no shell), so there's no injection
// surface:
// - Unix: `xdg-open <url>`. // - Unix: `xdg-open <url>`.
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the // - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
// default browser without going through `cmd`/`start`, which would // default browser without going through `cmd`/`start`, which would
// otherwise re-parse `&` in query strings. // otherwise re-parse `&` in query strings.
if url.starts_with("http://") || url.starts_with("https://") { if crate::sanitize::is_safe_web_url(&url) {
let spawned = { let spawned = {
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -3798,14 +3815,27 @@ fn short_id(id: &str) -> String {
id.chars().take(8).collect() id.chars().take(8).collect()
} }
/// Append a chat line, trimming the oldest once history exceeds the cap so a long /// Append a chat line, computing its cached link ranges, then trimming the
/// call can't grow the buffer without bound. /// oldest entries while history exceeds EITHER the entry cap or the total
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) { /// text-byte budget so a long call (or a max-length-message flood) can't grow
/// the buffer without bound. The just-pushed entry is never evicted (a single
/// message's 8 KiB ceiling sits far below the byte budget). Eviction only drops
/// `ChatEntry` values: the attachment byte cache in `AppState.attachments` is
/// bounded by its own budgets and is deliberately NOT touched here, so an open
/// Save/Play on an evicted line keeps its bytes-in-hand (the save dialog then
/// falls back to the "download" default name).
fn push_chat(messages: &mut Vec<ChatEntry>, mut entry: ChatEntry) {
entry.links = crate::sanitize::link_ranges(&entry.text);
messages.push(entry); messages.push(entry);
if messages.len() > CHAT_HISTORY_MAX { let mut total: usize = messages.iter().map(|m| m.text.len()).sum();
let overflow = messages.len() - CHAT_HISTORY_MAX; let mut overflow = 0;
messages.drain(..overflow); while messages.len() - overflow > CHAT_HISTORY_MAX
|| (total > CHAT_HISTORY_MAX_TEXT_BYTES && messages.len() - overflow > 1)
{
total -= messages[overflow].text.len();
overflow += 1;
} }
messages.drain(..overflow);
} }
/// Pick the default save-dialog filename for an attachment, matched by the FULL /// Pick the default save-dialog filename for an attachment, matched by the FULL
@@ -7275,12 +7305,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let name_color = if m.mine { color_green } else { color_lavender }; let name_color = if m.mine { color_green } else { color_lavender };
// Split the (already-sanitized) message into text + URL spans so // Split the (already-sanitized) message into text + URL spans so
// links render clickable and open in the system browser (A13). // links render clickable and open in the system browser (A13).
let spans: Vec<_> = crate::sanitize::linkify(&m.text) // The link ranges were validated + cached at push time, so this
// only slices borrowed text — no rescanning per redraw.
let spans: Vec<_> = crate::sanitize::segments(&m.text, &m.links)
.into_iter() .into_iter()
.map(|seg| match seg { .map(|(piece, is_link)| {
crate::sanitize::Segment::Text(t) => span(t).size(13).color(color_text), if is_link {
crate::sanitize::Segment::Link(u) => { span(piece)
span(u.clone()).size(13).color(color_blue).link(u) .size(13)
.color(color_blue)
.link(piece.to_string())
} else {
span(piece).size(13).color(color_text)
} }
}) })
.collect(); .collect();
@@ -9479,6 +9515,7 @@ mod tests {
kind: crate::files::AttachmentKind::File, kind: crate::files::AttachmentKind::File,
id: shared_id, id: shared_id,
}), }),
links: Vec::new(),
}; };
// Attacker's line is FIRST in history, so a bare-id scan would pick it. // Attacker's line is FIRST in history, so a bare-id scan would pick it.
let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")]; let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")];
@@ -9525,6 +9562,7 @@ mod tests {
mine: false, mine: false,
from: Some(peer.to_string()), from: Some(peer.to_string()),
attachment: None, attachment: None,
links: Vec::new(),
}); });
state.chat_input = "draft".to_string(); state.chat_input = "draft".to_string();
let att_key = (peer, attachment_id); let att_key = (peer, attachment_id);
@@ -10433,6 +10471,7 @@ mod tests {
mine: true, mine: true,
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(),
}; };
push_chat(&mut messages, entry); push_chat(&mut messages, entry);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
@@ -10454,6 +10493,7 @@ mod tests {
mine: i % 2 == 0, mine: i % 2 == 0,
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(),
}, },
); );
} }
@@ -10484,6 +10524,7 @@ mod tests {
mine: i % 2 == 0, mine: i % 2 == 0,
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(),
}, },
); );
} }
@@ -10501,4 +10542,60 @@ mod tests {
format!("Msg{}", total_pushes - 1) format!("Msg{}", total_pushes - 1)
); );
} }
#[test]
fn test_push_chat_evicts_on_total_text_bytes() {
use super::{CHAT_HISTORY_MAX_TEXT_BYTES, ChatEntry, push_chat};
// Ceiling-length messages (8 KiB each) hit the 512 KiB byte budget at 64
// entries — far below the 300-entry cap.
let big = "x".repeat(crate::sanitize::CHAT_MSG_MAX_BYTES);
let mut messages = Vec::new();
for i in 0..80 {
push_chat(
&mut messages,
ChatEntry {
name: format!("User{}", i),
text: big.clone(),
mine: false,
from: None,
attachment: None,
links: Vec::new(),
},
);
let total: usize = messages.iter().map(|m| m.text.len()).sum();
assert!(total <= CHAT_HISTORY_MAX_TEXT_BYTES, "over budget at {i}");
}
assert_eq!(
messages.len(),
CHAT_HISTORY_MAX_TEXT_BYTES / crate::sanitize::CHAT_MSG_MAX_BYTES
);
// Oldest evicted, newest kept.
assert_eq!(messages[0].name, "User16");
assert_eq!(messages[messages.len() - 1].name, "User79");
}
#[test]
fn test_push_chat_caches_validated_link_ranges() {
use super::{ChatEntry, push_chat};
let mut messages = Vec::new();
push_chat(
&mut messages,
ChatEntry {
name: "Alice".to_string(),
text: "see https://ok.org and javascript:alert(1) too".to_string(),
mine: false,
from: None,
attachment: None,
links: Vec::new(),
},
);
// Ranges are filled at push time (the construction-site value is
// ignored) and slicing them yields exactly the validated link.
let m = &messages[0];
assert_eq!(m.links.len(), 1);
assert_eq!(&m.text[m.links[0].clone()], "https://ok.org");
let pieces = crate::sanitize::segments(&m.text, &m.links);
let rebuilt: String = pieces.iter().map(|(s, _)| *s).collect();
assert_eq!(rebuilt, m.text);
}
} }
+227 -129
View File
@@ -12,6 +12,18 @@
/// the roster readable. /// the roster readable.
pub const NAME_MAX_CHARS: usize = 48; pub const NAME_MAX_CHARS: usize = 48;
/// Bidirectional override/isolate format characters (`General_Category=Cf`, NOT
/// caught by [`char::is_control`]) that can visually reorder surrounding text.
/// Stripped even from expressive chat bodies (security finding S14): unlike the
/// benign zero-width joiners/marks, these let a sender make rendered text read
/// differently from what was actually sent.
pub(crate) fn is_bidi_override_char(c: char) -> bool {
matches!(c,
'\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
)
}
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a /// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
/// rendered name even though they are NOT caught by [`char::is_control`]: /// rendered name even though they are NOT caught by [`char::is_control`]:
/// bidirectional overrides/isolates (text-direction spoofing) and /// bidirectional overrides/isolates (text-direction spoofing) and
@@ -19,13 +31,12 @@ pub const NAME_MAX_CHARS: usize = 48;
/// explicitly so the sanitizer stays dependency-free (std exposes no category /// explicitly so the sanitizer stays dependency-free (std exposes no category
/// query). Stripped outright rather than replaced. /// query). Stripped outright rather than replaced.
pub(crate) fn is_spoofing_format_char(c: char) -> bool { pub(crate) fn is_spoofing_format_char(c: char) -> bool {
matches!(c, is_bidi_override_char(c)
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM || matches!(c,
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides) '\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus | '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates) | '\u{FEFF}' // BOM / zero-width no-break space
| '\u{FEFF}' // BOM / zero-width no-break space )
)
} }
/// Max characters kept for a broadcast game-presence label after sanitizing /// Max characters kept for a broadcast game-presence label after sanitizing
@@ -86,18 +97,24 @@ pub const CHAT_MSG_MAX_BYTES: usize = 8 * 1024;
/// Sanitize a chat message body, applied to BOTH our outgoing text (before local /// Sanitize a chat message body, applied to BOTH our outgoing text (before local
/// echo, and again at the gossip sign point) and incoming peer text (untrusted — /// echo, and again at the gossip sign point) and incoming peer text (untrusted —
/// a buggy/malicious sender could include control characters or an enormous /// a buggy/malicious sender could include control characters or an enormous
/// payload). Single pass: control characters become spaces, any whitespace run /// payload). Single pass: bidi overrides/isolates are stripped outright (S14 —
/// collapses to a single space, the ends are trimmed, and both the character and /// they can visually reorder the rendered line), control characters become
/// UTF-8 byte ceilings are enforced without ever splitting a scalar. Message /// spaces, any whitespace run collapses to a single space, the ends are trimmed,
/// bodies deliberately keep Unicode format characters (ZWJ/ZWNJ etc.) that the /// and both the character and UTF-8 byte ceilings are enforced without ever
/// short-label sanitizers strip — chat is expressive text, not a label. Returns /// splitting a scalar. Message bodies deliberately keep the OTHER format
/// `""` for input with no visible text (callers drop empty messages). Idempotent, /// characters (ZWJ/ZWNJ/LRM/RLM etc.) that the short-label sanitizers strip —
/// so layered application converges on the same result. /// chat is expressive text, not a label, and those are needed for emoji
/// sequences and joining scripts. Returns `""` for input with no visible text
/// (callers drop empty messages). Idempotent, so layered application converges
/// on the same result.
pub fn sanitize_chat(input: &str) -> String { pub fn sanitize_chat(input: &str) -> String {
let mut out = String::new(); let mut out = String::new();
let mut chars = 0usize; let mut chars = 0usize;
let mut pending_space = false; let mut pending_space = false;
for c in input.chars() { for c in input.chars() {
if is_bidi_override_char(c) {
continue;
}
let c = if c.is_control() { ' ' } else { c }; let c = if c.is_control() { ' ' } else { c };
if c.is_whitespace() { if c.is_whitespace() {
// Trim: only mark a separator once visible text exists; a trailing // Trim: only mark a separator once visible text exists; a trailing
@@ -155,14 +172,10 @@ pub fn admit_chat_text(raw: &str, has_attachment: bool) -> Option<String> {
(!text.is_empty() || has_attachment).then_some(text) (!text.is_empty() || has_attachment).then_some(text)
} }
/// A piece of a chat message after URL detection: literal text or a link. /// Max clickable links rendered per chat message. Later URL candidates stay
#[derive(Debug, PartialEq, Eq, Clone)] /// selectable plain text — bounds both the span count a message can force the
pub enum Segment { /// renderer to build and the opener targets one line can carry.
/// Plain text to render as-is. pub const CHAT_MSG_MAX_LINKS: usize = 8;
Text(String),
/// A detected URL to render as a clickable link (also its href).
Link(String),
}
/// Trailing characters commonly adjacent to a URL in prose that should NOT be /// Trailing characters commonly adjacent to a URL in prose that should NOT be
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly). /// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
@@ -173,42 +186,85 @@ fn is_url_trailing_punct(c: char) -> bool {
) )
} }
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`, /// Find the byte index of the earliest `http://` or `https://` scheme in `s`
/// (ASCII-case-insensitive, so a sentence-capitalized "Http://…" still counts),
/// scanning only on char boundaries so slicing is always safe. /// scanning only on char boundaries so slicing is always safe.
fn find_scheme(s: &str) -> Option<usize> { fn find_scheme(s: &str) -> Option<usize> {
s.char_indices().find_map(|(i, _)| { s.char_indices().find_map(|(i, _)| {
let tail = &s[i..]; let tail = &s[i..];
(tail.starts_with("http://") || tail.starts_with("https://")).then_some(i) let matches_prefix = |p: &str| {
tail.get(..p.len())
.is_some_and(|t| t.eq_ignore_ascii_case(p))
};
(matches_prefix("http://") || matches_prefix("https://")).then_some(i)
}) })
} }
/// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s /// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs /// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
/// are treated as links, each ending at the first whitespace, with trailing prose /// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
/// punctuation peeled back into the following text. Concatenating every segment's /// username/password syntax (`http://user@host` reads as a credential but is a
/// inner string reproduces the input exactly (no characters added or dropped), so /// classic destination-spoof — such text stays plain, never clickable).
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format pub fn is_safe_web_url(s: &str) -> bool {
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable. let Ok(u) = url::Url::parse(s) else {
pub fn linkify(input: &str) -> Vec<Segment> { return false;
};
matches!(u.scheme(), "http" | "https")
&& u.host_str().is_some_and(|h| !h.is_empty())
&& u.username().is_empty()
&& u.password().is_none()
}
/// Detect clickable links in an (already chat-sanitized) message, returning the
/// byte range of each — computed ONCE when a message enters history and cached
/// on its entry, so redraws slice instead of rescanning. **Conservative on
/// purpose:** only `http://` / `https://` runs count, each ending at the first
/// whitespace with trailing prose punctuation peeled off, and only candidates
/// passing [`is_safe_web_url`] become links — a failing candidate's whole
/// whitespace-delimited run stays plain text (its interior is not re-scanned).
/// At most [`CHAT_MSG_MAX_LINKS`] ranges; ranges are ascending, non-overlapping,
/// and always on char boundaries. The href is exactly the displayed slice, so
/// what the user sees IS what the opener receives.
pub fn link_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut rest = input; let mut base = 0usize;
while !rest.is_empty() { while out.len() < CHAT_MSG_MAX_LINKS {
let Some(start) = find_scheme(rest) else { let Some(start) = find_scheme(&text[base..]) else {
out.push(Segment::Text(rest.to_string()));
break; break;
}; };
if start > 0 { let run_start = base + start;
out.push(Segment::Text(rest[..start].to_string())); let run = &text[run_start..];
let run_end = run.find(char::is_whitespace).unwrap_or(run.len());
// Peel trailing punctuation back out of the candidate; a run is at least
// the 7-byte scheme long, so `base` always advances.
let candidate = run[..run_end].trim_end_matches(is_url_trailing_punct);
if is_safe_web_url(candidate) {
out.push(run_start..run_start + candidate.len());
base = run_start + candidate.len();
} else {
base = run_start + run_end;
} }
let after = &rest[start..]; }
let end = after.find(char::is_whitespace).unwrap_or(after.len()); out
let candidate = &after[..end]; }
// Peel trailing punctuation back out of the link.
let url = candidate.trim_end_matches(is_url_trailing_punct); /// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
out.push(Segment::Link(url.to_string())); /// output. Concatenating the slices reproduces `text` exactly (purely a
// Continue past just the URL; any peeled punctuation + the rest (incl. the /// presentational split — no characters added or dropped). Borrows, so a redraw
// whitespace) is reconsidered as ordinary text on the next iteration. /// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
rest = &after[url.len()..]; /// this same `text` (ascending, non-overlapping, char-boundary ranges).
pub fn segments<'a>(text: &'a str, ranges: &[std::ops::Range<usize>]) -> Vec<(&'a str, bool)> {
let mut out = Vec::new();
let mut pos = 0usize;
for r in ranges {
if r.start > pos {
out.push((&text[pos..r.start], false));
}
out.push((&text[r.clone()], true));
pos = r.end;
}
if pos < text.len() {
out.push((&text[pos..], false));
} }
out out
} }
@@ -416,93 +472,80 @@ mod tests {
); );
} }
// --- linkify ----------------------------------------------------------- #[test]
fn chat_strips_bidi_overrides_but_keeps_benign_format_chars() {
// Overrides and isolates are removed outright (S14) …
assert_eq!(sanitize_chat("pay \u{202E}gpj.exe now"), "pay gpj.exe now");
assert_eq!(sanitize_chat("a\u{2066}b\u{2069}c"), "abc");
assert_eq!(
sanitize_chat("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
""
);
// … while the expressive format characters chat promises to keep — ZWJ
// (emoji sequences), ZWNJ (joining scripts), LRM/RLM (bidi *marks*, which
// cannot reorder text) — survive.
for kept in ['\u{200D}', '\u{200C}', '\u{200E}', '\u{200F}'] {
let msg = format!("a{kept}b");
assert_eq!(sanitize_chat(&msg), msg, "stripped benign {kept:?}");
}
}
/// Concatenating every segment's inner text must reproduce the input exactly. // --- link policy ---------------------------------------------------------
fn reassemble(segs: &[Segment]) -> String {
segs.iter() /// Concatenating every segment's slice must reproduce the input exactly.
.map(|s| match s { fn reassemble(text: &str) -> String {
Segment::Text(t) | Segment::Link(t) => t.as_str(), segments(text, &link_ranges(text))
}) .iter()
.map(|(s, _)| *s)
.collect()
}
/// The link slices of a message, in order.
fn links(text: &str) -> Vec<&str> {
segments(text, &link_ranges(text))
.into_iter()
.filter_map(|(s, is_link)| is_link.then_some(s))
.collect() .collect()
} }
#[test] #[test]
fn linkify_plain_text_has_no_links() { fn url_policy_accepts_only_wellformed_web_urls() {
let segs = linkify("just a normal message, nothing here"); for ok in [
assert_eq!( "http://example.com",
segs, "https://a.test/path?q=1&w=2",
vec![Segment::Text("just a normal message, nothing here".into())] "HTTP://EXAMPLE.COM", // mixed case scheme+host
); "https://x.com:8443/p", // explicit port
"https://d.com/路径?q=世界#frag", // unicode path/query/fragment
// WHATWG parsing (what browsers do) collapses the extra slash into
// host "path" — a valid, if odd, destination; not an empty host.
"http:///path",
] {
assert!(is_safe_web_url(ok), "rejected {ok:?}");
}
for bad in [
"",
"example.com", // no scheme
"http://", // empty host
"ftp://x.com", // non-web scheme
"file:///etc/passwd", // no host, wrong scheme
"javascript:alert(1)", // opener must never see this
"http://user@good.com", // userinfo → destination spoof risk
"http://user:pw@good.com", // credentials
"http://exa mple.com", // malformed host
] {
assert!(!is_safe_web_url(bad), "accepted {bad:?}");
}
} }
#[test] #[test]
fn linkify_detects_http_and_https() { fn link_ranges_detects_http_and_https_with_exact_roundtrip() {
assert_eq!(links("see http://example.com now"), ["http://example.com"]);
assert_eq!( assert_eq!(
linkify("see http://example.com now"), links("a http://one.com b https://two.com c"),
vec![ ["http://one.com", "https://two.com"]
Segment::Text("see ".into()),
Segment::Link("http://example.com".into()),
Segment::Text(" now".into()),
]
); );
assert_eq!( // Sentence-capitalized scheme still detected; href = the displayed slice.
linkify("https://a.test/path?q=1"), assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
vec![Segment::Link("https://a.test/path?q=1".into())]
);
}
#[test]
fn linkify_peels_trailing_punctuation() {
// Sentence-final period is not part of the link.
assert_eq!(
linkify("go to https://x.com."),
vec![
Segment::Text("go to ".into()),
Segment::Link("https://x.com".into()),
Segment::Text(".".into()),
]
);
// Parenthesized URL.
assert_eq!(
linkify("(https://x.com)"),
vec![
Segment::Text("(".into()),
Segment::Link("https://x.com".into()),
Segment::Text(")".into()),
]
);
}
#[test]
fn linkify_handles_multiple_urls() {
let segs = linkify("a http://one.com b https://two.com c");
assert_eq!(
segs,
vec![
Segment::Text("a ".into()),
Segment::Link("http://one.com".into()),
Segment::Text(" b ".into()),
Segment::Link("https://two.com".into()),
Segment::Text(" c".into()),
]
);
}
#[test]
fn linkify_only_matches_http_schemes() {
// Non-web schemes and bare domains are NOT linkified (conservative).
let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
assert_eq!(
segs,
vec![Segment::Text(
"email me@x.com or ftp://x.com or visit x.com".into()
)]
);
}
#[test]
fn linkify_preserves_input_exactly() {
for msg in [ for msg in [
"", "",
"no urls at all", "no urls at all",
@@ -510,12 +553,67 @@ mod tests {
"pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!", "pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
"weird))) http://c.com]]] tail", "weird))) http://c.com]]] tail",
"unicode 世界 http://d.com/路径 more 世界", "unicode 世界 http://d.com/路径 more 世界",
"bad http:// and http://user@x.com around https://ok.org here",
] { ] {
assert_eq!( assert_eq!(reassemble(msg), msg, "roundtrip failed for {msg:?}");
reassemble(&linkify(msg)),
msg,
"roundtrip failed for {msg:?}"
);
} }
} }
#[test]
fn link_ranges_peels_trailing_punctuation() {
assert_eq!(links("go to https://x.com."), ["https://x.com"]);
assert_eq!(links("(https://x.com)"), ["https://x.com"]);
}
#[test]
fn link_ranges_leaves_invalid_candidates_as_plain_text() {
// Non-web schemes and bare domains never linkify (conservative).
assert_eq!(
links("email me@x.com or ftp://x.com or visit x.com"),
[] as [&str; 0]
);
// A malformed/deceptive candidate stays text WITHOUT eating a later
// valid link.
assert_eq!(links("http:// then https://ok.org"), ["https://ok.org"]);
assert_eq!(
links("http://user:pw@evil.com vs https://good.com"),
["https://good.com"]
);
// An invalid run's interior is not re-scanned for nested schemes.
assert_eq!(links("http://a@http://b.com"), [] as [&str; 0]);
}
#[test]
fn link_ranges_caps_clickable_links_per_message() {
let many = (0..CHAT_MSG_MAX_LINKS + 4)
.map(|i| format!("https://site{i}.test"))
.collect::<Vec<_>>()
.join(" ");
let ranges = link_ranges(&many);
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
// The 9th+ URLs remain, but as plain selectable text.
assert_eq!(reassemble(&many), many);
let l = links(&many);
assert_eq!(l.last(), Some(&"https://site7.test"));
// Exactly at the cap: all clickable.
let at_cap = (0..CHAT_MSG_MAX_LINKS)
.map(|i| format!("https://site{i}.test"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(link_ranges(&at_cap).len(), CHAT_MSG_MAX_LINKS);
}
#[test]
fn link_ranges_survives_adversarial_many_link_input() {
// A ceiling-length message packed with minimal URLs: bounded output,
// exact reconstruction, and every range on char boundaries.
let flood = "http://a.io ".repeat(CHAT_MSG_MAX_BYTES / 12 + 1);
let msg = sanitize_chat(&flood);
let ranges = link_ranges(&msg);
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
for r in &ranges {
assert!(msg.is_char_boundary(r.start) && msg.is_char_boundary(r.end));
}
assert_eq!(reassemble(&msg), msg);
}
} }