mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's
i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
747 lines
30 KiB
Rust
747 lines
30 KiB
Rust
//! Document Type Definitions: element declarations (content models) and required attributes, and
|
|
//! validation of an XML document against them (Structure ▸ Validate).
|
|
|
|
use std::collections::HashMap;
|
|
|
|
/// How much text parameter entities may add during expansion (a pass count is not an output
|
|
/// bound: each pass replaces whole strings, so references can inflate multiplicatively).
|
|
const MAX_EXPANDED_BYTES: usize = 1024 * 1024;
|
|
|
|
/// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
enum Cp {
|
|
Name(String),
|
|
Seq(Vec<Cp>),
|
|
Choice(Vec<Cp>),
|
|
Rep(Box<Cp>, char),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
enum Model {
|
|
Empty,
|
|
Any,
|
|
/// `(#PCDATA | a | b)*`: text and these elements in any order.
|
|
Mixed(Vec<String>),
|
|
Children(Cp),
|
|
}
|
|
|
|
/// A parsed DTD.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Dtd {
|
|
elements: Vec<(String, Model)>,
|
|
/// Element → its `#REQUIRED` attributes.
|
|
required: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
/// A problem found by validation: where (an element path such as `Root/story[2]`) and what.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
|
pub struct Problem {
|
|
pub path: String,
|
|
pub message: String,
|
|
}
|
|
|
|
impl Dtd {
|
|
/// Declared element names, in order.
|
|
pub fn element_names(&self) -> impl Iterator<Item = &str> {
|
|
self.elements.iter().map(|(n, _)| n.as_str())
|
|
}
|
|
|
|
fn model(&self, name: &str) -> Option<&Model> {
|
|
self.elements.iter().find(|(n, _)| n == name).map(|(_, m)| m)
|
|
}
|
|
}
|
|
|
|
/// Markup declarations (`<!…>`) with comments removed and quoted strings kept whole.
|
|
fn declarations(src: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut rest = src;
|
|
while let Some(i) = rest.find("<!") {
|
|
rest = &rest[i..];
|
|
if rest.starts_with("<!--") {
|
|
rest = rest.find("-->").map_or("", |j| &rest[j + 3..]);
|
|
continue;
|
|
}
|
|
let mut quote = None;
|
|
let mut end = rest.len();
|
|
for (k, c) in rest.char_indices().skip(2) {
|
|
match (quote, c) {
|
|
(Some(q), c) if c == q => quote = None,
|
|
(None, '"' | '\'') => quote = Some(c),
|
|
(None, '>') => {
|
|
end = k;
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
out.push(rest[2..end].to_string());
|
|
rest = rest.get(end + 1..).unwrap_or("");
|
|
}
|
|
out
|
|
}
|
|
|
|
fn tokens(s: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut cur = String::new();
|
|
for c in s.chars() {
|
|
if c.is_whitespace() || "(),|?*+".contains(c) {
|
|
if !cur.is_empty() {
|
|
out.push(std::mem::take(&mut cur));
|
|
}
|
|
if !c.is_whitespace() {
|
|
out.push(c.to_string());
|
|
}
|
|
} else {
|
|
cur.push(c);
|
|
}
|
|
}
|
|
if !cur.is_empty() {
|
|
out.push(cur);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// How deeply groups may nest in a content model; parsing, matching (`ends`) and printing (`show`)
|
|
/// recurse once per level.
|
|
const MAX_CONTENT_MODEL_DEPTH: usize = 64;
|
|
|
|
/// Parse a content particle from `t[*i..]`, inside `depth` enclosing groups.
|
|
fn particle(t: &[String], i: &mut usize, depth: usize) -> Result<Cp, String> {
|
|
let base = match t.get(*i).map(String::as_str) {
|
|
Some("(") => {
|
|
if depth >= MAX_CONTENT_MODEL_DEPTH {
|
|
return Err(format!("content model nested more than {MAX_CONTENT_MODEL_DEPTH} levels"));
|
|
}
|
|
*i += 1;
|
|
let mut items = vec![particle(t, i, depth + 1)?];
|
|
let mut sep = None;
|
|
loop {
|
|
match t.get(*i).map(String::as_str) {
|
|
Some(")") => {
|
|
*i += 1;
|
|
break;
|
|
}
|
|
Some(s @ ("," | "|")) => {
|
|
if sep.is_some_and(|p| p != s) {
|
|
return Err("mixed `,` and `|` in one group".into());
|
|
}
|
|
sep = Some(if s == "," { "," } else { "|" });
|
|
*i += 1;
|
|
items.push(particle(t, i, depth + 1)?);
|
|
}
|
|
other => return Err(format!("unexpected `{}` in a content model", other.unwrap_or("end"))),
|
|
}
|
|
}
|
|
if sep == Some("|") { Cp::Choice(items) } else { Cp::Seq(items) }
|
|
}
|
|
Some(n) if !"),|?*+".contains(n) => {
|
|
*i += 1;
|
|
Cp::Name(n.to_string())
|
|
}
|
|
other => return Err(format!("unexpected `{}` in a content model", other.unwrap_or("end"))),
|
|
};
|
|
Ok(match t.get(*i).map(String::as_str) {
|
|
Some(r @ ("?" | "*" | "+")) => {
|
|
*i += 1;
|
|
Cp::Rep(Box::new(base), r.chars().next().unwrap_or('*'))
|
|
}
|
|
_ => base,
|
|
})
|
|
}
|
|
|
|
fn parse_model(s: &str) -> Result<Model, String> {
|
|
let s = s.trim();
|
|
match s {
|
|
"EMPTY" => return Ok(Model::Empty),
|
|
"ANY" => return Ok(Model::Any),
|
|
_ => {}
|
|
}
|
|
if s.contains("#PCDATA") {
|
|
let t = tokens(&s.replace("#PCDATA", " #PCDATA "));
|
|
return Ok(Model::Mixed(t.into_iter().filter(|x| !"()|*".contains(x.as_str()) && x != "#PCDATA").collect()));
|
|
}
|
|
let t = tokens(s);
|
|
let mut i = 0;
|
|
let cp = particle(&t, &mut i, 0)?;
|
|
if i != t.len() {
|
|
return Err(format!("unexpected `{}` after the content model", t[i]));
|
|
}
|
|
Ok(Model::Children(cp))
|
|
}
|
|
|
|
/// How deeply parameter entities may nest: a reference in the DTD is level 1, a reference in its
|
|
/// value level 2, and so on.
|
|
const MAX_PARAMETER_ENTITY_DEPTH: usize = 64;
|
|
|
|
/// Expand `%name;` references, one level per round, until a round changes nothing.
|
|
fn expand_parameter_entities(src: &str, ents: &[(String, String)]) -> Result<String, String> {
|
|
let mut entities = HashMap::new();
|
|
for (name, value) in ents {
|
|
entities.entry(name.as_str()).or_insert(value.as_str());
|
|
}
|
|
let limit = src.len().saturating_add(MAX_EXPANDED_BYTES);
|
|
let append = |out: &mut String, value: &str| -> Result<(), String> {
|
|
if value.len() > limit.saturating_sub(out.len()) {
|
|
return Err(format!("parameter entities expand the DTD beyond {limit} bytes"));
|
|
}
|
|
out.push_str(value);
|
|
Ok(())
|
|
};
|
|
let too_deep = || format!("parameter entities refer to themselves or nest deeper than {MAX_PARAMETER_ENTITY_DEPTH} levels");
|
|
let mut text = src.to_string();
|
|
// The round after the deepest level changes nothing, hence one more round than levels.
|
|
for _ in 0..=MAX_PARAMETER_ENTITY_DEPTH {
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut rest = text.as_str();
|
|
let mut changed = false;
|
|
while let Some(i) = rest.find('%') {
|
|
let (prefix, reference) = rest.split_at(i);
|
|
append(&mut out, prefix)?;
|
|
// `find` points to an ASCII `%`, so both split positions are UTF-8 boundaries.
|
|
rest = reference.split_at(1).1;
|
|
// A name has no whitespace and no `%` (`parse` ends a name at whitespace), whatever its
|
|
// length: the search for the closing `;` stops at the first of those, so the searches of
|
|
// a round never overlap and a round stays linear in the text.
|
|
let end =
|
|
rest.bytes().position(|b| matches!(b, b';' | b'%') || b.is_ascii_whitespace()).filter(|&k| rest.as_bytes().get(k) == Some(&b';'));
|
|
if let Some(end) = end
|
|
&& let Some(value) = rest.get(..end).and_then(|name| entities.get(name))
|
|
{
|
|
// A value that is its own reference (`<!ENTITY % a "%a;">`) never gets anywhere.
|
|
if reference.get(..end + 2) == Some(*value) {
|
|
return Err(too_deep());
|
|
}
|
|
append(&mut out, value)?;
|
|
changed = true;
|
|
rest = rest.split_at(end + 1).1;
|
|
continue;
|
|
}
|
|
append(&mut out, "%")?;
|
|
}
|
|
append(&mut out, rest)?;
|
|
text = out;
|
|
if !changed {
|
|
return Ok(text);
|
|
}
|
|
}
|
|
Err(too_deep())
|
|
}
|
|
|
|
/// Parse a DTD (its element and attribute-list declarations; parameter entities are expanded).
|
|
pub fn parse(src: &str) -> Result<Dtd, String> {
|
|
// Parameter entities first, then expand references and read the declarations again.
|
|
let mut ents: Vec<(String, String)> = Vec::new();
|
|
for d in declarations(src) {
|
|
if let Some(rest) = d.strip_prefix("ENTITY") {
|
|
let rest = rest.trim_start();
|
|
if let Some(rest) = rest.strip_prefix('%') {
|
|
let rest = rest.trim_start();
|
|
let name: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
|
|
let val = rest[name.len()..].trim();
|
|
let q = val.chars().next().unwrap_or('"');
|
|
if let Some(v) = val.strip_prefix(q).and_then(|v| v.find(q).map(|e| &v[..e])) {
|
|
ents.push((name, v.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let text = expand_parameter_entities(src, &ents)?;
|
|
let mut dtd = Dtd::default();
|
|
for d in declarations(&text) {
|
|
if let Some(rest) = d.strip_prefix("ELEMENT") {
|
|
let rest = rest.trim_start();
|
|
let name: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
|
|
let model = parse_model(&rest[name.len()..]).map_err(|e| format!("<!ELEMENT {name}>: {e}"))?;
|
|
dtd.elements.push((name, model));
|
|
} else if let Some(rest) = d.strip_prefix("ATTLIST") {
|
|
// name (attr type default)*; only `#REQUIRED` matters here.
|
|
let mut words = Vec::new();
|
|
let mut cur = String::new();
|
|
let mut quote = None;
|
|
let mut group = 0;
|
|
for c in rest.chars() {
|
|
match (quote, c) {
|
|
(Some(q), c) if c == q => {
|
|
quote = None;
|
|
cur.push(c);
|
|
}
|
|
(Some(_), c) => cur.push(c),
|
|
(None, '"' | '\'') => {
|
|
quote = Some(c);
|
|
cur.push(c);
|
|
}
|
|
(None, '(') => {
|
|
group += 1;
|
|
cur.push(c);
|
|
}
|
|
(None, ')') => {
|
|
group -= 1;
|
|
cur.push(c);
|
|
}
|
|
(None, c) if c.is_whitespace() && group == 0 => {
|
|
if !cur.is_empty() {
|
|
words.push(std::mem::take(&mut cur));
|
|
}
|
|
}
|
|
(None, c) => cur.push(c),
|
|
}
|
|
}
|
|
if !cur.is_empty() {
|
|
words.push(cur);
|
|
}
|
|
let Some((el, attrs)) = words.split_first() else { continue };
|
|
let mut k = 0;
|
|
while k + 2 < attrs.len() {
|
|
let name = &attrs[k];
|
|
let default = attrs.get(k + 2).map(String::as_str).unwrap_or("");
|
|
if default == "#REQUIRED" {
|
|
dtd.required.entry(el.clone()).or_default().push(name.clone());
|
|
}
|
|
k += if default == "#FIXED" { 4 } else { 3 };
|
|
}
|
|
}
|
|
}
|
|
if dtd.elements.is_empty() {
|
|
return Err("no element declarations".into());
|
|
}
|
|
Ok(dtd)
|
|
}
|
|
|
|
/// What `ends` has worked out for one sequence of children: the end positions of a group (by the
|
|
/// address of its node, which lives as long as the model) from a position.
|
|
type EndsMemo = HashMap<(usize, usize), Vec<usize>>;
|
|
|
|
/// End positions after `cp` matches a prefix of `seq[i..]`.
|
|
///
|
|
/// The positions of each group are worked out once per start position (`memo`): nested repetitions
|
|
/// such as `((a*)*)*` ask the inner group for the same positions again and again, which is
|
|
/// exponential in their depth without it.
|
|
fn ends(cp: &Cp, seq: &[&str], i: usize, memo: &mut EndsMemo) -> Vec<usize> {
|
|
if let Cp::Name(n) = cp {
|
|
return if seq.get(i) == Some(&n.as_str()) { vec![i + 1] } else { vec![] };
|
|
}
|
|
let key = (std::ptr::from_ref(cp) as usize, i);
|
|
if let Some(done) = memo.get(&key) {
|
|
return done.clone();
|
|
}
|
|
let mut out: Vec<usize> = match cp {
|
|
Cp::Name(_) => vec![],
|
|
// Keep the positions after each item a set: as a list of every path, `(a?, a?, …)` doubles per item.
|
|
Cp::Seq(items) => {
|
|
let mut at = vec![i];
|
|
for c in items {
|
|
let mut next = Vec::new();
|
|
for p in at {
|
|
next.extend(ends(c, seq, p, memo));
|
|
}
|
|
next.sort_unstable();
|
|
next.dedup();
|
|
at = next;
|
|
}
|
|
at
|
|
}
|
|
Cp::Choice(items) => {
|
|
let mut all = Vec::new();
|
|
for c in items {
|
|
all.extend(ends(c, seq, i, memo));
|
|
}
|
|
all
|
|
}
|
|
Cp::Rep(c, r) => {
|
|
let mut all = if *r == '+' { vec![] } else { vec![i] };
|
|
let mut frontier = ends(c, seq, i, memo);
|
|
if *r == '?' {
|
|
all.extend(frontier);
|
|
} else {
|
|
while !frontier.is_empty() {
|
|
let new: Vec<usize> = frontier.iter().copied().filter(|p| !all.contains(p)).collect();
|
|
all.extend(&new);
|
|
let mut next = Vec::new();
|
|
for p in new.into_iter().filter(|&p| p > i) {
|
|
next.extend(ends(c, seq, p, memo).into_iter().filter(|p| !all.contains(p)));
|
|
}
|
|
// Two paths to one position are one position: kept apart they multiply per round.
|
|
next.sort_unstable();
|
|
next.dedup();
|
|
frontier = next;
|
|
}
|
|
}
|
|
all
|
|
}
|
|
};
|
|
out.sort_unstable();
|
|
out.dedup();
|
|
memo.insert(key, out.clone());
|
|
out
|
|
}
|
|
|
|
fn show(cp: &Cp) -> String {
|
|
match cp {
|
|
Cp::Name(n) => n.clone(),
|
|
Cp::Seq(v) => format!("({})", v.iter().map(show).collect::<Vec<_>>().join(", ")),
|
|
Cp::Choice(v) => format!("({})", v.iter().map(show).collect::<Vec<_>>().join(" | ")),
|
|
Cp::Rep(c, r) => format!("{}{r}", show(c)),
|
|
}
|
|
}
|
|
|
|
/// An element of the document being validated.
|
|
struct Node {
|
|
name: String,
|
|
attrs: Vec<String>,
|
|
children: Vec<Node>,
|
|
text: bool,
|
|
}
|
|
|
|
fn read_tree(xml: &str) -> Result<Node, String> {
|
|
use quick_xml::events::Event;
|
|
let mut r = quick_xml::Reader::from_str(xml);
|
|
let mut stack: Vec<Node> = Vec::new();
|
|
let node = |e: &quick_xml::events::BytesStart| Node {
|
|
name: String::from_utf8_lossy(e.name().as_ref()).to_string(),
|
|
attrs: e.attributes().flatten().map(|a| String::from_utf8_lossy(a.key.as_ref()).to_string()).collect(),
|
|
children: vec![],
|
|
text: false,
|
|
};
|
|
loop {
|
|
match r.read_event().map_err(|e| e.to_string())? {
|
|
Event::Start(e) => stack.push(node(&e)),
|
|
Event::Empty(e) => {
|
|
let n = node(&e);
|
|
match stack.last_mut() {
|
|
Some(p) => p.children.push(n),
|
|
None => return Ok(n),
|
|
}
|
|
}
|
|
Event::End(_) => {
|
|
let n = stack.pop().ok_or("unbalanced end tag")?;
|
|
match stack.last_mut() {
|
|
Some(p) => p.children.push(n),
|
|
None => return Ok(n),
|
|
}
|
|
}
|
|
Event::Text(t) => {
|
|
if let Some(p) = stack.last_mut()
|
|
&& !t.iter().all(u8::is_ascii_whitespace)
|
|
{
|
|
p.text = true;
|
|
}
|
|
}
|
|
Event::CData(_) | Event::GeneralRef(_) => {
|
|
if let Some(p) = stack.last_mut() {
|
|
p.text = true;
|
|
}
|
|
}
|
|
Event::Eof => return Err("no root element".into()),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check(dtd: &Dtd, n: &Node, path: &str, out: &mut Vec<Problem>) {
|
|
let mut problem = |m: String| out.push(Problem { path: path.to_string(), message: m });
|
|
match dtd.model(&n.name) {
|
|
None => problem(format!("`{}` isn't declared in the DTD", n.name)),
|
|
Some(Model::Any) => {}
|
|
Some(Model::Empty) => {
|
|
if n.text || !n.children.is_empty() {
|
|
problem(format!("`{}` should be empty", n.name));
|
|
}
|
|
}
|
|
Some(Model::Mixed(allowed)) => {
|
|
for c in &n.children {
|
|
if !allowed.contains(&c.name) {
|
|
problem(format!("`{}` isn't allowed in `{}`", c.name, n.name));
|
|
}
|
|
}
|
|
}
|
|
Some(Model::Children(cp)) => {
|
|
if n.text {
|
|
problem(format!("`{}` can't contain text", n.name));
|
|
}
|
|
let seq: Vec<&str> = n.children.iter().map(|c| c.name.as_str()).collect();
|
|
if !ends(cp, &seq, 0, &mut EndsMemo::new()).contains(&seq.len()) {
|
|
let got = if seq.is_empty() { "nothing".to_string() } else { seq.join(", ") };
|
|
problem(format!("`{}` contains {got}; the DTD asks for {}", n.name, show(cp)));
|
|
}
|
|
}
|
|
}
|
|
for a in dtd.required.get(&n.name).into_iter().flatten() {
|
|
if !n.attrs.contains(a) {
|
|
problem(format!("`{}` is missing the required attribute `{a}`", n.name));
|
|
}
|
|
}
|
|
let mut seen: HashMap<&str, usize> = HashMap::new();
|
|
for c in &n.children {
|
|
let k = seen.entry(c.name.as_str()).or_default();
|
|
*k += 1;
|
|
check(dtd, c, &format!("{path}/{}[{k}]", c.name), out);
|
|
}
|
|
}
|
|
|
|
/// Validate `xml` against `dtd`; an empty list when it's valid.
|
|
pub fn validate(dtd: &Dtd, xml: &str) -> Vec<Problem> {
|
|
match read_tree(xml) {
|
|
Ok(root) => {
|
|
let mut out = Vec::new();
|
|
let path = root.name.clone();
|
|
check(dtd, &root, &path, &mut out);
|
|
out
|
|
}
|
|
Err(e) => vec![Problem { path: String::new(), message: format!("not well-formed XML: {e}") }],
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const DTD: &str = r##"<!-- an article -->
|
|
<!ENTITY % inline "#PCDATA | em | term">
|
|
<!ELEMENT Root (story+, figure*)>
|
|
<!ELEMENT story (title, (para | list)*)>
|
|
<!ELEMENT title (%inline;)*>
|
|
<!ELEMENT para (%inline;)*>
|
|
<!ELEMENT list (para+)>
|
|
<!ELEMENT em (#PCDATA)>
|
|
<!ELEMENT term (#PCDATA)>
|
|
<!ELEMENT figure EMPTY>
|
|
<!ATTLIST figure href CDATA #REQUIRED alt CDATA "a > b" kind (photo|chart) "photo">"##;
|
|
|
|
#[test]
|
|
fn content_models_and_required_attributes() {
|
|
let dtd = parse(DTD).unwrap();
|
|
assert_eq!(dtd.element_names().count(), 8);
|
|
let ok = r#"<Root><story><title>T <em>x</em></title><para>a</para><list><para>b</para></list></story><figure href="a.png"/></Root>"#;
|
|
assert_eq!(validate(&dtd, ok), vec![]);
|
|
let bad = r#"<Root><story><para>no title</para></story><figure/><figure href="b"><em>x</em></figure><aside/></Root>"#;
|
|
let p = validate(&dtd, bad);
|
|
let msgs: Vec<String> = p.iter().map(|p| format!("{}: {}", p.path, p.message)).collect();
|
|
assert!(msgs.iter().any(|m| m.starts_with("Root/story[1]: `story` contains para; the DTD asks for (title, (para | list)*)")), "{msgs:?}");
|
|
assert!(msgs.iter().any(|m| m == "Root/figure[1]: `figure` is missing the required attribute `href`"), "{msgs:?}");
|
|
assert!(msgs.iter().any(|m| m == "Root/figure[2]: `figure` should be empty"), "{msgs:?}");
|
|
assert!(msgs.iter().any(|m| m == "Root/aside[1]: `aside` isn't declared in the DTD"), "{msgs:?}");
|
|
// The root's model fails too (aside isn't in it).
|
|
assert!(msgs.iter().any(|m| m.starts_with("Root: ")), "{msgs:?}");
|
|
// Text where only elements are allowed; an element not in a mixed model.
|
|
let p = validate(&dtd, "<Root><story>loose<title><list/></title></story></Root>");
|
|
assert!(p.iter().any(|p| p.message == "`story` can't contain text"), "{p:?}");
|
|
assert!(p.iter().any(|p| p.message == "`list` isn't allowed in `title`"), "{p:?}");
|
|
assert!(parse("<!ELEMENT a (b, c | d)>").is_err());
|
|
assert!(parse("just text").is_err());
|
|
}
|
|
|
|
/// A few hundred bytes of nested parameter entities used to inflate multiplicatively across
|
|
/// the eight wholly-string-replacing passes (#713); expansion is capped.
|
|
#[test]
|
|
fn parameter_entities_cannot_expand_without_a_budget() {
|
|
let mut src = String::from("<!ENTITY % e1 \"xx\">\n");
|
|
for i in 2..=6 {
|
|
src.push_str(&format!("<!ENTITY % e{i} \"{}\">\n", format!("%e{};", i - 1).repeat(16)));
|
|
}
|
|
src.push_str("<!ELEMENT r (%e6;)>");
|
|
assert!(src.len() < 500, "the payload is small: {} bytes", src.len());
|
|
let err = parse(&src).unwrap_err();
|
|
assert!(err.contains("expand the DTD beyond"), "{err}");
|
|
// Entities that expand a little still parse.
|
|
let dtd = parse("<!ENTITY % inline \"#PCDATA | em\">\n<!ELEMENT r (%inline;)*>").unwrap();
|
|
assert_eq!(dtd.element_names().collect::<Vec<_>>(), ["r"]);
|
|
}
|
|
|
|
#[test]
|
|
fn repetition_matches_like_a_regular_expression() {
|
|
let dtd = parse("<!ELEMENT r ((a, b?)+, c*)><!ELEMENT a EMPTY><!ELEMENT b EMPTY><!ELEMENT c EMPTY>").unwrap();
|
|
for (xml, ok) in [
|
|
("<r><a/></r>", true),
|
|
("<r><a/><b/><a/><c/><c/></r>", true),
|
|
("<r><a/><a/><b/></r>", true),
|
|
("<r><b/></r>", false),
|
|
("<r><a/><b/><b/></r>", false),
|
|
("<r><a/><c/><a/></r>", false),
|
|
("<r/>", false),
|
|
] {
|
|
assert_eq!(validate(&dtd, xml).is_empty(), ok, "{xml}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_bomb_is_rejected() {
|
|
let mut src = String::new();
|
|
for i in (0..10).rev() {
|
|
let value = if i == 0 { "x".to_string() } else { format!("%e{};", i - 1).repeat(8) };
|
|
src.push_str(&format!("<!ENTITY % e{i} \"{value}\">\n"));
|
|
}
|
|
src.push_str("<!ELEMENT root (%e9;)>");
|
|
match parse(&src) {
|
|
Err(error) => assert!(error.contains("parameter entities expand"), "{error}"),
|
|
Ok(_) => panic!("parameter entity expansion bomb was accepted"),
|
|
}
|
|
}
|
|
|
|
/// `n` parameter entities, `e{i}` referring to `e{i-1}` and `e0` being `x`, declared in ascending
|
|
/// or descending order, and an element `root` whose model uses the last one.
|
|
fn entity_chain(n: usize, ascending: bool) -> String {
|
|
let mut order: Vec<usize> = (0..n).collect();
|
|
if !ascending {
|
|
order.reverse();
|
|
}
|
|
let mut src = String::new();
|
|
for i in order {
|
|
let value = if i == 0 { "x".to_string() } else { format!("%e{};", i - 1) };
|
|
src.push_str(&format!("<!ENTITY % e{i} \"{value}\">\n"));
|
|
}
|
|
src.push_str(&format!("<!ELEMENT root (%e{};)><!ELEMENT x EMPTY>", n - 1));
|
|
src
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_reverse_chain_expands_completely() {
|
|
let dtd = parse(&entity_chain(20, false)).unwrap();
|
|
assert_eq!(validate(&dtd, "<root><x/></root>"), vec![]);
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_ascending_chain_expands_completely() {
|
|
// With `e0` declared first, replacing the entities in declaration order resolves one level per pass.
|
|
let dtd = parse(&entity_chain(20, true)).unwrap();
|
|
assert_eq!(validate(&dtd, "<root><x/></root>"), vec![]);
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_cycle_is_refused() {
|
|
// Two entities that refer to each other, and one whose value is its own reference.
|
|
for src in ["<!ENTITY % a \"%b;\"><!ENTITY % b \"%a;\"><!ELEMENT root (%a;)>", "<!ENTITY % a \"%a;\"><!ELEMENT root (%a;)>"] {
|
|
match parse(src) {
|
|
Err(error) => assert!(error.contains("refer to themselves"), "{src}: {error}"),
|
|
Ok(dtd) => panic!("a cycle was accepted ({src}): {:?}", dtd.model("root")),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_chain_deeper_than_the_limit_is_refused() {
|
|
for ascending in [true, false] {
|
|
for n in [65, 70] {
|
|
match parse(&entity_chain(n, ascending)) {
|
|
Err(error) => assert!(error.contains("deeper than 64 levels"), "{n}, {ascending}: {error}"),
|
|
Ok(dtd) => panic!("a chain of {n} ({ascending}) was accepted: {:?}", dtd.model("root")),
|
|
}
|
|
}
|
|
let dtd = parse(&entity_chain(64, ascending)).unwrap();
|
|
assert_eq!(validate(&dtd, "<root><x/></root>"), vec![], "{ascending}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_name_scan_stops_at_the_next_percent() {
|
|
// A name never contains `%`, so the search for the closing `;` stops at the next one: the
|
|
// searches of a run of `%` don't overlap and a round stays linear. (Looking past the next
|
|
// `%` would expand this reference to an entity that is named `a%b`.)
|
|
let entities = [("a%b".to_string(), "X".to_string())];
|
|
assert_eq!(expand_parameter_entities("%a%b;", &entities), Ok("%a%b;".to_string()));
|
|
// A run of `%` around a real reference still finds it.
|
|
let entities = [("model".to_string(), "EMPTY".to_string())];
|
|
assert_eq!(expand_parameter_entities("%%%model;%%", &entities), Ok("%%EMPTY%%".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_with_a_long_name_expands() {
|
|
// A name can be any length: the search for the closing `;` has no window.
|
|
let name = "n".repeat(300);
|
|
let dtd = parse(&format!("<!ENTITY % {name} \"EMPTY\"><!ELEMENT root %{name};>")).unwrap();
|
|
assert_eq!(dtd.model("root"), Some(&Model::Empty));
|
|
}
|
|
|
|
#[test]
|
|
fn percent_in_text_without_a_reference_is_left_alone() {
|
|
// `100% sure` (a space ends the search) and a `%` followed by 1 MiB without a `;` are text.
|
|
let text = format!("100% sure, %{}", "a".repeat(1 << 20));
|
|
let entities = [("a".to_string(), "X".to_string())];
|
|
assert_eq!(expand_parameter_entities(&text, &entities), Ok(text.clone()));
|
|
}
|
|
|
|
/// `a` inside `levels` groups, each repeated: `((a)*)*…`.
|
|
fn nested_repetitions(levels: usize) -> String {
|
|
format!("{}a{}", "(".repeat(levels), ")*".repeat(levels))
|
|
}
|
|
|
|
#[test]
|
|
fn nested_repetitions_validate_without_going_exponential() {
|
|
// Ten levels over 100 children: each level asked the one below for the same positions again
|
|
// and again, about 10^14 steps without the memo of `ends`.
|
|
let dtd = parse(&format!("<!ELEMENT root {}><!ELEMENT a EMPTY><!ELEMENT b EMPTY>", nested_repetitions(10))).unwrap();
|
|
assert_eq!(validate(&dtd, &format!("<root>{}</root>", "<a/>".repeat(100))), vec![]);
|
|
let problems = validate(&dtd, &format!("<root>{}<b/></root>", "<a/>".repeat(100)));
|
|
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn repetition_of_a_sequence_of_optional_elements_validates() {
|
|
// `(a, a?, a?, a?, a?, a?)*` over 90 children: every way to split the children among the
|
|
// repetitions reaches the same positions, and the paths used to be kept apart in each round.
|
|
let dtd = parse("<!ELEMENT r (a, a?, a?, a?, a?, a?)*><!ELEMENT a EMPTY><!ELEMENT b EMPTY>").unwrap();
|
|
assert_eq!(validate(&dtd, &format!("<r>{}</r>", "<a/>".repeat(90))), vec![]);
|
|
let problems = validate(&dtd, &format!("<r>{}<b/></r>", "<a/>".repeat(90)));
|
|
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_first_declaration_wins() {
|
|
let dtd = parse("<!ENTITY % model \"EMPTY\"><!ENTITY % model \"ANY\"><!ELEMENT root %model;>").unwrap();
|
|
assert_eq!(dtd.model("root"), Some(&Model::Empty));
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_unknown_and_lone_percent_are_preserved() {
|
|
let src = "%nothing; <!ELEMENT root EMPTY> %";
|
|
let dtd = parse(src).unwrap();
|
|
assert_eq!(dtd.model("root"), Some(&Model::Empty));
|
|
assert_eq!(expand_parameter_entities("%nothing; %", &[]), Ok("%nothing; %".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn parameter_entity_many_flat_declarations_parse() {
|
|
let mut src = String::new();
|
|
for i in 0..5000 {
|
|
src.push_str(&format!("<!ENTITY % e{i} \"x\">\n"));
|
|
}
|
|
src.push_str("<!ELEMENT root (%e0;, %e2499;, %e4999;)><!ELEMENT x EMPTY>");
|
|
let dtd = parse(&src).unwrap();
|
|
assert_eq!(validate(&dtd, "<root><x/><x/><x/></root>"), vec![]);
|
|
}
|
|
|
|
/// An element `root` whose content model is `a` inside `depth` nested groups.
|
|
fn nested_model(depth: usize) -> String {
|
|
format!("<!ELEMENT root {}a{}><!ELEMENT a EMPTY>", "(".repeat(depth), ")".repeat(depth))
|
|
}
|
|
|
|
#[test]
|
|
fn content_model_nested_100000_deep_is_refused() {
|
|
// One recursion per `(` overflowed the stack: an abort, not a panic.
|
|
match parse(&nested_model(100_000)) {
|
|
Err(error) => assert!(error.contains("nested more than 64 levels"), "{error}"),
|
|
Ok(_) => panic!("a content model nested 100000 deep was accepted"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn content_model_nesting_limit() {
|
|
let dtd = parse(&nested_model(64)).unwrap();
|
|
assert_eq!(validate(&dtd, "<root><a/></root>"), vec![]);
|
|
assert_eq!(validate(&dtd, "<root/>").len(), 1);
|
|
match parse(&nested_model(65)) {
|
|
Err(error) => assert_eq!(error, "<!ELEMENT root>: content model nested more than 64 levels"),
|
|
Ok(_) => panic!("a content model nested 65 deep was accepted"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sequence_of_40_optional_elements_validates() {
|
|
// Keeping every path through the sequence meant 2^40 positions here.
|
|
let dtd = parse(&format!("<!ELEMENT r ({})><!ELEMENT a EMPTY>", ["a?"; 40].join(", "))).unwrap();
|
|
assert_eq!(validate(&dtd, &format!("<r>{}</r>", "<a/>".repeat(40))), vec![]);
|
|
let problems = validate(&dtd, &format!("<r>{}</r>", "<a/>".repeat(41)));
|
|
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
assert!(problems[0].message.starts_with("`r` contains a, a, a"), "{problems:?}");
|
|
}
|
|
}
|