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
+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);
}
}
+227 -129
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,13 +31,12 @@ 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,
'\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
)
is_bidi_override_char(c)
|| matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
| '\u{FEFF}' // BOM / zero-width no-break space
)
}
/// 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
/// 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);
}
}