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",
"tokio",
"tokio-stream",
"url",
"windows-sys 0.61.2",
]
+4
View File
@@ -75,6 +75,10 @@ serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
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 -----------------------------------------
# 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
**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.
Phase 2 = roster-bound authorship (`src/core/chatroster.rs`), replay dedup + rate
limits (`ChatIngressGate` in `src/network/gossip.rs`). Phase 3 = attachment
cache/serve-store budgets, downscaled previews, auto-fetch byte/request budgets
(`src/core/fetchbudget.rs`), exact transfers, bounded local reads. All gates
green each phase. Phases 45 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.
(`src/core/fetchbudget.rs`), exact transfers, bounded local reads. Phase 4 =
parsed-URL link policy (`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`,
`url` crate), 8-link cap, cached link ranges in `ChatEntry`, 512 KiB history
text budget, chat-body bidi-override strip (closes S14). All gates green each
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
@@ -257,31 +260,34 @@ unbounded memory, bandwidth, decoder, or task pressure.
**Target:** keep clickable links without making malformed/deceptive input or many
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`.
- [ ] 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] A clickable URL must have an `http` or `https` scheme and a valid host.
- [x] Treat URLs containing username/password syntax as plain text, or require an
explicit confirmation that shows the parsed destination host. Prefer plain text
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.
- [ ] 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.
- [ ] 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.
- [ ] 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
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
- [ ] 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.
- [ ] Eight-link boundary and many-link adversarial input.
- [ ] Segment/range reconstruction exactly reproduces the sanitized message.
- [ ] Entry-count and total-text-budget history eviction.
- [ ] Opener policy cannot launch a non-web scheme even if called directly.
- [x] Eight-link boundary and many-link adversarial input.
- [x] Segment/range reconstruction exactly reproduces the sanitized message.
- [x] Entry-count and total-text-budget history eviction.
- [x] Opener policy cannot launch a non-web scheme even if called directly.
## Phase 5 — Honest local send status
@@ -484,3 +490,31 @@ The plan is complete when:
explicit length compare; short transfers get the explicit
`len == declared_size` check. Music fetches ride `fetch_blob`, so they
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
/// holds the descriptor so history stays cheap.
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
@@ -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.
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DividerKind {
@@ -2157,6 +2168,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
mine: false,
from: Some(from),
attachment,
links: Vec::new(),
},
);
}
@@ -3082,6 +3094,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
mine: true,
from: Some(state.self_id.clone()),
attachment: None,
links: Vec::new(),
},
);
let _ = state.controller.send(CoreCommand::SendChat(text));
@@ -3166,6 +3179,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
mine: true,
from: Some(state.self_id.clone()),
attachment: Some(att.clone()),
links: Vec::new(),
},
);
let _ = state.controller.send(CoreCommand::SendChatFile {
@@ -3483,16 +3497,19 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
AppMessage::OpenUrl(url) => {
// Defence in depth: only ever hand http(s) URLs to the opener. The
// link span's href came from `linkify`, which only emits http/https,
// but re-check here so this can't be widened into launching arbitrary
// schemes/args. Each opener receives the URL as a single argv entry
// (no shell), so there's no injection surface:
// Defence in depth: only ever hand policy-clean web URLs to the
// opener. The link span's href came from `link_ranges`, which only
// emits candidates passing `is_safe_web_url`, but re-check the SAME
// parsed policy here (http/https scheme + real host + no userinfo)
// 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>`.
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
// default browser without going through `cmd`/`start`, which would
// 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 = {
#[cfg(unix)]
{
@@ -3798,14 +3815,27 @@ fn short_id(id: &str) -> String {
id.chars().take(8).collect()
}
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
/// call can't grow the buffer without bound.
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
/// Append a chat line, computing its cached link ranges, then trimming the
/// oldest entries while history exceeds EITHER the entry cap or the total
/// 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);
if messages.len() > CHAT_HISTORY_MAX {
let overflow = messages.len() - CHAT_HISTORY_MAX;
messages.drain(..overflow);
let mut total: usize = messages.iter().map(|m| m.text.len()).sum();
let mut overflow = 0;
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
@@ -7275,12 +7305,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let name_color = if m.mine { color_green } else { color_lavender };
// Split the (already-sanitized) message into text + URL spans so
// 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()
.map(|seg| match seg {
crate::sanitize::Segment::Text(t) => span(t).size(13).color(color_text),
crate::sanitize::Segment::Link(u) => {
span(u.clone()).size(13).color(color_blue).link(u)
.map(|(piece, is_link)| {
if is_link {
span(piece)
.size(13)
.color(color_blue)
.link(piece.to_string())
} else {
span(piece).size(13).color(color_text)
}
})
.collect();
@@ -9479,6 +9515,7 @@ mod tests {
kind: crate::files::AttachmentKind::File,
id: shared_id,
}),
links: Vec::new(),
};
// 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")];
@@ -9525,6 +9562,7 @@ mod tests {
mine: false,
from: Some(peer.to_string()),
attachment: None,
links: Vec::new(),
});
state.chat_input = "draft".to_string();
let att_key = (peer, attachment_id);
@@ -10433,6 +10471,7 @@ mod tests {
mine: true,
from: None,
attachment: None,
links: Vec::new(),
};
push_chat(&mut messages, entry);
assert_eq!(messages.len(), 1);
@@ -10454,6 +10493,7 @@ mod tests {
mine: i % 2 == 0,
from: None,
attachment: None,
links: Vec::new(),
},
);
}
@@ -10484,6 +10524,7 @@ mod tests {
mine: i % 2 == 0,
from: None,
attachment: None,
links: Vec::new(),
},
);
}
@@ -10501,4 +10542,60 @@ mod tests {
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);
}
}
+223 -125
View File
@@ -12,6 +12,18 @@
/// the roster readable.
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
/// rendered name even though they are NOT caught by [`char::is_control`]:
/// bidirectional overrides/isolates (text-direction spoofing) and
@@ -19,11 +31,10 @@ pub const NAME_MAX_CHARS: usize = 48;
/// explicitly so the sanitizer stays dependency-free (std exposes no category
/// query). Stripped outright rather than replaced.
pub(crate) fn is_spoofing_format_char(c: char) -> bool {
matches!(c,
is_bidi_override_char(c)
|| matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
| '\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
)
}
@@ -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
/// echo, and again at the gossip sign point) and incoming peer text (untrusted —
/// a buggy/malicious sender could include control characters or an enormous
/// payload). Single pass: control characters become spaces, any whitespace run
/// collapses to a single space, the ends are trimmed, and both the character and
/// UTF-8 byte ceilings are enforced without ever splitting a scalar. Message
/// bodies deliberately keep Unicode format characters (ZWJ/ZWNJ etc.) that the
/// short-label sanitizers strip — chat is expressive text, not a label. Returns
/// `""` for input with no visible text (callers drop empty messages). Idempotent,
/// so layered application converges on the same result.
/// payload). Single pass: bidi overrides/isolates are stripped outright (S14 —
/// they can visually reorder the rendered line), control characters become
/// spaces, any whitespace run collapses to a single space, the ends are trimmed,
/// and both the character and UTF-8 byte ceilings are enforced without ever
/// splitting a scalar. Message bodies deliberately keep the OTHER format
/// characters (ZWJ/ZWNJ/LRM/RLM etc.) that the short-label sanitizers strip —
/// 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 {
let mut out = String::new();
let mut chars = 0usize;
let mut pending_space = false;
for c in input.chars() {
if is_bidi_override_char(c) {
continue;
}
let c = if c.is_control() { ' ' } else { c };
if c.is_whitespace() {
// 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)
}
/// A piece of a chat message after URL detection: literal text or a link.
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Segment {
/// Plain text to render as-is.
Text(String),
/// A detected URL to render as a clickable link (also its href).
Link(String),
}
/// Max clickable links rendered per chat message. Later URL candidates stay
/// selectable plain text — bounds both the span count a message can force the
/// renderer to build and the opener targets one line can carry.
pub const CHAT_MSG_MAX_LINKS: usize = 8;
/// 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).
@@ -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.
fn find_scheme(s: &str) -> Option<usize> {
s.char_indices().find_map(|(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
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs
/// are treated as links, each ending at the first whitespace, with trailing prose
/// punctuation peeled back into the following text. Concatenating every segment's
/// inner string reproduces the input exactly (no characters added or dropped), so
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable.
pub fn linkify(input: &str) -> Vec<Segment> {
/// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
/// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
/// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
/// username/password syntax (`http://user@host` reads as a credential but is a
/// classic destination-spoof — such text stays plain, never clickable).
pub fn is_safe_web_url(s: &str) -> bool {
let Ok(u) = url::Url::parse(s) else {
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 rest = input;
while !rest.is_empty() {
let Some(start) = find_scheme(rest) else {
out.push(Segment::Text(rest.to_string()));
let mut base = 0usize;
while out.len() < CHAT_MSG_MAX_LINKS {
let Some(start) = find_scheme(&text[base..]) else {
break;
};
if start > 0 {
out.push(Segment::Text(rest[..start].to_string()));
let run_start = base + start;
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());
let candidate = &after[..end];
// Peel trailing punctuation back out of the link.
let url = candidate.trim_end_matches(is_url_trailing_punct);
out.push(Segment::Link(url.to_string()));
// Continue past just the URL; any peeled punctuation + the rest (incl. the
// whitespace) is reconsidered as ordinary text on the next iteration.
rest = &after[url.len()..];
}
out
}
/// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
/// output. Concatenating the slices reproduces `text` exactly (purely a
/// presentational split — no characters added or dropped). Borrows, so a redraw
/// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
/// 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
}
@@ -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.
fn reassemble(segs: &[Segment]) -> String {
segs.iter()
.map(|s| match s {
Segment::Text(t) | Segment::Link(t) => t.as_str(),
})
// --- link policy ---------------------------------------------------------
/// Concatenating every segment's slice must reproduce the input exactly.
fn reassemble(text: &str) -> String {
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()
}
#[test]
fn linkify_plain_text_has_no_links() {
let segs = linkify("just a normal message, nothing here");
assert_eq!(
segs,
vec![Segment::Text("just a normal message, nothing here".into())]
);
fn url_policy_accepts_only_wellformed_web_urls() {
for ok in [
"http://example.com",
"https://a.test/path?q=1&w=2",
"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]
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!(
linkify("see http://example.com now"),
vec![
Segment::Text("see ".into()),
Segment::Link("http://example.com".into()),
Segment::Text(" now".into()),
]
links("a http://one.com b https://two.com c"),
["http://one.com", "https://two.com"]
);
assert_eq!(
linkify("https://a.test/path?q=1"),
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() {
// Sentence-capitalized scheme still detected; href = the displayed slice.
assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
for msg in [
"",
"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!",
"weird))) http://c.com]]] tail",
"unicode 世界 http://d.com/路径 more 世界",
"bad http:// and http://user@x.com around https://ok.org here",
] {
assert_eq!(
reassemble(&linkify(msg)),
msg,
"roundtrip failed for {msg:?}"
);
assert_eq!(reassemble(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);
}
}