gatewayd: images, and links in untrusted replies, are posted as code
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -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