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>
195 lines
8.1 KiB
Rust
195 lines
8.1 KiB
Rust
//! 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!()
|
|
}
|