<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://klarfakt.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://klarfakt.dev/" rel="alternate" type="text/html" /><updated>2026-09-07T18:34:37+00:00</updated><id>https://klarfakt.dev/feed.xml</id><title type="html">Klarfakt</title><subtitle>Read and validate EN 16931 electronic invoices in .NET. No Java, no Node, nothing leaves your server.</subtitle><author><name>Oleh Yanytskyi</name></author><entry><title type="html">Validating European e-invoices in .NET, without a JVM</title><link href="https://klarfakt.dev/2026/09/en16931-without-java/" rel="alternate" type="text/html" title="Validating European e-invoices in .NET, without a JVM" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://klarfakt.dev/2026/09/en16931-without-java</id><content type="html" xml:base="https://klarfakt.dev/2026/09/en16931-without-java/"><![CDATA[<p>Since 1 January 2025, every company in Germany must be able to receive an
electronic invoice. Not send one — that comes later, in stages — but receive one,
today, with no revenue threshold and no exemption for small businesses. Belgium
went live in January 2026. Poland in February. France starts in September 2026.</p>

<p>If you write software that receives invoices, someone is going to send you a file
and expect you to know whether it is valid. This is a note about doing that in
.NET, which until about six months ago meant running Java.</p>

<h2 id="what-valid-actually-involves">What “valid” actually involves</h2>

<p>EN 16931 is the European standard for the semantic content of an invoice. It says
nothing about file format directly; it defines around 200 business terms (BT-1 is
the invoice number, BT-112 the total with VAT) and the rules between them.</p>

<p>A real invoice then arrives in one of two syntaxes — OASIS UBL 2.1 or UN/CEFACT
CII D16B — and usually claims to follow a national profile on top of the standard.
Germany has XRechnung. Peppol has BIS Billing 3.0. France and Italy have their
own. So validating one invoice means, in order:</p>

<ol>
  <li>XML Schema. Is this structurally a UBL Invoice at all?</li>
  <li>The EN 16931 business rules. <code class="language-plaintext highlighter-rouge">BR-CO-15</code>: does the total with VAT equal the
total without VAT plus the VAT amount?</li>
  <li>The national profile’s extra rules. <code class="language-plaintext highlighter-rouge">BR-DE-15</code>: XRechnung requires a buyer
reference, which EN 16931 leaves optional.</li>
</ol>

<p>Steps 2 and 3 are published as <strong>Schematron</strong> — a rule language where each rule is
an XPath expression and a message. The publishers ship them precompiled to XSLT,
which sounds like good news until you look at the header:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;schema</span> <span class="na">queryBinding=</span><span class="s">"xslt2"</span> <span class="na">xmlns=</span><span class="s">"http://purl.oclc.org/dsdl/schematron"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<h2 id="the-reason-everyone-shipped-a-jvm">The reason everyone shipped a JVM</h2>

<p><code class="language-plaintext highlighter-rouge">System.Xml.Xsl.XslCompiledTransform</code> implements XSLT 1.0. Only XSLT 1.0. There
has never been an XSLT 2.0 implementation in the .NET base class library and
Microsoft has been clear that there will not be one.</p>

<p>The EN 16931 artefacts use XSLT 2.0 constructs throughout, and the German ones
reach further still — Saxon’s <code class="language-plaintext highlighter-rouge">Q{namespace}name</code> notation shows up in the location
paths they emit. There is no rewriting your way around this. You need a real
XSLT 2.0/3.0 processor.</p>

<p>So every option involved somebody else’s runtime:</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>What it costs you</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>KoSIT validationtool</td>
      <td>a JVM in your image, and a process to shell out to</td>
    </tr>
    <tr>
      <td>SaxonJS</td>
      <td>Node.js, plus converting the stylesheets to <code class="language-plaintext highlighter-rouge">.sef.json</code> first</td>
    </tr>
    <tr>
      <td>Hosted validation APIs</td>
      <td>your customers’ invoice data leaves your infrastructure</td>
    </tr>
  </tbody>
</table>

<p>That last row is the one that mattered for the people I ended up talking to. An
on-premise German accounting product cannot post its customers’ invoices to a
third party for checking, and adding a JVM to a .NET deployment because of one XSLT
file is the kind of thing that gets an architecture rejected.</p>

<p>I found a repository that captured the situation exactly: someone had created
<code class="language-plaintext highlighter-rouge">einvoice-validator</code> in May 2026, a .NET wrapper that shells out to the KoSIT
validator in Docker. The README says it wraps the reference validator “instead of
implementing Peppol business rules in application code”. Two commits, zero stars,
abandoned after two days. Somebody needed this, found nothing, built a Java
wrapper by hand, and walked away.</p>

<h2 id="what-changed">What changed</h2>

<p><strong>SaxonCS-HE 13.0.0</strong>, released 29 May 2026 under MPL-2.0. It is a native .NET
build of Saxon — not IKVM, not a Java transpile — and it is the first
licence-free XSLT 3.0 processor for .NET. Before Saxon 13, SaxonCS was
Enterprise-only.</p>

<p>That is the whole unlock. The gap was never mysterious; the thing that closed it
simply did not exist until recently.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">processor</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Processor</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">executable</span> <span class="p">=</span> <span class="n">processor</span><span class="p">.</span><span class="nf">NewXsltCompiler</span><span class="p">().</span><span class="nf">Compile</span><span class="p">(</span><span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="n">stylesheetPath</span><span class="p">));</span>
<span class="kt">var</span> <span class="n">transformer</span> <span class="p">=</span> <span class="n">executable</span><span class="p">.</span><span class="nf">Load30</span><span class="p">();</span>
</code></pre></div></div>

<p>Compiling the 895 KB <code class="language-plaintext highlighter-rouge">EN16931-UBL-validation.xslt</code> takes about two seconds warm,
six cold. Validating an invoice afterwards takes <strong>27 milliseconds</strong>. That ratio is
the single most important architectural fact: compile once, keep the
<code class="language-plaintext highlighter-rouge">XsltExecutable</code> for the life of the process, and never do it per request.</p>

<h2 id="four-things-that-will-cost-you-an-afternoon">Four things that will cost you an afternoon</h2>

<p>These are the ones I lost time to. None of them are in any tutorial.</p>

<p><strong>The context item is absent.</strong> Load the KoSIT stylesheets and the first
transformation fails with <code class="language-plaintext highlighter-rouge">XPDY0002</code>. The Schematron artefacts declare global
variables that select from the document root, and <code class="language-plaintext highlighter-rouge">Xslt30Transformer</code> does not
infer a global context item from the node you pass to <code class="language-plaintext highlighter-rouge">ApplyTemplates</code>. You have
to set it yourself:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">transformer</span> <span class="p">=</span> <span class="n">executable</span><span class="p">.</span><span class="nf">Load30</span><span class="p">();</span>
<span class="n">transformer</span><span class="p">.</span><span class="n">GlobalContextItem</span> <span class="p">=</span> <span class="n">input</span><span class="p">;</span>   <span class="c1">// not optional</span>
<span class="n">transformer</span><span class="p">.</span><span class="nf">ApplyTemplates</span><span class="p">(</span><span class="n">input</span><span class="p">,</span> <span class="n">destination</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>XmlSchemaSet will not compile the UBL schemas the obvious way.</strong> <code class="language-plaintext highlighter-rouge">XmlResolver</code> is
null by default on modern .NET, so <code class="language-plaintext highlighter-rouge">xsd:import</code> resolves to nothing and compilation
fails. Set a resolver and it fails differently: the UBL modules reference each
other’s prefixes without always importing them, and following imports as well as
adding files loads some namespaces twice.</p>

<p>What works is to add every module explicitly with resolution turned off:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="k">set</span> <span class="p">=</span> <span class="k">new</span> <span class="n">XmlSchemaSet</span> <span class="p">{</span> <span class="n">XmlResolver</span> <span class="p">=</span> <span class="k">null</span> <span class="p">};</span>
<span class="kt">var</span> <span class="n">settings</span> <span class="p">=</span> <span class="k">new</span> <span class="n">XmlReaderSettings</span> <span class="p">{</span> <span class="n">DtdProcessing</span> <span class="p">=</span> <span class="n">DtdProcessing</span><span class="p">.</span><span class="n">Parse</span><span class="p">,</span> <span class="n">XmlResolver</span> <span class="p">=</span> <span class="k">null</span> <span class="p">};</span>

<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">name</span> <span class="k">in</span> <span class="n">schemaFiles</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">using</span> <span class="nn">var</span> <span class="n">reader</span> <span class="p">=</span> <span class="n">XmlReader</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">settings</span><span class="p">);</span>
    <span class="k">set</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">XmlSchema</span><span class="p">.</span><span class="nf">Read</span><span class="p">(</span><span class="n">reader</span><span class="p">,</span> <span class="k">null</span><span class="p">));</span>
<span class="p">}</span>

<span class="k">set</span><span class="p">.</span><span class="nf">Compile</span><span class="p">();</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DtdProcessing.Parse</code> is needed because the xmldsig core schema declares an
internal DTD of entities. Invoices themselves are still read with DTDs prohibited
outright — that is a different trust boundary.</p>

<p><strong>Do not tidy the XML.</strong> I spent an evening on a rule that fired locally and not in
the official test suite. The cause was <code class="language-plaintext highlighter-rouge">XDocument.ToString()</code>, which pretty-prints
by default. Indenting a fragment inserts whitespace text nodes, which changes the
string value of elements, which silently flips any rule that measures content
length. <code class="language-plaintext highlighter-rouge">SaveOptions.DisableFormatting</code> fixes it. For the same reason, do not strip
comments or processing instructions: a validator has to judge the document as it
arrived.</p>

<p><strong>A Schematron location is the rule’s context, not the value.</strong> When I started
reporting the offending value alongside each finding, I resolved the location
XPath and took its string value. For <code class="language-plaintext highlighter-rouge">BR-CL-04</code> — an invalid currency code — that
gives you <code class="language-plaintext highlighter-rouge">"XYZ"</code>, which is exactly what you want. For <code class="language-plaintext highlighter-rouge">BR-CO-15</code>, which compares
three totals, the location is <code class="language-plaintext highlighter-rouge">/Invoice</code> and the string value is <em>every scrap of
text in the entire document</em>. Adding <code class="language-plaintext highlighter-rouge">[not(*)]</code> to the XPath, so only leaf nodes
report a value, was the difference between a useful field and an unusable one.</p>

<h2 id="proving-it-rather-than-asserting-it">Proving it, rather than asserting it</h2>

<p>Anyone can run a Schematron file and print the output. The interesting question is
whether your verdicts match the verdicts that matter.</p>

<p>Two things settle it.</p>

<p>CEN publishes a test suite: 309 files, each targeting one rule, each declaring
which rules must fire and which must not. Replaying every expectation proves
agreement per rule rather than in aggregate. It also turned up something I would
not have guessed — CEN ships test files for <code class="language-plaintext highlighter-rouge">BR-CO-25</code> while its own Schematron
never implements that rule. My test suite pins that exact set, so the day CEN adds
it, the build says so.</p>

<p>Then, because Germany is the market, I run KoSIT’s own validationtool over the
same invoices in CI and diff the rule ids against mine. Same scenario
configuration release on both sides — read from my manifest, so it cannot drift
onto a different version and produce disagreements that mean nothing.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>comparing 143 files against itplr-kosit/validator@v1.6.3
100/100 comparable files agree rule for rule with the reference validator (43 not comparable)
</code></pre></div></div>

<p>The 43 are recorded with the reason each cannot be compared, usually that the
XRechnung configuration matched no scenario for that file. A report with no
matched scenario is the reference <em>declining to judge</em>, which is not the same as
judging clean, and counting those as agreement would have inflated the number for
nothing.</p>

<p>Java runs the reference. It is not needed to use the library — CI keeping a JDK
around to check my work does not change that.</p>

<h2 id="where-the-artefacts-live">Where the artefacts live</h2>

<p>One design decision worth explaining, because it looks like an inconvenience.</p>

<p>The rule artefacts are not in the package. The CEN rules are EUPL-1.2, the KoSIT
configurations are Apache-2.0, and they are not mine to redistribute. So the
package embeds a manifest naming every artefact, its upstream release tag, its
licence and its SHA-256, and downloads them on request:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>klarfakt rules restore
</code></pre></div></div>

<p>That runs once — at deployment, or in a Docker build stage so the running
container never reaches the network. Nothing else in the library makes a network
call. A pack is checked against the manifest the first time it is used, so an
artefact altered on disk stops validation rather than quietly changing a verdict.</p>

<h2 id="where-this-leaves-things">Where this leaves things</h2>

<p>Validating an EN 16931 invoice in .NET is now an ordinary problem. No JVM, no Node
process, no uploading a customer’s invoice to somebody else’s API. The library I
built is <a href="https://github.com/yivo-0/Klarfakt">Klarfakt</a> — free below €1,000,000
of annual revenue and free inside OSI-licensed open source, €690 a year above that — but the more useful
thing in this post is probably the four gotchas, which apply to anyone running
Schematron on .NET regardless of what they build with it.</p>

<p>If you are running EN 16931 validation in production some other way, I would
genuinely like to hear what it cost you. That is the part nobody writes down.</p>]]></content><author><name>Oleh Yanytskyi</name></author><summary type="html"><![CDATA[Why XslCompiledTransform cannot run the EN 16931 artefacts, what SaxonCS-HE 13 changed, and four things that cost an afternoon.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klarfakt.dev/social-card.png" /><media:content medium="image" url="https://klarfakt.dev/social-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Four hundred bytes</title><link href="https://klarfakt.dev/2026/09/four-hundred-bytes/" rel="alternate" type="text/html" title="Four hundred bytes" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://klarfakt.dev/2026/09/four-hundred-bytes</id><content type="html" xml:base="https://klarfakt.dev/2026/09/four-hundred-bytes/"><![CDATA[<p>This file is 424 bytes. It kills a .NET process.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>%PDF-1.7
1 0 obj &lt;&lt; /Type /Catalog /Pages 2 0 R /Names &lt;&lt; /EmbeddedFiles 4 0 R &gt;&gt; &gt;&gt; endobj
2 0 obj &lt;&lt; /Type /Pages /Kids [3 0 R] /Count 1 &gt;&gt; endobj
3 0 obj &lt;&lt; /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] &gt;&gt; endobj
4 0 obj &lt;&lt; /Kids [4 0 R] &gt;&gt; endobj
...
</code></pre></div></div>

<p>Look at object 4. Its <code class="language-plaintext highlighter-rouge">/Kids</code> array contains a reference to object 4.</p>

<p>I found this in my own code, which makes it a better story than if I had found it
in somebody else’s.</p>

<h2 id="why-i-was-walking-a-pdf-at-all">Why I was walking a PDF at all</h2>

<p>I maintain a library that reads European e-invoices. Since January 2025 every
German company has to be able to <em>receive</em> one, and one of the formats they
receive is Factur-X: an ordinary-looking PDF with the invoice also embedded
inside it as XML. Human reads the PDF, machine reads the XML, one file.</p>

<p>To get at the XML you look in the document catalogue for <code class="language-plaintext highlighter-rouge">/Names</code>, then
<code class="language-plaintext highlighter-rouge">/EmbeddedFiles</code>. What you find there is a PDF name tree — a structure that is
either a leaf holding a <code class="language-plaintext highlighter-rouge">/Names</code> array of key-value pairs, or a branch holding a
<code class="language-plaintext highlighter-rouge">/Kids</code> array of more nodes.</p>

<p>A tree. So I wrote the obvious thing:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">static</span> <span class="k">void</span> <span class="nf">CollectNameTree</span><span class="p">(</span><span class="n">PdfDictionary</span> <span class="n">node</span><span class="p">,</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">byte</span><span class="p">[</span><span class="k">]&gt;</span> <span class="n">attachments</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">names</span> <span class="p">=</span> <span class="n">node</span><span class="p">.</span><span class="n">Elements</span><span class="p">.</span><span class="nf">GetArray</span><span class="p">(</span><span class="s">"/Names"</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">names</span> <span class="k">is</span> <span class="n">not</span> <span class="k">null</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// ... pull out the attachments</span>
    <span class="p">}</span>

    <span class="kt">var</span> <span class="n">kids</span> <span class="p">=</span> <span class="n">node</span><span class="p">.</span><span class="n">Elements</span><span class="p">.</span><span class="nf">GetArray</span><span class="p">(</span><span class="s">"/Kids"</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">kids</span> <span class="k">is</span> <span class="k">null</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>

    <span class="k">for</span> <span class="p">(</span><span class="kt">var</span> <span class="n">index</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">index</span> <span class="p">&lt;</span> <span class="n">kids</span><span class="p">.</span><span class="n">Elements</span><span class="p">.</span><span class="n">Count</span><span class="p">;</span> <span class="n">index</span><span class="p">++)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">kid</span> <span class="p">=</span> <span class="n">kids</span><span class="p">.</span><span class="n">Elements</span><span class="p">.</span><span class="nf">GetDictionary</span><span class="p">(</span><span class="n">index</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">kid</span> <span class="k">is</span> <span class="n">not</span> <span class="k">null</span><span class="p">)</span> <span class="nf">CollectNameTree</span><span class="p">(</span><span class="n">kid</span><span class="p">,</span> <span class="n">attachments</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I was pleased with this. It handles both shapes, it is short, and it reads like
the specification.</p>

<p>It is also a straight line to a dead process, because nothing in a PDF file
prevents object 4 from listing itself as its own child. The format is a graph of
numbered objects that reference each other. Nobody validates that the subgraph
you happen to be treating as a tree is actually a tree. The producer of the file
decides what is in it, and in my case the producer is whoever emailed my user an
invoice.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Stack overflow.
   at Klarfakt.Reading.PdfAttachments.CollectNameTree(...)
   at Klarfakt.Reading.PdfAttachments.CollectNameTree(...)
   at Klarfakt.Reading.PdfAttachments.CollectNameTree(...)
   ...
</code></pre></div></div>

<h2 id="the-part-that-actually-stung">The part that actually stung</h2>

<p>I had a handler. I had written it deliberately, with a comment explaining itself:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// A malformed PDF can fail anywhere inside the reader, and PDFsharp does not confine itself</span>
<span class="c1">// to PdfReaderException — a truncated file raises ArgumentOutOfRangeException. Callers get</span>
<span class="c1">// one exception type for "this file is not usable" rather than whatever surfaced.</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">exception</span><span class="p">)</span> <span class="nf">when</span> <span class="p">(</span><span class="n">exception</span> <span class="k">is</span> <span class="nf">not</span> <span class="p">(</span><span class="n">UnsupportedDocumentException</span> <span class="n">or</span> <span class="n">OutOfMemoryException</span><span class="p">))</span>
</code></pre></div></div>

<p>It does nothing here. Since .NET 2.0, a <code class="language-plaintext highlighter-rouge">StackOverflowException</code> cannot be caught.
The runtime does not raise it as an exception you may handle; it terminates the
process. Not the request, not the thread — the process. In a container handling a
queue of inbound invoices, one crafted attachment takes down the worker and
everything else it was in the middle of.</p>

<p>Every defensive habit I have was pointing at the wrong thing. I was thinking about
what happens if the <em>parser</em> fails. The parser was fine. It handed me a perfectly
well-formed object graph and I walked off the end of the stack myself.</p>

<p>The fix is not clever:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">pending</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Stack</span><span class="p">&lt;</span><span class="n">PdfDictionary</span><span class="p">&gt;();</span>
<span class="kt">var</span> <span class="n">seen</span> <span class="p">=</span> <span class="k">new</span> <span class="n">HashSet</span><span class="p">&lt;</span><span class="kt">object</span><span class="p">&gt;(</span><span class="n">ReferenceEqualityComparer</span><span class="p">.</span><span class="n">Instance</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">visited</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span>

<span class="n">pending</span><span class="p">.</span><span class="nf">Push</span><span class="p">(</span><span class="n">root</span><span class="p">);</span>

<span class="k">while</span> <span class="p">(</span><span class="n">pending</span><span class="p">.</span><span class="n">Count</span> <span class="p">&gt;</span> <span class="m">0</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(++</span><span class="n">visited</span> <span class="p">&gt;</span> <span class="n">limits</span><span class="p">.</span><span class="n">MaxPdfNameTreeNodes</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">UnsupportedDocumentException</span><span class="p">(...);</span>

    <span class="kt">var</span> <span class="n">node</span> <span class="p">=</span> <span class="n">pending</span><span class="p">.</span><span class="nf">Pop</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(!</span><span class="n">seen</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">node</span><span class="p">))</span> <span class="k">continue</span><span class="p">;</span>

    <span class="c1">// ... same body as before, pushing kids instead of recursing</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Iterative, so there is no stack to overflow. A visited set, so a cycle terminates.
And a node cap, because a visited set alone still happily walks a million-node
chain that isn’t a cycle at all, just long. Both guards earn their place; I tested
each with its own hand-built file.</p>

<h2 id="then-i-found-the-second-one">Then I found the second one</h2>

<p>Feeling thorough, I went looking for the same <em>class</em> of problem elsewhere, and
found it about ten metres away.</p>

<p>The library has a configurable cap on attachment size. Sensible. Here is how it
was applied:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">bytes</span> <span class="p">=</span> <span class="n">content</span><span class="p">?.</span><span class="n">Stream</span><span class="p">?.</span><span class="n">UnfilteredValue</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="n">bytes</span><span class="p">.</span><span class="n">LongLength</span> <span class="p">&gt;</span> <span class="n">limits</span><span class="p">.</span><span class="n">MaxAttachmentBytes</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">UnsupportedDocumentException</span><span class="p">(</span><span class="s">$"The attachment is </span><span class="p">{</span><span class="n">bytes</span><span class="p">.</span><span class="n">LongLength</span><span class="p">:</span><span class="n">N0</span><span class="p">}</span><span class="s"> bytes..."</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">UnfilteredValue</code> is PDFsharp’s “give me the decompressed bytes” property. So the
check reads: allocate the entire attachment, then decide whether we were willing
to allocate it.</p>

<p>Embedded files in a PDF are usually Flate-compressed. Deflate tops out around
1000:1 on repetitive input. I generated a 261 KB PDF whose attachment expands to
256 MB, and the library dutifully allocated all 256 MB before refusing it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The attachment 'factur-x.xml' is 268,435,456 bytes, over the 33,554,432 byte limit.
</code></pre></div></div>

<p>It knows the exact size. That is how you can tell it paid for it.</p>

<p>Scaling up, a 2.6 MB file produced a 2.5 GB attachment and died differently —
<code class="language-plaintext highlighter-rouge">IOException: Stream was too long</code>, because .NET refuses to grow a <code class="language-plaintext highlighter-rouge">MemoryStream</code>
past about 2 GB. Which is a kind of accidental safety net, if your definition of
safe includes a two-gigabyte allocation on a machine that may only have four.</p>

<p>The fix was to stop using the convenient property and inflate it myself, a block
at a time, checking as I go:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">while</span> <span class="p">((</span><span class="n">read</span> <span class="p">=</span> <span class="n">inflater</span><span class="p">.</span><span class="nf">Read</span><span class="p">(</span><span class="n">chunk</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">chunk</span><span class="p">.</span><span class="n">Length</span><span class="p">))</span> <span class="p">&gt;</span> <span class="m">0</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">buffer</span><span class="p">.</span><span class="n">Length</span> <span class="p">+</span> <span class="n">read</span> <span class="p">&gt;</span> <span class="n">limits</span><span class="p">.</span><span class="n">MaxAttachmentBytes</span><span class="p">)</span> <span class="k">throw</span> <span class="nf">TooLarge</span><span class="p">(...);</span>
    <span class="n">buffer</span><span class="p">.</span><span class="nf">Write</span><span class="p">(</span><span class="n">chunk</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">read</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both bomb files now stop at 33.5 MB. They also finish <em>faster</em> than before —
1.2 seconds instead of 5.5 — because nothing large is ever materialised. That is
usually a sign the earlier version was doing something silly.</p>

<p>One caveat I left in deliberately: this path only handles plain Flate with no
decode parameters, which is what every hybrid invoice in my test corpus uses.
Anything more exotic — a predictor, a chain of filters — still goes through
PDFsharp with the check afterwards. Guessing wrong about an unusual filter would
mean failing to read a legitimate invoice in order to save memory, and that is
the wrong trade for a compliance tool.</p>

<h2 id="what-i-took-from-it">What I took from it</h2>

<p><strong>A limit you check afterwards is not a limit.</strong> It is a report. If the expensive
thing has already happened by the time you test the condition, you have written
an assertion, not a guard.</p>

<p><strong>Know which exceptions your runtime will not let you catch.</strong> <code class="language-plaintext highlighter-rouge">StackOverflowException</code>
is the obvious one and it is genuinely different from everything else in .NET: no
<code class="language-plaintext highlighter-rouge">catch</code>, no <code class="language-plaintext highlighter-rouge">finally</code>, no <code class="language-plaintext highlighter-rouge">AppDomain.UnhandledException</code>. If untrusted input can
reach recursive code, the recursion is the bug, not the missing handler.</p>

<p><strong>“It’s a tree” is an assumption about the producer, not the format.</strong> PDF, XML
with entity references, JSON with <code class="language-plaintext highlighter-rouge">$ref</code>, protobuf with recursive messages — in
every case the shape you are relying on is a convention the sender can decline to
follow. My name-tree walker was correct for every file produced by software that
wasn’t trying to hurt me.</p>

<p><strong>Test the guard, not the happy path.</strong> Both of these were found by sitting down
and writing files specifically designed to break my own code: a self-referencing
node, a two-node cycle, a node that is its own grandchild, a 6,000-node chain, and
two compression bombs. All of them are in the test suite now. None of them would
have appeared in any corpus of real invoices, which is exactly why 521 files of
real corpus told me nothing about either bug.</p>

<p>The library is <a href="https://github.com/yivo-0/Klarfakt">Klarfakt</a>, if you want to
see the fixes in context. But the two mistakes are not specific to PDFs or to
e-invoicing, which is why I wrote this instead of a release note.</p>]]></content><author><name>Oleh Yanytskyi</name></author><summary type="html"><![CDATA[A 424-byte PDF that terminates a .NET process, why a catch cannot help, and the compression bomb next to it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klarfakt.dev/social-card.png" /><media:content medium="image" url="https://klarfakt.dev/social-card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>