File::Raw · File::Raw::XML 0.04

File::Raw::XML

An XML parser that refuses what it should, with XPath and canonicalisation.

XML 1.0 with namespaces into a read-only tree, canonicalised in the three algorithms a signature names. By default it refuses every DOCTYPE and anything but UTF-8, and that one rule removes entity expansion, external entities and XXE as a class. profile => 'full' is a full XML processor, when asked for.

Registers xml

use File::Raw ();
use File::Raw::XML;

my $doc  = File::Raw::slurp('feed.xml', plugin => 'xml');
my $root = $doc->root;

for my $item ($root->descendants(undef, 'item')) {
    my ($title) = $item->find(undef, 'title');
    printf "%s: %s\n", $item->attr('id'), $title->text;
}

What you get

  • Safe by default

    A DOCTYPE anywhere is refused, and so is anything but UTF-8. Entity expansion, external entities, DTD fetches, the billion laughs and XXE are gone as a class, not configured away.

  • Refusals that say where

    Every refusal names the byte offset and up to sixteen bytes of what was there. Duplicate IDs among id_attrs are refused outright: picking one is the signature-wrapping attack.

  • Canonicalisation

    Exclusive XML Canonicalization 1.0, Canonical XML 1.0 and 1.1, over a node and its subtree, with nodes left out: the bytes a signature is computed over.

  • XPath 1.0

    Compiled expressions with namespaces and variables, or $node->xpath(...) for one.

  • Write and edit

    A writer, and a mutable tree: append, insert, detach, set attributes, text and names.

  • The full processor, asked for

    profile => 'full': DTDs and their subsets, entities under budgets, UTF-16 and ISO-8859-1, XML 1.1, DTD validation and XInclude. Nothing of it is reachable without asking.

In use

use File::Raw::XML qw(file_xml_decode);

my $doc = file_xml_decode('<feed><item id="a"/><item id="b"/></feed>');

my $count = $doc->root->xpath('count(//item)');
my @ids   = map { $_->attr('id') } $doc->root->xpath('//item');

# Canonical bytes, as a signature is computed over them.
my $c14n = $doc->root->c14n(mode => 'exclusive');

The numbers

Against the fastest alternative that was installed, and shown whichever way it went.

  • 2.4x parse a 1.0 MB document to a tree, against XML::LibXML File::Raw::XML 0.04 at 225/s, XML::LibXML 2.0213 at 93.0/s
  • 0.9x stream a 1.0 MB file record by record, against XML::LibXML::Reader File::Raw::XML 0.04 at 142/s, XML::LibXML::Reader 2.0213 at 153/s

Measured on Apple M5, darwin with perl 5.42.0, 2026-10-19: each entry timed for 3 CPU seconds by the Benchmark module, 3 times, the median kept, after checking that both sides produced the same result. Every number.

Two profiles

strict is the default everywhere: the plugin, the codec, the reader and the C ABI. Under it a document type declaration is refused wherever it stands, the input is UTF-8 or it is refused, and the tree, the messages and the canonical bytes do not move. A caller who wants the guarantee that no entity was expanded and nothing was fetched has it by doing nothing.

full reads what strict refuses, and anything that reaches outside the document is a further option on top of it: resolve, validate, xinclude. An option only the full profile can honour is refused under strict rather than ignored.

What it refuses

In the order the parser meets them: input over max_bytes. Anything but UTF-8 and XML 1.0. A DOCTYPE. Bytes that are not UTF-8 or not XML characters. A named entity beyond the five predefined. ]]> in content and -- in a comment. Names that are not names. An attribute given twice, even through two prefixes. An unbound prefix. Nesting past max_depth. More than one root. Two elements sharing an ID. Each refusal says where.

For C

frx_abi.h exposes the parser, the tree and canonicalisation to other XS modules, with the ownership of every handle stated, and four entries that cross between a handle and the Perl object.

The header in the reference

Get it

cpanm File::Raw::XML

The reference

Every File::Raw dist