211 lines
8.9 KiB
Rust
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");
|
|
}
|