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:
@@ -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: `` 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: ``, 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 `` 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(¶graph, untrusted);
|
||||||
|
paragraph.clear();
|
||||||
|
self.text.push_str(line);
|
||||||
|
} else {
|
||||||
|
paragraph.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.paragraph(¶graph, 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()),
|
||||||
|
¬ice,
|
||||||
|
);
|
||||||
|
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. "
|
||||||
|
),
|
||||||
|
["Summary done. ``"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untrusted_reply_with_only_an_image_has_no_notice() {
|
||||||
|
assert_eq!(
|
||||||
|
posts(
|
||||||
|
"links-only-image",
|
||||||
|
done_untrusted,
|
||||||
|
""
|
||||||
|
),
|
||||||
|
["``"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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\n```",
|
||||||
|
"y".repeat(10_000),
|
||||||
|
"z".repeat(10_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
answer_posts(&content, false),
|
||||||
|
[
|
||||||
|
format!("```\n{}", "y".repeat(10_000)),
|
||||||
|
format!(
|
||||||
|
"{}\n````````\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  here.",
|
||||||
|
"An image `` here.",
|
||||||
|
"An image `` here.",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image with a size",
|
||||||
|
"",
|
||||||
|
"``",
|
||||||
|
"``",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image with a width and a title",
|
||||||
|
"",
|
||||||
|
"``",
|
||||||
|
"``",
|
||||||
|
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. ",
|
||||||
|
"Done. ``",
|
||||||
|
"Done. ``",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image glued to text, non-ASCII too",
|
||||||
|
"AB 字字",
|
||||||
|
"A ``B 字 ``字",
|
||||||
|
"A ``B 字 ``字",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image alt over two lines",
|
||||||
|
"",
|
||||||
|
"``",
|
||||||
|
"``",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image alt with a pipe",
|
||||||
|
"",
|
||||||
|
"``",
|
||||||
|
"``",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image inside a link",
|
||||||
|
"[](https://b.example)",
|
||||||
|
"`[](https://b.example)`",
|
||||||
|
"`[](https://b.example)`",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image inside brackets that are no link",
|
||||||
|
"[]",
|
||||||
|
"\\[ ``]",
|
||||||
|
"\\[ ``]",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"no bracket closes the alt",
|
||||||
|
"",
|
||||||
|
"\\",
|
||||||
|
"\\! `[x](https://e.example/a)`",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"escaped backslash, then an image",
|
||||||
|
"\\\\",
|
||||||
|
"\\\\ ``",
|
||||||
|
"\\\\ ``",
|
||||||
|
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 `` now.",
|
||||||
|
"Run `curl https://example.com/x` and `` now.",
|
||||||
|
"Run `curl https://example.com/x` and `` now.",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fenced block",
|
||||||
|
"Before:\n```sh\ncurl https://example.com/x\n\n```\nAfter.",
|
||||||
|
"Before:\n```sh\ncurl https://example.com/x\n\n```\nAfter.",
|
||||||
|
"Before:\n```sh\ncurl https://example.com/x\n\n```\nAfter.",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tilde fence, and a longer fence holding a shorter one",
|
||||||
|
"~~~\n\n~~~\n````\n```\nhttps://b.example\n````",
|
||||||
|
"~~~\n\n~~~\n````\n```\nhttps://b.example\n````",
|
||||||
|
"~~~\n\n~~~\n````\n```\nhttps://b.example\n````",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"span with a backtick inside",
|
||||||
|
"``a ` https://x.example`` and ````",
|
||||||
|
"``a ` https://x.example`` and ````",
|
||||||
|
"``a ` https://x.example`` and ````",
|
||||||
|
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 ",
|
||||||
|
"``x`` then https://y.example and ``````",
|
||||||
|
"``x`` then ```https://y.example``` and ``````",
|
||||||
|
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",
|
||||||
|
"",
|
||||||
|
"````",
|
||||||
|
"````",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a backtick right after the image",
|
||||||
|
"x `y`",
|
||||||
|
"x ```` `y`",
|
||||||
|
"x ```` `y`",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a lone backtick right before the image",
|
||||||
|
"`",
|
||||||
|
"` ````",
|
||||||
|
"` ````",
|
||||||
|
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",
|
||||||
|
"```\n````````",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a closer may end with tildes, and what follows is prose",
|
||||||
|
"```\n\n```~\n",
|
||||||
|
"```\n\n```~\n````````",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a longer closer closes, a shorter one does not",
|
||||||
|
"```\n\n````\n````\n```\n\n````",
|
||||||
|
"```\n\n````\n````\n```\n\n````",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an opener indented four spaces is prose",
|
||||||
|
" ```\n \n ```",
|
||||||
|
" ```\n ````````\n ```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"two backticks are no fence",
|
||||||
|
"``\n\n``",
|
||||||
|
"``\n``````\n``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a closer indented four spaces does not close",
|
||||||
|
"```\n\n ```\n\n```",
|
||||||
|
"```\n\n ```\n\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an opener indented three spaces is a fence",
|
||||||
|
" ```\n \n```",
|
||||||
|
" ```\n \n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an opener with a pipe after it is prose",
|
||||||
|
"```|x\n\n```",
|
||||||
|
"```|x\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an opener with a backtick after it is prose",
|
||||||
|
"``` a`b\n\n```",
|
||||||
|
"``` a`b\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a fence continuing a list item is prose",
|
||||||
|
"- item\n```\n\n```",
|
||||||
|
"- item\n```\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a fence continuing a quote is prose",
|
||||||
|
"> quote\n```\n\n```",
|
||||||
|
"> quote\n```\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a fence continuing a numbered item is prose",
|
||||||
|
"1. item\n```\n\n```",
|
||||||
|
"1. item\n```\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a fence after an empty line below a list is a fence, even with an empty line inside",
|
||||||
|
"- item\n\n```\n\n\nx\n```",
|
||||||
|
"- item\n\n```\n\n\nx\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a line indented less than the opener ends it",
|
||||||
|
"- item\n\n ```\n\n ```",
|
||||||
|
"- item\n\n ```\n````````\n ```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an empty line inside an indented fence does not end it",
|
||||||
|
"- item\n\n ```\n one\n\n \n ```",
|
||||||
|
"- item\n\n ```\n one\n\n \n ```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a fence after an HTML tag is prose",
|
||||||
|
"<div>\n\n```\n\n```\n\n</div>",
|
||||||
|
"<div>\n\n```\n````````\n```\n\n</div>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a tag after a list marker counts",
|
||||||
|
"- <div>\n\n```\n\n```",
|
||||||
|
"- <div>\n\n```\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a tag inside a line does not",
|
||||||
|
"Use `Vec<String>`:\n```\n\n```",
|
||||||
|
"Use `Vec<String>`:\n```\n\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a closing tag counts",
|
||||||
|
"</p>\n```\n\n```",
|
||||||
|
"</p>\n```\n````````\n```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a tag after the fence does not matter",
|
||||||
|
"```\n\n```\n<div>",
|
||||||
|
"```\n\n```\n<div>",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a tag inside a fence does not count",
|
||||||
|
"```\n<div>\n```\n```\n\n```",
|
||||||
|
"```\n<div>\n```\n```\n\n```",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const LINES: &[Case] = &[
|
||||||
|
(
|
||||||
|
"a carriage return ends a line",
|
||||||
|
"```\r\r```\rtext\r",
|
||||||
|
"```\r\r```\rtext\r````````",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"U+2424 ends a line",
|
||||||
|
"```\u{2424}\u{2424}```",
|
||||||
|
"```\u{2424}\u{2424}```",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CRLF is one line end, not an empty line",
|
||||||
|
"`a\r\n``",
|
||||||
|
"`a\r\n` ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a line of spaces inside an alt is written as it is",
|
||||||
|
"",
|
||||||
|
"``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an alt split by CRLF keeps it",
|
||||||
|
"",
|
||||||
|
"``",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPANS: &[Case] = &[
|
||||||
|
(
|
||||||
|
"a span on one line is code",
|
||||||
|
"a\n``",
|
||||||
|
"a\n``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"after a lone backtick, a span of its length is prose",
|
||||||
|
"`a\n``",
|
||||||
|
"`a\n` ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a span of another length is still code",
|
||||||
|
"`a\n````",
|
||||||
|
"`a\n````",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a span holding a run of that length is prose",
|
||||||
|
"`lone\n``a `  ``",
|
||||||
|
"`lone\n``a ` `````` ``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an empty line starts afresh",
|
||||||
|
"`a\n\n``",
|
||||||
|
"`a\n\n``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a line of spaces does not",
|
||||||
|
"`a\n \n``",
|
||||||
|
"`a\n \n` ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a span that does not close on its line is prose",
|
||||||
|
"`a\nb` ",
|
||||||
|
"`a\nb` ````",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a span holding a pipe is prose (a table cell ends there)",
|
||||||
|
"| `x |  | y` |",
|
||||||
|
"| `x | ```` | y` |",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"an escaped backtick opens nothing",
|
||||||
|
"\\``",
|
||||||
|
"\\` ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a backtick in a URL opens nothing (marked reads it as the URL's)",
|
||||||
|
"https://x.example/a`b  `",
|
||||||
|
"https://x.example/a`b ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a backtick in a link destination opens nothing",
|
||||||
|
"[a](x `)  `",
|
||||||
|
"[a](x `) ```` `",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
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",
|
||||||
|
" `",
|
||||||
|
"`````` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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",
|
||||||
|
"\\)c) d",
|
||||||
|
"`\\)`c) d",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"no parenthesis closes the destination",
|
||||||
|
",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 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",
|
||||||
|
"** `**  `",
|
||||||
|
"** `** ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a tag before a span that holds a `>` makes it prose",
|
||||||
|
"<b x='`'>  `",
|
||||||
|
"<b x='`'> ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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) `*`",
|
||||||
|
"[a*b](https://l.example) `* ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"inline LaTeX before a span that holds a `$` makes it prose",
|
||||||
|
"$a `$  `",
|
||||||
|
"$a `$ ```` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a new span splits at a `$` seen before it, which marked does not escape",
|
||||||
|
"$x ",
|
||||||
|
"$x ``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"but not at a `$` seen only after it",
|
||||||
|
" $x",
|
||||||
|
"`` $x",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a new span splits at a closer seen before it",
|
||||||
|
"*a* ",
|
||||||
|
"*a* ``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"but not at one seen only after it",
|
||||||
|
" *a*",
|
||||||
|
"`` *a*",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"marked may read part of a run into a clean URL",
|
||||||
|
"www.x.example/p```)``",
|
||||||
|
"www.x.example/p```)` ```````` `",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a clean URL ends at a `<` before a line end, which may start an autolink",
|
||||||
|
"e.org/p<\ne.org/p<https://a.example>",
|
||||||
|
"e.org/p<\ne.org/p<https://a.example> ``",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a clean URL holding an image goes in code whole",
|
||||||
|
"https://x.example/ end",
|
||||||
|
"`https://x.example/` 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!("", "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 `"));
|
||||||
|
let got = disarm(&image(2047), true).text;
|
||||||
|
assert_eq!(got, format!("`"));
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user