Repository navigation
Identify XML vocabularies by their root element - #173
Merged
Merged
Conversation
jeremy
force-pushed
the
xml-root-sniffing
branch
2 times, most recently
from
August 21, 2026 17:15
5386ea6 to
01af448
Compare
jeremy
force-pushed
the
xml-root-sniffing
branch
3 times, most recently
from
August 22, 2026 06:59
544b2f5 to
5c1866f
Compare
Tika distinguishes feeds, KML, property lists, XSLT, XHTML, ONIX, iWork flat XML, Word/Excel 2003 XML and other vocabularies by the namespace and local name of the document's root element rather than by byte patterns, since a prolog of arbitrary length precedes it. The generator ignored those root-XML rules entirely, so RSS and Atom bodies detected as application/xml, and the feed types had no parent link to application/xml, so a filename could not refine the magic verdict either. The generator now reads root-XML rules into a ROOT_XML table keyed by [namespace, local name]. Tika matches by exact two-way equality and takes the first match over types sorted by name, so the one rule without a localName is unreachable and skipped as unsupported, and colliding keys resolve to the lexicographically first type at generation time. Marcel::Magic::Xml refines an application/xml magic match by skipping the prolog within a 64KB prefix (declaration, processing instructions, comments, DOCTYPE with internal subset), reading the root start-tag, resolving its namespace from its own xmlns declarations and looking the pair up. Tika hands that prefix to a namespace-aware SAX parser and takes no root when parsing fails before the first start element, so the scan holds the same line at the token level. The consumed bytes must be valid in the declared encoding and free of characters the XML version forbids as literals (XML 1.1 restricts the C0 and C1 controls to character references; 1.0 forbids C0 outright). The declaration may appear only first, parsed in full and limited to versions 1.0 and 1.1; no other processing instruction may use the reserved xml target, and PI data must follow whitespace; comments may not contain "--". Names — QName parts, prefixes, PI targets and entity names — are validated as Unicode NCNames after decoding. Attribute QNames must have bound prefixes and unique expanded names, the xml and xmlns bindings are reserved (with Namespaces 1.1 prefix undeclaration honoured under version 1.1), and attribute values may not contain "<"; their character references must denote characters the version admits and their entity references must be predefined unless a DTD that could declare them was seen. References are validated, never resolved. Anything the scanner cannot read with certainty leaves the generic type in place. custom.xml gives the feed, XHTML, XSL and XSPF types an application/xml parent so hints can refine to them. The release workflow's inline gem manifest had already fallen behind lib/marcel/magic/zip.rb and would have aborted the build; the manifest and identity checks move to script/verify_gem.rb, which the release build runs and test/package_test.rb exercises on every push against a freshly built gem. Fixes #48
jeremy
force-pushed
the
xml-root-sniffing
branch
from
August 22, 2026 07:53
5c1866f to
4d8e444
Compare
This was referenced Aug 22, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #48.
What changes
Tika expresses detection of 38 XML vocabularies as
<root-XML localName=… namespaceURI=…>rules thatscript/generate_tables.rbignored, so RSS/Atom bodies detected asapplication/xml. Two live gaps onmain:<?xml …?><rss version="2.0">→application/xml. Feeds are the visible tip; kml, plist, xliff, DITA, ONIX, XSLT, iWork flat-XML, Word/Excel 2003 XML are all dropped on the floor.for(io, name: "feed.rss")→application/xml, becauseapplication/rss+xmlhad no parent link toapplication/xml, somost_specific_typecouldn't let the filename refine the magic verdict.This PR mirrors the merged ZIP central-directory refiner (#169): generated data, a bounded fail-closed procedural refinement layered over the declarative magic result, no dependencies.
Generator
root-XMLrules into a newROOT_XMLtable keyed[namespace_or_nil, local_name], withMimeData.local_name/namespace_urivalidators so the data is validated before emission and every string goes throughRubySource.string(the inert-literal test now covers a code-shaped namespace).MimeType.RootXML#matchesandMimeTypes#init): matching is strict two-way equality, so the one namespace-only rule (RSS 1.0) can never match a real root element — it's routed throughUnsupportedRulesand the pin moves from 58 to 59. Cross-type precedence is lexicographic type name, first match wins; since each document matches at most one key, a per-key min at generation time is equivalent. The ONIX collision resolves toapplication/onix-message+xml, matching Tika's own tests.TYPE_EXTSis byte-identical; the only other table change is six newTYPE_PARENTSentries.Runtime —
Marcel::Magic::Xml.refine(io, base_type)base_type == "application/xml"only (Tika'stext/htmlleg is moot: marcel's XML magic never yields it).getMinLength).startElement(XmlRootExtractortakes no root on a parse failure):<?xml … encoding="…"?>, UTF-8 by default; unknown, dummy, binary and Ruby pseudo-names likelocalerefine nothing) over exactly the bytes consumed up to the end of the root start-tag — so an invalid byte in a comment or an unrelated attribute before the root fails closed, while one after the root, or a multibyte character cut at 64KB past the root, is harmless.standalone="yes|no", in that order, with nothing else before?>— so trailing or duplicated tokens cannot hide behind the PI skipper (<?xml version="1.0" garbage?>, duplicateencoding,standalone="maybe", out-of-order pseudo-attributes all fail closed). The declaration is limited to SAX-supported versions 1.0 and 1.1. No other PI may use the reservedxmltarget (<?XML …?>, a second declaration, a declaration after a comment all fail closed;xml-stylesheetand other targets are fine), and PI data must be separated from its target by whitespace (<?pi?x?>fails closed); comments may not contain--.<inside a comment stays legal. Each side is pinned by positive controls so strictness cannot overshoot the spec.fails closed under 1.0 but passes under 1.1, and surrogates, noncharacters and�fail closed under both — and entity references must be predefined (amp/lt/gt/quot/apos) unless a DTD that could declare them was seen. References are validated, never resolved.·a=fails closed since U+00B7 is NameChar but not NameStartChar;café=is a valid name), not as arbitrary high bytes.a:b:c=fails closed), prefixes bound (p:x=with noxmlns:pfails closed), expanded[namespace, local]attribute names unique (xmlns:a="urn:x" xmlns:b="urn:x" a:x b:xfails closed; the default namespace does not apply to attributes), thexml/xmlnsnames and namespace URIs reserved (xmlns:xmlonly to its own URI,xmlns:xmlnsnever;xmlns:p=""is a Namespaces 1.1 undeclaration honoured under version 1.1 and an error under 1.0, andxmlcan never be undeclared), and attribute values free of raw<and malformed references (bare&fails closed;&/Aare matched but never resolved).<!DOCTYPE>with quoted literals and an internal subset —<!ENTITY x "<foo>">and<!-- don't -->inside the subset don't derail it. Linear in the prefix; every step advances by anindexjump, no backtracking regexes.>in a value can't truncate the tag; duplicate attributes are a well-formedness error (Unique Att Spec) and refine nothing — last-one-wins could otherwise flip the namespace; namespace resolved from the root's ownxmlns/xmlns:<prefix>;xmlns=""un-declares; an undeclared prefix is a namespace error and refines nothing.application/x-xliff+xmlis an alias key in marcel, so an xliff document yieldsapplication/xliff+xml, preserving Respect MIME type aliases #171's canonical-output invariant.custom.xmladds<sub-class-of type="application/xml"/>foratom+xml,rss+xml,xhtml+xml,xslfo+xml,xslt+xml,xspf+xml, repeating each type's Tika globs and comment because the generator records parents only for glob-bearing types and overwrites the type wholesale (same pattern as the existing OOXML overrides).Release manifest (
script/verify_gem.rb)release.yml's inline gem manifest was already missinglib/marcel/magic/zip.rb(since #169) and would have aborted the build at its exact-manifest assertion; CI's "Release toolchain" job never executes that block. The manifest and identity checks now live inscript/verify_gem.rb: the release build calls it, andtest/package_test.rbboth uses its manifest constant and runs the script's CLI against a freshly built gem on every push (plus a negative case), so CI and release hold the gem to one list. Verified locally by replaying the release build step (gem build→ruby script/verify_gem.rb pkg/marcel-1.2.1.gem 1.2.1→Verified marcel-1.2.1: 13 files).Behavior changes beyond feeds, all Tika-faithful
<?xml?><body>,<p>,<script>,<frameset>,<iframe>,<link>roots (either case) →text/html(TIKA-327).<?xml?><html>was alreadytext/htmlvia the runtime HTML matcher.<html>lies past the 4KB HTML-magic window but within 64KB is nowtext/htmlrather thanapplication/xml(html_magic_testupdated; the 4KB bound itself is re-pinned with a >64KB variant).<k:kml xmlns:k=…>,<h:html xmlns:h="http://www.w3.org/1999/xhtml">→ kml / xhtml.Marcel::Magic.new("application/rss+xml").text?is now true (viaapplication/xml→text/plain), as for every other XML subtype.Documented posture
refinecan return the 10 glob-less Tika types absent fromTYPE_EXTS(e.g.application/vnd.ms-word2006ml) and ignores runtimeMagic.remove— the postureZip::DOCUMENT_FAMILIESestablished in Sniff ZIP central directories to identify OOXML documents #169.magic/application/xml/xml.xml's<_/>root matches no key. A test pins these.text/plain(marcel stays onapplication/xml); a bounded token-level scanner instead of a full SAX parse, so it enforces encoding validity and prolog token rules but does not resolve character references inxmlns, apply<!ATTLIST>#FIXEDdefaults, normalize attribute values, or validate DOCTYPE grammar beyond bracket/quote/comment/PI structure — each divergence is pinned asapplication/xmlin the tests. Normalization can never change a verdict anyway: no key contains whitespace.seek/size.Tests
test/xml_sniffing_test.rb(49 tests): happy paths for every vocabulary family above, canonicalization, Tika-fidelity cases, a prolog gauntlet (comment-first, internal DTD subset with quoted>and commented', PI before root, multi-line start-tag with a non-ASCII attribute name,xmlns="", all three BOMs), encoding strictness (invalid UTF-8 in a comment / in an unrelated attribute / after the root, lone UTF-16 surrogate in a comment, split surrogate pair at the cut, ISO-8859-1 and Shift_JIS validated as declared, unknown/pseudo/dummy encodings), prolog well-formedness (<?XML, missing version, duplicate and misplaced declarations, full-declaration grammar with positive standalone/spacing controls,--in comments incl. inside the internal subset), fail-closed cases (start-tag straddling the 64KB limit, root beyond it, garbage after the prolog, unterminated comment/DOCTYPE/tag, odd-length UTF-16 truncation inside vs. after the root), bounded-time adversarial prologs, a 10MB document asserting exactlyMAX_SCANbytes read, the gap-2 regression (name: "feed.rss"on generic XML →application/rss+xml), sibling-hint non-override, fixture regressions, andio.pos == 0after refine. Generator tests cover collision resolution, the[nil, name]key shape, parameterized values, and rejection of code-shaped names/namespaces. Three new authored fixtures (rss, atom, kml) ride the magic sweep.bundle exec rake(test +tables:check): 659 runs, 1660 assertions, green; the branch is rebased onto #174's merge (90855e0), so the reviewed tree is the merge tree. Zero runtime dependencies unchanged.