Files
2026-10-15 02:24:15 -03:00

211 lines
8.9 KiB
Rust

//! Hostile input: thirty ways a note can be wrong, none of which may panic,
//! hang or eat unbounded memory (SPEC 3, "never crash").
//!
//! Every case asserts only that the call returns in bounded time and with a
//! document or an [`Error`], never a panic: the exporter turns what it can
//! into pages and refuses the rest with data.
mod support;
use notescraft_pdf::{AssetSource, Error, ExportOptions, MAX_PAGES, MAX_SOURCE_BYTES, NoAssets, PageSize, export, page_count};
use support::{options, structural_check};
/// A hostile asset source: every lookup succeeds with junk.
struct Hostile;
impl AssetSource for Hostile {
fn image(&self, _target: &str) -> Option<Vec<u8>> {
Some(b"\x89PNG\r\n\x1a\nnot really a png".to_vec())
}
fn embedded_note(&self, target: &str) -> Option<String> {
// A note that embeds itself under a different spelling.
Some(format!("![[{target}.loop]]\n\n- [ ] a\n- [x] b\n\n```\n{}\n```\n", "x".repeat(50)))
}
}
/// Concatenates generated documents without a formatting round trip.
fn join(parts: impl Iterator<Item = String>) -> String {
parts.fold(String::new(), |mut all, part| {
all.push_str(&part);
all
})
}
/// Runs one case: `Ok` must be a structurally sound document, `Err` a value.
#[track_caller]
fn survives(source: &str) -> Result<usize, Error> {
let opts = options();
match export(source, &opts, &NoAssets) {
Ok(pdf) => {
structural_check(&pdf);
let pages = page_count(&pdf);
assert!((1..=MAX_PAGES).contains(&pages), "page count {pages} is inside the caps");
Ok(pages)
}
Err(error) => Err(error),
}
}
/// The thirty hostile cases, each a document that must not panic.
fn cases() -> Vec<(&'static str, String)> {
let big_table = {
let mut text = String::from("| a | b | c | d | e | f | g | h |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n");
for row in 0..5_000 {
let _ = std::fmt::Write::write_fmt(&mut text, format_args!("| {row} | x | y | z | w | v | u | t |\n"));
}
text
};
let big_code = {
let mut text = String::from("```rust\n");
for line in 0..10_000 {
let _ = std::fmt::Write::write_fmt(&mut text, format_args!("let value_{line} = {line};\n"));
}
text.push_str("```\n");
text
};
let nested = {
let mut text = String::new();
for depth in 0..100 {
let _ = std::fmt::Write::write_fmt(&mut text, format_args!("{}- item {depth}\n", " ".repeat(depth)));
}
text
};
let deep_quotes = format!("{}deep quote\n", "> ".repeat(500));
let many_markers = format!("{}bold {}\n", "**".repeat(500), "**".repeat(500));
let lone_markers = "***~~~```==$$[[[[(((()))".to_owned();
let nul_bytes = "text with \0 a nul and \0 more\n".to_owned();
let broken_unicode = "héllo wörld 🎉 \u{10FFFF} done\n".to_owned();
let huge_headings = join((1_u32..=500).map(|level| format!("{} heading {level}\n", "#".repeat(usize::try_from(level.rem_euclid(7)).unwrap_or(1) + 1))));
let wide_table_row = format!("| {} |\n| --- |\n", "very long cell content ".repeat(200));
let many_images = format!("{}!\n", "![[pic.png]]\n".repeat(300));
let many_embeds = format!("{}!\n", "![[note.md]]\n".repeat(300));
let huge_image = format!("![[pic.png|{}]]\n", "9".repeat(64));
let unbalanced_table = "| a | b\n| --- | --- |\n| 1 |\n|".to_owned();
let empty_document = String::new();
let only_newlines = "\n\n\n\n\n".to_owned();
let only_spaces = " \t \n \n".to_owned();
let rule_soup = "---\n***\n___\n- - -\n".to_owned();
let frontmatter_only = "---\na: 1\nb: [2, 3]\n---\n".to_owned();
let crlf = "# Title\r\n\r\nBody with **bold**.\r\n".to_owned();
let mixed_endings = "# Title\n\r\nBody\r\n\nMore\n".to_owned();
let very_long_line = format!("{}\n", "word ".repeat(50_000));
let very_long_word = format!("{}\n", "x".repeat(200_000));
let definitions = join((0..500).map(|index| format!("[^f{index}]: body {index}\n")));
let many_footnotes = definitions.clone();
let footnote_flood = format!("text[^f0]\n\n{definitions}");
let callout_soup = join((0..200).map(|index| format!("> [!note] Callout {index}\n> body\n\n")));
let math_soup = format!("{}\n", "$x^2$ ".repeat(20_000));
let heading_flood = join((0..1_000).map(|index| format!("# Heading {index}\n\nBody {index}.\n\n")));
let five_mib = "a".repeat(5 * 1024 * 1024);
let over_cap_with_markdown = format!("# {}\n", "b".repeat(MAX_SOURCE_BYTES));
vec![
("huge table", big_table),
("ten thousand line code block", big_code),
("hundred deep list", nested),
("five hundred deep quote", deep_quotes),
("unbalanced bold markers", many_markers),
("lone delimiters", lone_markers),
("nul bytes", nul_bytes),
("astral and unassigned planes", broken_unicode),
("hundreds of headings", huge_headings),
("one enormous table cell", wide_table_row),
("three hundred images", many_images),
("three hundred note embeds", many_embeds),
("absurd image size hint", huge_image),
("unbalanced table", unbalanced_table),
("empty document", empty_document),
("only newlines", only_newlines),
("only spaces", only_spaces),
("rule soup", rule_soup),
("frontmatter only", frontmatter_only),
("crlf line endings", crlf),
("mixed line endings", mixed_endings),
("very long line", very_long_line),
("one hundred thousand character word", very_long_word),
("many footnote definitions", many_footnotes),
("footnote flood", footnote_flood),
("two hundred callouts", callout_soup),
("twenty thousand inline math", math_soup),
("one thousand headings with bodies", heading_flood),
("five mebibyte paragraph", five_mib),
("over the source cap with markdown", over_cap_with_markdown),
]
}
/// Every hostile document exports or fails, in bounded time and memory.
#[test]
fn thirty_hostile_documents_never_panic() {
let cases = cases();
assert_eq!(cases.len(), 30, "the suite keeps thirty hostile cases");
for (name, source) in &cases {
match survives(source) {
Ok(pages) => assert!(pages >= 1, "{name}: at least one page"),
Err(error) => assert!(matches!(error, Error::TooLarge(_)), "{name}: a refusal is data, got {error:?}"),
}
}
}
/// A source over the cap is refused, never truncated.
#[test]
fn oversized_input_is_refused_not_truncated() {
let source = format!("# {}\n", "b".repeat(MAX_SOURCE_BYTES));
let result = export(&source, &options(), &NoAssets);
assert!(matches!(result, Err(Error::TooLarge(_))), "{:?}", result.err());
assert!(source.len() > MAX_SOURCE_BYTES, "the caller's input is untouched: {} bytes", source.len());
}
/// The same hostile documents survive every page size, margin and font size.
#[test]
fn hostile_documents_survive_every_page_setup() {
let mut opts = ExportOptions::new(support::fonts());
opts.created = Some(String::from("D:20260101000000"));
for page in [PageSize::A4, PageSize::Letter] {
for margins in [10.0f32, 20.0, 40.0] {
for size in [4.0f32, 11.0, 48.0] {
opts.page = page;
opts.margins_mm = margins;
opts.base_font_size = size;
let pdf = export("# Deep\n\n- [ ] one\n- [x] two\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n", &opts, &Hostile);
if let Ok(pdf) = pdf {
structural_check(&pdf);
assert!(page_count(&pdf) >= 1, "{page:?} {margins}mm {size}pt produced pages");
}
}
}
}
}
/// Broken assets are placeholders, not failures: the document still exports.
#[test]
fn broken_assets_do_not_stop_the_export() {
let source = "![[pic.png]]\n\n![[note.md]]\n\n![[note.md.loop]]\n\n![[note.md.loop.loop]]\n";
let opts = options();
let pdf = export(source, &opts, &Hostile);
assert!(pdf.is_ok(), "{:?}", pdf.err());
if let Ok(pdf) = pdf {
structural_check(&pdf);
assert!(page_count(&pdf) >= 1);
}
}
/// Options outside the documented ranges are clamped or refused, never
/// silently turned into a nonsense document.
#[test]
fn hostile_options_are_clamped_or_refused() {
let base = options();
let mut opts = base.clone();
opts.margins_mm = f32::NAN;
assert!(export("# x\n", &opts, &NoAssets).is_err(), "NaN margins are refused");
opts.margins_mm = 1.0e9;
let pdf = export("# x\n", &opts, &NoAssets);
assert!(pdf.is_ok(), "a huge margin clamps into the cap");
let mut opts = base.clone();
opts.base_font_size = 0.0;
assert!(export("# x\n", &opts, &NoAssets).is_err(), "a zero font size is refused");
let mut opts = base;
opts.title = Some("x".repeat(4_096));
assert!(export("# x\n", &opts, &NoAssets).is_ok(), "a long title still exports");
}