Files
skylar taylor-barrick 80d57189b8 archive: xml scan requires the <! prefix, skips DTD PIs, refuses UTF-16
Review findings, all reproduced before this fix:
- opens_doctype matched the keyword without requiring <! first, so the
  valid element <aDOCTYPE> was treated as a declaration: depth-capped
  documents passed under DeclarationOnly and ordinary elements were
  refused under Refuse.
- A processing instruction inside an internal subset was not skipped,
  so a ] inside it ended the subset measurement early and the rest of
  the subset escaped the byte cap.
- A UTF-16 document was silently admitted: the scan saw no ASCII
  markup at all, so a refused DTD passed. A UTF-16 BOM now returns
  UnsupportedEncoding; the caller decodes to UTF-8 first.

Each has a regression test; workspace tests, clippy -D warnings and
fmt pass.
2026-10-14 15:34:52 -06:00
..

Craft Archive

craft-archive holds the checks that must run before an archive or XML parser touches a file the app did not write. It has no dependencies and no unsafe, and it parses nothing: a file it accepts may still be malformed and be rejected by the parser afterwards, but a file it refuses is never fed to a parser that could exhaust memory or the stack.

Two modules, one counter type. Each format keeps its own numbers; the crate holds none.

Budget and Exceeded

A named running total. Charge it before allocating, and a breach says which limit was hit:

use craft_archive::Budget;

let mut total = Budget::new("document", 512 * 1024 * 1024);
total.charge(1024)?;                 // Ok: 1024 bytes charged
let left = total.remaining();        // what is left
# Ok::<(), craft_archive::Exceeded>(())

zip::read_entry charges a budget itself, so one budget covers a whole import and the totals are bytes that really arrived, not bytes the archive claimed.

zip::preflight, zip::read_entry

zip::ZipArchive::new reads the end records and then reads and indexes every central-directory entry before a caller can count them, and a ZIP64 archive may declare billions. preflight runs first, on the raw bytes, without allocating:

use craft_archive::zip::{self, Limits};

# fn example(bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let limits = Limits { max_entries: 20_000, max_central_directory_bytes: 16 * 1024 * 1024, max_name_bytes: 1_024 };
let directory = zip::preflight(bytes, limits)?;
# let _ = directory;
# Ok(())
# }

It validates the single end-of-central-directory record that ends the file, the optional ZIP64 record and locator, the declared entry count and directory size against the limits, the directory offset and size arithmetic, and every entry header in the directory. Split archives, prefixed (self-extracting) archives and archives with more than one plausible end record are refused rather than guessed at.

zip::read_entry then reads one entry against a per-entry limit and the remaining total budget. It refuses a declared size over the limit, reserves a fixed small amount rather than the declared size, charges only the bytes actually read, and reports more bytes than the bound as an overrun — what a lying declared size or a decompression bomb looks like. It takes any std::io::Read, so a reader from the zip crate and a raw reader both work.

xml::check, xml::Depth

Recursive XML parsers consume stack per element, so deeply nested input aborts the process instead of returning an error. xml::check measures the nesting in one iterative pass over the bytes first, and finds the <!DOCTYPE so the application can apply its DTD policy:

use craft_archive::xml::{self, Dtd, Limits};

# fn example(bytes: &[u8]) -> Result<(), craft_archive::xml::Error> {
let limits = Limits { max_depth: 256, dtd: Dtd::Refuse };
xml::check(bytes, &limits)
# }

Comments, CDATA sections, processing instructions and quoted attribute values cannot fake a tag. Dtd is Refuse, DeclarationOnly (a declaration without an internal subset) or InternalSubset { max_bytes }. xml::Depth is the same counter for a reader that counts as it parses, so both agree on where the limit is.

xml::check reads UTF-8 bytes. A document in any other encoding (a UTF-16 BOM is the tell) is refused with Error::UnsupportedEncoding rather than silently admitted: decode to UTF-8 first and check those bytes — the same bytes the downstream parser reads.

What stays in the applications

Every number, and which parts are read or skipped. Entry-name rules and part allowlists. Whether a breach fails the import or drops a part, and the wording of the user-facing message. The choice of XML parser and its own node limit, and node, text and model budgets inside a parser. The application's own tree depth limits (math, content types, styles). OPC relationships and content types. Image-decode admission is not in this crate: its consumers and checks depend on each decoder.

License

Dual-licensed under MIT or Apache-2.0, at your option.