<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pal</title>
    <description>The latest articles on DEV Community by Pal (@pal11103).</description>
    <link>https://dev.to/pal11103</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064494%2F6f4f7f5b-f3c7-4a7a-9f1c-09664c16f659.png</url>
      <title>DEV Community: Pal</title>
      <link>https://dev.to/pal11103</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pal11103"/>
    <language>en</language>
    <item>
      <title>Your database is not an audit log: 72 hours of verifiable hackathon judging</title>
      <dc:creator>Pal</dc:creator>
      <pubDate>Tue, 29 Sep 2026 18:32:47 +0000</pubDate>
      <link>https://dev.to/pal11103/your-database-is-not-an-audit-log-72-hours-of-verifiable-hackathon-judging-1bke</link>
      <guid>https://dev.to/pal11103/your-database-is-not-an-audit-log-72-hours-of-verifiable-hackathon-judging-1bke</guid>
      <description>&lt;p&gt;Most hackathon portals answer one question — &lt;em&gt;who won?&lt;/em&gt; — with a row in a database. The row renders, everyone moves on, and nobody asks the harder question that no row can answer on its own: &lt;em&gt;how would you know if it were wrong?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dogfood Portal&lt;/strong&gt; is a self-hostable platform for running a hackathon's submissions and judging: teams submit, judges score, and it publishes a final leaderboard. I gave myself two rules for the 72 hours. It has to come up from a single &lt;code&gt;docker compose up&lt;/code&gt; with the network unplugged — no Redis, no message broker, no background worker, no CDN, two services in the compose file and one of them is Postgres. And the results it publishes have to be verifiable by anyone, offline, without trusting my server. The second rule is the whole post.&lt;/p&gt;

&lt;p&gt;Here is the thing that rule forces you to admit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A hackathon portal's "final results" are a &lt;code&gt;SELECT&lt;/code&gt; over a table. Anyone who can reach the database can &lt;code&gt;UPDATE&lt;/code&gt; a score after the fact, and the results page will render the new number exactly as confidently as the old one. Nothing on the page knows it changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not a Django problem and it is not a Postgres problem. A database stores current state; it was never a record of what happened. Treating "it's in the database" as "it's the result" is a quiet category error — reaching for a tool whose contract is &lt;em&gt;close&lt;/em&gt; to the one you want, reading storage as if it were proof, and shipping the gap without noticing it is there. So the results here carry their own proof, and there is one command that re-checks all of it with the database container stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Dogfood Portal is a single Django 5.2 + PostgreSQL 16 monolith — one &lt;code&gt;web&lt;/code&gt; service, one &lt;code&gt;db&lt;/code&gt; service, nothing else. It boots offline, runs the migrations, seeds a demo event (an organizer, judges, teams, and scored submissions) on first start, and serves a public project gallery, a judging flow, and a normalized leaderboard. The interesting half is underneath: the final results are exported as a signed, reproducible bundle, and there is an offline verifier that re-derives and re-checks the whole thing from that bundle alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; web python src/manage.py release_bundle /state/release
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /app/src web python &lt;span class="nt"&gt;-m&lt;/span&gt; normalize.release /state/release
&lt;span class="go"&gt;[PASS] ranking.csv matches the signed run
[PASS] signed run reproduces from the pinned ballots
[PASS] normalization.published event is on the audit chain
[PASS] audit checkpoint signature verifies
[PASS] every artifact traces to one operator public key
RESULT: VERIFIED
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second command needs no database and no network. It is the point of the project, so the rest of this post is what "verifiable" had to actually mean, and what generalizes past one Django app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a database won't do for you
&lt;/h2&gt;

&lt;p&gt;The naive design is one table: &lt;code&gt;results&lt;/code&gt;, with a rank and a score per project, written when the organizer clicks &lt;em&gt;publish&lt;/em&gt;. It renders fine. It is also unfalsifiable — there is nothing to check it &lt;em&gt;against&lt;/em&gt;, because the row is simultaneously the claim and the only evidence for the claim.&lt;/p&gt;

&lt;p&gt;So publishing does not write a row you have to trust. It writes a chain you can follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the normalized ranking is computed and frozen as a &lt;strong&gt;signed run&lt;/strong&gt; — the pinned input ballots, the model settings, the resulting scores, and an &lt;strong&gt;Ed25519&lt;/strong&gt; signature over all of it;&lt;/li&gt;
&lt;li&gt;that publication is recorded as a &lt;code&gt;normalization.published&lt;/code&gt; event on an &lt;strong&gt;append-only audit hash-chain&lt;/strong&gt;, where each event carries the hash of the one before it;&lt;/li&gt;
&lt;li&gt;the chain head is sealed with a &lt;strong&gt;signed checkpoint&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the exported &lt;code&gt;ranking.csv&lt;/code&gt; is bound back to the signed run;&lt;/li&gt;
&lt;li&gt;and every signature in the bundle traces to &lt;strong&gt;one operator public key&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The verifier walks that chain backwards. It re-runs the estimator from the pinned ballots and checks the result it gets matches the signed one; it re-checks every signature; it confirms the published event actually sits on the chain and that the checkpoint over the chain verifies; it confirms one key signed all of it. Any broken link fails the run and names itself — &lt;code&gt;signed run does NOT reproduce&lt;/code&gt;, not a generic false. With the &lt;code&gt;db&lt;/code&gt; container stopped, from the bundle alone, on a machine that has never seen my server.&lt;/p&gt;

&lt;p&gt;Everything that verifier needs travels in one exported bundle: the ranking CSV, the signed run with its pinned inputs and result, the audit checkpoint and the slice of chain leading up to it, and the single public key the whole thing is signed under. The export self-verifies before it will finish writing, so a bundle that does not check out is never produced in the first place. For the everyday case — "prove to me this leaderboard is the one you actually published" — there is a smaller &lt;code&gt;dogfood.results-certificate.v1&lt;/code&gt; you can hand to a single team, and it verifies through the same path. The bundle is the audit; the certificate is the receipt; both are checked by code, not taken on faith.&lt;/p&gt;

&lt;p&gt;The transferable version is small: &lt;strong&gt;sign the result; don't ask people to trust the server.&lt;/strong&gt; If the only evidence for a number is the system that produced it, you have not published a result, you have published an assertion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement: a leaderboard implies a precision the ballots don't have
&lt;/h2&gt;

&lt;p&gt;Raw judge scores are not comparable. One judge's 7 is another's 9, and if two projects were seen by different judges, their averages are measuring the judges as much as the work. So the leaderboard is not a mean — it is a ridge / partial-pooling estimate that models each ballot as &lt;code&gt;quality + judge_bias + noise&lt;/code&gt;, fits it with a gauge that pins each connected group of judges-and-projects to a common zero, and picks the regularization strength by cross-validation rather than by taste.&lt;/p&gt;

&lt;p&gt;That gauge is the subtle part, and it is where most naive score-normalization quietly goes wrong. Judge biases and project qualities are only defined relative to each other, so the model has a free parameter you have to pin down or the numbers wander: add a constant to every judge's bias and subtract it from every quality, and nothing observable changes. The fix is to require each connected group of judges-and-projects to sum to zero. "Connected" is meant literally — two projects are in the same group if a chain of shared judges links them — and it carries an honest limit worth stating out loud: two projects that share no judge, even transitively, live in different groups with no common zero, so the model refuses to rank them against each other rather than inventing a bridge. On this event everything was one connected component, so the limit never bit; the code enforces it regardless, and a self-check confirms the gauge sums return to zero within floating-point noise — about &lt;code&gt;1e-17&lt;/code&gt; — before a run is ever signed.&lt;/p&gt;

&lt;p&gt;On the seed event — 126 ballots, 41 projects, 30 judges across 8 tracks, all one connected component — this is not cosmetic: cross-validation lands on λ = 10, and correcting for judge bias is enough to break a tie that the raw averages leave at the top.&lt;/p&gt;

&lt;p&gt;Then it measures its own confidence, and this is the number I did not get to choose:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Across all 40 adjacent pairs on the leaderboard, not one is statistically resolved. For the closest, the model's probability that the higher-ranked project is actually better than the one just below it is about &lt;strong&gt;0.518&lt;/strong&gt; — a coin flip. Correcting for judge bias tightened within-submission spread by about &lt;strong&gt;1.08×&lt;/strong&gt;, not the dramatic factor a demo would prefer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The honest reading is that these judges were fairly consistent and the field was close, so a strict ranking would be inventing separation the ballots do not contain. The tool therefore publishes the pairwise probabilities and intervals &lt;em&gt;next to&lt;/em&gt; the ranking, so "3rd vs 4th" reads as the coin flip it is. &lt;strong&gt;Publish the uncertainty, not just the order&lt;/strong&gt; — a ranking that hides it is the same flattering lie as a benchmark that reports its fastest run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that only appears after a round-trip through Postgres
&lt;/h2&gt;

&lt;p&gt;The signed run is supposed to be reproducible: sign it once, and anyone re-running the estimator on the same ballots should land on the same bytes and the same signature. It worked in memory and failed the moment the run was reloaded to verify it.&lt;/p&gt;

&lt;p&gt;The cause was &lt;code&gt;-0.0&lt;/code&gt;. The gauge that pins each group to a common zero legitimately produces negative-zero components; in memory that is what got signed. But the run is stored as &lt;code&gt;jsonb&lt;/code&gt;, and the reload path normalizes &lt;code&gt;-0.0&lt;/code&gt; to &lt;code&gt;0.0&lt;/code&gt;. So the verifier hashed a projection that differed from the signed one in a byte that is numerically invisible — &lt;code&gt;-0.0 == 0.0&lt;/code&gt; is true — and the signature check failed for a reason no arithmetic could see.&lt;/p&gt;

&lt;p&gt;The fix was not to special-case the comparison. It was to normalize negative zero &lt;em&gt;inside the projection that gets hashed&lt;/em&gt;, before signing, so the run signs exactly what the database will hand back. &lt;strong&gt;Hash what you'll read back, not what you happen to be holding&lt;/strong&gt; — a signature over an in-memory object is a signature over a serialization decision you forgot you were making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Append-only, not delete
&lt;/h2&gt;

&lt;p&gt;A score is never edited in place. Each revision is an append-only row keyed &lt;code&gt;UNIQUE(ballot, version)&lt;/code&gt;, with a database &lt;code&gt;CHECK&lt;/code&gt; keeping every score in 1–5, and the current ballot is just a pointer at the latest version. A withdrawal is a soft state flip, not a &lt;code&gt;DELETE&lt;/code&gt;, and the admin path refuses to cascade-delete anything a scored ballot depends on. History survives the people who would rather it didn't.&lt;/p&gt;

&lt;p&gt;Underneath the ballots is the log that makes them evidence rather than just rows: an append-only audit chain on a schema I froze on purpose, so its meaning cannot drift out from under old events. There is a single head row and a stream of events, and each event stores its own payload, the hash of that payload, the hash of the event before it, and a row hash over the whole record — the ordinary hash-chain shape, where altering any past event breaks every hash that follows it. Writing one event takes a &lt;code&gt;select_for_update&lt;/code&gt; lock on the head so two concurrent writers cannot fork the chain, links the new event to the current head, and advances the head — all inside a single database transaction.&lt;/p&gt;

&lt;p&gt;The load-bearing claim there is that the data write and its audit event are one atomic thing — that you can't get the score without the chain entry, or vice-versa. The way I believe that is a test that can fail: it monkeypatches the audit chain head's &lt;code&gt;save()&lt;/code&gt; to raise mid-transaction, then writes a ballot. Afterwards, the ballot, the judge assignment, &lt;em&gt;and&lt;/em&gt; the audit event are all absent, and the chain head is still at sequence 0. A test that cannot fail is decoration; this one holds a real invariant, because it forces the exact failure the invariant is supposed to survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permission is a grant on an event, not a role
&lt;/h2&gt;

&lt;p&gt;Judging platforms tend to leak along their authorization seams: a judge from one event nudges a score in another, an organizer glimpses a draft they should not. The tempting shortcut is a global role — &lt;code&gt;is_judge&lt;/code&gt;, &lt;code&gt;is_organizer&lt;/code&gt; — checked in a view. It is the same category error as the results table, convenient and wrong at exactly the edges that matter. So permission here is a grant scoped to a specific event, and it is enforced in the service layer where the write actually happens, not only in a view decorator that some second code path could quietly bypass. To keep myself honest about that, a mutation test deletes the check and expects a test to go red — if removing an authorization guard changes nothing, the guard was never load-bearing in the first place. Invitations are cut from the same cloth: each is signed and single-use, and "single-use" is enforced by taking a row lock and marking the invite spent inside the accept transaction, so two clicks on the same link cannot both succeed. The honest boundary is that this enforcement lives in the application's service layer, not yet in the database as row-level security — a deferral I wrote down on purpose rather than a thing I hope nobody notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Living without a broker
&lt;/h2&gt;

&lt;p&gt;The "no moving parts" rule means the usual answers were off the table, so a few things are hand-built that a bigger stack would outsource. Rate limiting and caching run on Django's database-backed cache — a table created at boot, not a Redis. Static files are served in-process by WhiteNoise, vendored into the image, because there is no CDN to reach on a machine with the cable pulled. And the entrypoint does in sequence what an orchestrator usually spreads across services: wait for the database, generate the secret key race-safely with &lt;code&gt;O_EXCL&lt;/code&gt; on first boot only, migrate, create the cache table, seed the demo event if the database is empty, ensure the admin exists, then hand off to gunicorn. It is less clever than a broker, and it has the property I wanted: one command, offline, and nothing to stand up first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract is replayed, not assumed
&lt;/h2&gt;

&lt;p&gt;A grader checks the portal by hitting a fixed set of routes and asserting on the responses. That contract is easy to break by accident — a refactor renames a path, a template grows a nav link, and the grade drops for a reason unrelated to the feature you were building. So the routes the grader depends on are frozen, feature routes are appended after them and never linked from the base navigation, and a stdlib-only &lt;code&gt;tools/replay.py&lt;/code&gt; re-runs the same seven checks the grader runs, from the same contract file the grader reads. It is stricter in exactly one way — it does not follow redirects, so a route that only reaches &lt;code&gt;200&lt;/code&gt; after an append-slash bounce is a visible failure rather than a silent pass — and it ends on a green &lt;code&gt;7/7 checks green&lt;/code&gt;. The compatibility claim is a thing the build re-measures, not a thing I remember being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof that it runs on a machine that isn't mine
&lt;/h2&gt;

&lt;p&gt;"Works on my machine" is the oldest lie in software, and a portal that only boots on the laptop that built it has proven nothing about whether you can run it. Two things guard against that. The first is that single &lt;code&gt;docker compose up&lt;/code&gt;: a cold clone, an empty volume, the network off, and the container brings itself all the way up — waits for the database, generates its secret, migrates, seeds the demo event, creates the admin — with no manual step I could have forgotten to document, because there is nowhere to hide one. The second is continuous integration that does exactly this on hardware that has never seen my working tree: it clones fresh and runs the suite against a real Postgres on every push, so "it boots and it passes" is a fact re-established by a stranger rather than a thing I merely remember working. The image build is gated as well — Django's own system checks at &lt;code&gt;--fail-level WARNING&lt;/code&gt;, plus a &lt;code&gt;makemigrations --check&lt;/code&gt; that fails the build when a model changed without its migration, which is precisely the mistake that turns a green local run into a broken clone. And the tests come in two deliberate layers: the pure-Python core — the hash-chain, the signing format, the estimator's math — has tests that need no database and run anywhere, while the full suite runs against Postgres in CI. &lt;strong&gt;The proof that something reproduces is someone else reproducing it&lt;/strong&gt;, so the whole build is arranged to make that the cheap and default path rather than a favor I have to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  A signature is evidence about a result, not about my character
&lt;/h2&gt;

&lt;p&gt;It is worth being precise about what the cryptography does and does not buy, because the failure mode of a security feature is someone trusting it past its contract. A valid signature proves two things: the bytes have not changed since the key-holder signed them, and anyone can re-derive the ranking from the pinned ballots and get the same answer. It does &lt;strong&gt;not&lt;/strong&gt; prove the judging was fair, that the ballots were honest, or that I did not sign a bad result on purpose. A determined operator with raw database access can still change data — the chain does not make that impossible, it makes it &lt;strong&gt;detectable&lt;/strong&gt;: the re-derivation or a hash link fails, and the verifier tells you which one. Detectable is a weaker and far more honest promise than tamper-proof, and it is the one the design can actually keep.&lt;/p&gt;

&lt;p&gt;The same restraint applies to the review diagnostics — leave-one-out residuals, judge influence, coverage gaps. They ship behind a banner that says, in the product, that they are descriptive and &lt;strong&gt;not fraud detection&lt;/strong&gt;, because a residual is a question worth a human's attention, not a verdict. &lt;strong&gt;Describe what the proof covers, and put the limit where the reader is, not in your own head.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  I made the documentation fail the build
&lt;/h2&gt;

&lt;p&gt;This is the practice that makes any number in this post safe to write. A README figure goes stale in the commit &lt;em&gt;after&lt;/em&gt; the one that earned it, and prose does not fail a build, so a handful of tests do nothing but read a document and compare it to the code — the threat-model table has to match the controls that actually exist, and a doc that names a route or a setting has to name one the code really defines. For each claim, ask what file would contradict it, then read both in one test.&lt;/p&gt;

&lt;p&gt;The honest-claims rule is the same tool pointed at myself: every capability the docs describe has to map to a feature that actually shipped in &lt;code&gt;src/&lt;/code&gt;. The week of submission that rule cost me two lines — a demo script called the replay checker a "byte-for-byte" comparison when it runs status-and-content checks and follows no redirects, and a config comment claimed a test asserted something no test asserted. Both were true-ish and both were wrong, so both got reworded before the freeze. A capability you can describe but not point at is not a feature, it is a plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;Multi-event support is partial, by a decision recorded in the threat model rather than an oversight. The diagnostics are descriptive and not fraud detection. Row-level security is deferred, so raw-database tampering is detectable, not prevented. The normalizer will tell you two adjacent ranks are a coin flip and will not pretend otherwise to make a cleaner slide. And it is one person's 72 hours — the seam is real, it is written down, and the verifier is exactly the thing that keeps the seam from being load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;git clone https://github.com/pal-123456789/dogfood-portal
cd dogfood-portal
docker compose up --build --wait
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the gallery at &lt;code&gt;/projects&lt;/code&gt; and the signed leaderboard at &lt;code&gt;/normalize/results&lt;/code&gt;, publish the results, then export a bundle and — with the &lt;code&gt;db&lt;/code&gt; container stopped — run &lt;code&gt;python -m normalize.release&lt;/code&gt; over it and watch it re-derive the ranking and check every signature offline.&lt;/p&gt;

&lt;p&gt;Prefer to watch first? Here's the walkthrough:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/-NThDgcTt-8" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/pal-123456789/dogfood-portal" rel="noopener noreferrer"&gt;pal-123456789/dogfood-portal&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs (architecture, data model, judging, ablation, threat model): &lt;a href="https://pal-123456789.github.io/dogfood-portal/" rel="noopener noreferrer"&gt;pal-123456789.github.io/dogfood-portal&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Runbook / 60-second demo: &lt;a href="https://github.com/pal-123456789/dogfood-portal/blob/main/run.md" rel="noopener noreferrer"&gt;run.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Interactive walkthrough site: &lt;a href="https://dogfood-site-eta.vercel.app/" rel="noopener noreferrer"&gt;dogfood-site-eta.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Video walkthrough: &lt;a href="https://youtu.be/-NThDgcTt-8" rel="noopener noreferrer"&gt;youtu.be/-NThDgcTt-8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;On X: &lt;a href="https://x.com/0xhello69/status/2104986897236009408" rel="noopener noreferrer"&gt;the thread&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you take one thing from this, let it be the move rather than the app: don't ask anyone to trust that your results are right — hand them the thing that lets them check, offline, without you in the room. Judging is a place people are right to be a little suspicious, and the honest answer to suspicion is not a louder claim, it is a smaller thing they can verify for themselves. The database will always tell them whatever it currently says. Whether that matches what actually happened is a different question — and the whole point of the 72 hours was to make that question answerable by someone who has no reason to take my word for anything.&lt;/p&gt;

</description>
      <category>django</category>
      <category>postgres</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The standard library is not a validator: 72 hours of zero-dependency JSON in Rust</title>
      <dc:creator>Pal</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:32:16 +0000</pubDate>
      <link>https://dev.to/pal11103/the-standard-library-is-not-a-validator-72-hours-of-zero-dependency-json-in-rust-afj</link>
      <guid>https://dev.to/pal11103/the-standard-library-is-not-a-validator-72-hours-of-zero-dependency-json-in-rust-afj</guid>
      <description>&lt;p&gt;I spent last weekend building a JSON toolkit in Rust under one rule: &lt;strong&gt;no third-party dependencies&lt;/strong&gt;. Not "few". None. The &lt;code&gt;[dependencies]&lt;/code&gt; table in &lt;code&gt;Cargo.toml&lt;/code&gt; is present and empty, and &lt;code&gt;Cargo.lock&lt;/code&gt; holds exactly one package — the project itself. No &lt;code&gt;serde&lt;/code&gt;, No &lt;code&gt;serde_json&lt;/code&gt;, No &lt;code&gt;clap&lt;/code&gt;, No &lt;code&gt;itoa&lt;/code&gt;, No &lt;code&gt;ryu&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That constraint is the premise of the &lt;a href="https://hackathon-raptors.com" rel="noopener noreferrer"&gt;Zero Dependency hackathon&lt;/a&gt;,&lt;br&gt;
and it is a good premise, because it forces you to find out what the standard library actually promises. Here is the thing I did not expect to find:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;About 10% of the JSON documents that RFC 8259 says a parser &lt;strong&gt;must reject&lt;/strong&gt; are accepted by Rust's own number parser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a subtle 10%. &lt;code&gt;NaN&lt;/code&gt;, &lt;code&gt;Infinity&lt;/code&gt;, &lt;code&gt;.5&lt;/code&gt;, &lt;code&gt;5.&lt;/code&gt;, &lt;code&gt;+1&lt;/code&gt; and &lt;code&gt;012&lt;/code&gt; are all invalid JSON, and&lt;br&gt;
&lt;code&gt;f64::from_str&lt;/code&gt; and &lt;code&gt;i64::from_str&lt;/code&gt; take every one of them. If you write a JSON parser the obvious way — scan to the end of the number token, hand the slice to &lt;code&gt;from_str&lt;/code&gt; — you ship a parser that is silently non-conformant, with no warning anywhere.&lt;/p&gt;

&lt;p&gt;I have the number because I counted it against a real corpus before writing the parser. The rest of this post is what that measurement did to the design, and what generalizes to languages that are not Rust.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/pal-123456789/jaq-lite" rel="noopener noreferrer"&gt;&lt;strong&gt;jaq-lite&lt;/strong&gt;&lt;/a&gt; is a hand-rolled RFC 8259 parser, a serializer, and a jq-style query CLI with &lt;code&gt;rustc&lt;/code&gt;-style caret diagnostics — 4,670 lines under &lt;code&gt;src/&lt;/code&gt; and 4,432 lines of tests, standard library only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"users":[{"name":"ada","age":36},{"name":"linus","age":54}]}'&lt;/span&gt; | jaq-lite &lt;span class="s1"&gt;'.users[] | .name'&lt;/span&gt;
&lt;span class="go"&gt;"ada"
"linus"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It supports identity, field access, quoted fields, indexes, iteration, pipes, commas, parentheses, the optional operator &lt;code&gt;?&lt;/code&gt;, and eleven builtins (&lt;code&gt;length&lt;/code&gt;, &lt;code&gt;keys&lt;/code&gt;, &lt;code&gt;keys_unsorted&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;to_entries&lt;/code&gt;, &lt;code&gt;from_entries&lt;/code&gt;, &lt;code&gt;flatten&lt;/code&gt;, &lt;code&gt;first&lt;/code&gt;, &lt;code&gt;last&lt;/code&gt;, &lt;code&gt;reverse&lt;/code&gt;, &lt;code&gt;not&lt;/code&gt;). Exit codes follow jq: 2 for a bad flag, 3 for a filter that does not compile, 5 for input that is not JSON, 0 otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nst/JSONTestSuite" rel="noopener noreferrer"&gt;JSONTestSuite&lt;/a&gt; is the standard conformance corpus:&lt;br&gt;
318 files in &lt;code&gt;test_parsing/&lt;/code&gt;, named by what a parser is supposed to do with them. 95 &lt;code&gt;y_&lt;/code&gt; files must parse, 188 &lt;code&gt;n_&lt;/code&gt; files must be rejected, and 35 &lt;code&gt;i_&lt;/code&gt; files are cases the standard leaves to the implementation. I vendored it at a pinned commit before writing a line of parser code.&lt;/p&gt;

&lt;p&gt;51 of those files are &lt;code&gt;n_number_*&lt;/code&gt;. Four contain invalid UTF-8, leaving 47 that are a single well-formed literal. I tested each one against Rust's &lt;em&gt;documented&lt;/em&gt; &lt;code&gt;from_str&lt;/code&gt; grammars — the float grammar being &lt;code&gt;Sign? (inf | infinity | nan | Number)&lt;/code&gt;, case insensitive, and the integer grammar being &lt;code&gt;Sign? Digit+&lt;/code&gt; with leading zeros permitted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;19 of the 47 are accepted.&lt;/strong&gt; Fifteen by &lt;code&gt;f64::from_str&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.123    -.123   .2e-3        &amp;lt;- float grammar allows Digit* '.' Digit+
1.      -2.     0.e1         &amp;lt;- and Digit+ '.' Digit*
2.e3    2.e+3   2.e-3
Inf     +Inf    Infinity     &amp;lt;- the inf / infinity keywords, case-insensitive
-Infinity       NaN   -NaN   &amp;lt;- and nan, with or without a sign
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And four more by &lt;code&gt;i64::from_str&lt;/code&gt;, which permits a leading &lt;code&gt;+&lt;/code&gt; and leading zeros: &lt;code&gt;+1&lt;/code&gt;, &lt;code&gt;012&lt;/code&gt;, &lt;code&gt;-01&lt;/code&gt;, &lt;code&gt;-012&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nineteen fixtures is about a tenth of the entire must-reject set. Delegating validation to &lt;code&gt;from_str&lt;/code&gt; fails all nineteen with no error message, no warning, and no test failure anywhere except a conformance harness you had to build first in order to notice.&lt;/p&gt;

&lt;p&gt;This is not a bug in Rust, and that is the part worth internalizing. &lt;code&gt;from_str&lt;/code&gt; is a &lt;strong&gt;conversion&lt;/strong&gt;, and its contract is Rust's literal syntax, not JSON's. It is permissive about exactly the forms Rust's own grammar permits, which is why it takes &lt;code&gt;inf&lt;/code&gt; and &lt;code&gt;+1&lt;/code&gt;.&lt;br&gt;
Nothing in its signature or its name claims to be a validator. I reached for it because it was the obvious tool, and the obvious tool answers a different question.&lt;/p&gt;

&lt;p&gt;So the number grammar is hand-written: a small state machine over bytes that enforces RFC 8259's actual production — optional minus, an integer part that is either &lt;code&gt;0&lt;/code&gt; alone or a nonzero digit followed by digits, an optional fraction that must have at least one digit after the dot, an optional exponent that must have at least one digit after &lt;code&gt;e&lt;/code&gt; and its optional sign — and then, critically, checks the terminator. &lt;code&gt;1 000.0&lt;/code&gt; is in the corpus precisely because a grammar that stops at the space and does not ask what follows will accept &lt;code&gt;1&lt;/code&gt; and then either choke or silently succeed.&lt;/p&gt;

&lt;p&gt;The state machine is a few dozen lines. It was the highest-value few dozen lines in the project, and I only knew that because I had counted the fixtures first.&lt;/p&gt;
&lt;h2&gt;
  
  
  This is not a Rust problem
&lt;/h2&gt;

&lt;p&gt;Before publishing that finding I checked whether it was a quirk of one standard library. It is not. Every language ships a lenient string-to-number conversion, because a conversion's job is to be generous about its own literal forms. Paste these and watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Infinity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;012&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1_0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1_0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# digit separators, since 3.6
&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  12  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# surrounding whitespace is stripped
&lt;/span&gt;&lt;span class="mf"&gt;12.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python goes one better, and this one genuinely surprised me — the standard library's &lt;em&gt;JSON module itself&lt;/em&gt; is non-conformant by design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Infinity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-Infinity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is documented behaviour, not a bug, and it is why a payload written by Python's &lt;code&gt;json.dumps&lt;/code&gt; can be rejected by a strict reader in another language. If you exchange JSON with a Python service, &lt;code&gt;allow_nan=False&lt;/code&gt; is worth knowing about.&lt;/p&gt;

&lt;p&gt;JavaScript's &lt;code&gt;Number()&lt;/code&gt; is looser still, because it accepts every numeric literal form the language has, including bases JSON does not have at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 0.5      Number("0x1")  // 1&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 5        Number("0b11") // 3&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 1        Number("0o7")  // 7&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;012&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 12       Number(" 12 ") // 12&lt;/span&gt;
&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Infinity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Infinity     Number("") // 0  &amp;lt;- the empty string is zero&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JSON.parse&lt;/code&gt; is strict about all of them, which is the point: V8 ships a separate grammar for JSON rather than reusing &lt;code&gt;Number()&lt;/code&gt;. Somebody there had this same realization and paid for the second implementation.&lt;/p&gt;

&lt;p&gt;The transferable rule is small enough to remember: &lt;strong&gt;if a function's job is to convert, it is not your validator.&lt;/strong&gt; When a wire format specifies a grammar, implement the grammar. The convenience helper will agree with it most of the time, and the disagreements are exactly the inputs an attacker or a buggy producer will send you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trap, three more times
&lt;/h2&gt;

&lt;p&gt;Once you are looking for it, the pattern is everywhere in a parser. Three more instances I hit in the same weekend, all from the same root cause — a helper whose contract is &lt;em&gt;nearly&lt;/em&gt; the one you want:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;char::is_whitespace()&lt;/code&gt; follows Unicode &lt;code&gt;White_Space&lt;/code&gt;, which is about a dozen characters including the no-break space U+00A0 and the vertical tab. JSON's whitespace is exactly four bytes: space, tab, line feed, carriage return. A lexer that skips &lt;code&gt;is_whitespace()&lt;/code&gt; accepts documents it must reject.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;char::is_numeric()&lt;/code&gt; accepts &lt;code&gt;１&lt;/code&gt;, U+FF11 FULLWIDTH DIGIT ONE — which is in the corpus as a must-reject fixture. The funny part is that &lt;code&gt;from_str&lt;/code&gt; gets this one right, because Rust's integer parser only takes ASCII digits. So a lexer that uses &lt;code&gt;is_numeric()&lt;/code&gt; to find where a number token &lt;em&gt;ends&lt;/em&gt; fails the fixture even with a perfectly correct grammar behind it. The answer is &lt;code&gt;is_ascii_digit()&lt;/code&gt;, and the general answer is that "is this a digit" is a different question from "is this one of the ten characters my grammar permits".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;read_to_string()&lt;/code&gt; panics on 25 of the 318 fixtures, because they are not valid UTF-8 — 12 of those are must-reject cases, which means the naive harness dies before it can print a score for the very inputs it exists to check. The parser therefore takes &lt;code&gt;&amp;amp;[u8]&lt;/code&gt; and gates UTF-8 explicitly with &lt;code&gt;Utf8Error::valid_up_to()&lt;/code&gt;, so "not valid UTF-8 after 3 bytes" is a diagnostic rather than a panic. The conformance harness uses &lt;code&gt;fs::read&lt;/code&gt;, never &lt;code&gt;read_to_string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And two fixtures nest 100,000 levels deep. Recursive descent on those is a stack overflow, which is a crash and not a rejection — the corpus scores it as a failure either way, but your users experience the difference. Both recursive descents carry an explicit &lt;code&gt;u32&lt;/code&gt; counter and refuse &lt;em&gt;before&lt;/em&gt; recursing: 128 levels for a document, 64 for filter parentheses. Both limits are readable back out of the error variant that reports them, rather than being buried in an English message, because a number in prose is not available to a caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostics are worth the afternoon
&lt;/h2&gt;

&lt;p&gt;The one feature I would keep if I had to cut everything else is the error format. This is &lt;code&gt;rustc&lt;/code&gt;'s shape, and it takes maybe two hours to implement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jaq-lite &lt;span class="nb"&gt;.&lt;/span&gt; bad.json
&lt;span class="go"&gt;jaq-lite: bad.json: line 1, column 2: expected a string as the object key
  |
1 | {1:2}
  |  ^
[exit 5]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renderer works on raw bytes rather than on a &lt;code&gt;str&lt;/code&gt;, for the reason above: input that is not valid UTF-8 is one of the failures it has to draw a caret under. A position is stored as a byte offset and lines are only counted when a message is actually printed, so nothing pays for a line table it never uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 35 files where the standard declines to decide
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;i_&lt;/code&gt; fixtures are the interesting third of the corpus: lone surrogates, a UTF-8 BOM before an empty object, integers that exceed 64 bits, duplicate keys. RFC 8259 permits a parser to accept or reject each of these, so a score against them means nothing — you can claim any number you like by changing your mind.&lt;/p&gt;

&lt;p&gt;What I did instead was write four policies before looking at the files, and derive all 35 decisions from them. Policy 1 is the UTF-8 gate and settles 14 of the cases. The other three handle the numeric range, duplicate keys, and structural edge cases, covering 10, 10 and 1 respectively, with no residue: 10 accepted, 25 rejected, every one traceable to a rule rather than to a judgement call made at 2am.&lt;/p&gt;

&lt;p&gt;That is the transferable move for implementation-defined behaviour anywhere: &lt;strong&gt;publish the policy, not the tally.&lt;/strong&gt; A reader can disagree with a policy. A tally offers nothing to disagree with.&lt;/p&gt;

&lt;h2&gt;
  
  
  A number is the bytes it was written with
&lt;/h2&gt;

&lt;p&gt;The design decision I am happiest about started as a compatibility question. jq does not round-trip numbers through a float: give it &lt;code&gt;1.0&lt;/code&gt; and you get &lt;code&gt;1.0&lt;/code&gt; back, &lt;code&gt;-0.0&lt;/code&gt; stays &lt;code&gt;-0.0&lt;/code&gt;, and a 30-digit integer comes back intact. It only canonicalizes exponent form,&lt;br&gt;
turning &lt;code&gt;1e2&lt;/code&gt; into &lt;code&gt;1E+2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a number here is not an &lt;code&gt;f64&lt;/code&gt;. It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the exact span the RFC 8259 grammar validated&lt;/span&gt;
    &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// for comparisons and arithmetic-free semantics&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Construction is crate-private, which makes "&lt;code&gt;raw&lt;/code&gt; was validated" a structural invariant rather than a comment. Three things fall out of it. Round-tripping becomes byte-exact over all 95 documents that must parse, rather than merely semantically equal. &lt;code&gt;-0.0 != 0.0&lt;/code&gt; comes free from comparing the text, while &lt;code&gt;PartialEq&lt;/code&gt; on &lt;code&gt;val&lt;/code&gt; still gives jq's &lt;code&gt;1.0 == 1&lt;/code&gt;. And it &lt;em&gt;deletes&lt;/em&gt; code from the serializer: no appending &lt;code&gt;.0&lt;/code&gt; to integral floats, no scientific-notation threshold, no non-finite branch, because a literal the grammar accepted can never be &lt;code&gt;inf&lt;/code&gt; or &lt;code&gt;NaN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cost is real and I state it rather than hiding it: roughly 16 bytes plus the digits against 8 for a bare &lt;code&gt;f64&lt;/code&gt;, which a number-dense document pays for.&lt;/p&gt;

&lt;p&gt;Numbers the tool &lt;em&gt;synthesizes&lt;/em&gt; — &lt;code&gt;length&lt;/code&gt;, indices — are the only remaining place text gets generated, and they go through &lt;code&gt;core::fmt::NumBuffer&lt;/code&gt; with &lt;code&gt;format_into&lt;/code&gt;. That is also the &lt;code&gt;itoa&lt;/code&gt; replacement, and it is measurably faster than the &lt;code&gt;format!&lt;/code&gt; most people would reach for: 1.68 times, measured. One consequence is a named divergence from jq, in the README rather than in a footnote: &lt;code&gt;1e2&lt;/code&gt; stays &lt;code&gt;1e2&lt;/code&gt; here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility is compared, not remembered
&lt;/h2&gt;

&lt;p&gt;"jq-compatible" is the kind of claim that rots quietly. So CI runs 62 comparisons against a real jq 1.8.1 binary on every push: eight identity cases, six path expressions, 29 builtins over the corpus, and 19 over shapes the script writes into its own temporary directory. It reads its builtin roster out of the code's own match arms and &lt;strong&gt;fails if a builtin exists that no comparison reaches&lt;/strong&gt;, which is a different and much more useful failure than a disagreement — it catches the feature you added and forgot to compare.&lt;/p&gt;

&lt;p&gt;An external binary is not a dependency: nothing about jq ships in the artifact, and the comparison exists so that "jq-compatible" is a measurement rather than an adjective.&lt;/p&gt;

&lt;p&gt;One trap worth passing on. jq's Windows build writes CRLF, because its stdout is in text mode; Rust writes LF. A naive byte diff therefore fails on every single line for a reason that has nothing to do with correctness, and Git Bash does not save you. The differential runs under WSL, which is also where the reproducibility check lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I made my documents fail the build
&lt;/h2&gt;

&lt;p&gt;This is the practice I would take to any codebase, and it came out of being burned. A README number goes stale in the commit &lt;em&gt;after&lt;/em&gt; the one that wrote it, and prose does not fail a build, so nothing tells you. In this repo, 14 of the 191 tests do nothing but read documents and compare them to the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every hash recorded in &lt;code&gt;BUILD_LOG.md&lt;/code&gt; must agree with the others that describe the same
build&lt;/li&gt;
&lt;li&gt;the README must quote the reproducible-build script's assertion phrases &lt;strong&gt;verbatim&lt;/strong&gt;, and
the CI job the README names by name must exist in &lt;code&gt;ci.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the README must contain &lt;strong&gt;no&lt;/strong&gt; 64-hex-character token, so the decision not to publish a
digest as if it were a constant cannot quietly reverse&lt;/li&gt;
&lt;li&gt;the test count stated in &lt;code&gt;CLAIMS.md&lt;/code&gt; is grepped out of &lt;code&gt;src/&lt;/code&gt; and &lt;code&gt;tests/&lt;/code&gt; rather than
typed, because it had already gone stale twice&lt;/li&gt;
&lt;li&gt;every limit the code enforces must be stated in the README, and every builtin the code has
must be named there&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one that convinced me was a row in the claims ledger that said &lt;em&gt;thirteen&lt;/em&gt; while the file it described held &lt;em&gt;fourteen&lt;/em&gt; tests. Every gate passed while it was false, because no program was reading it. Now one is. A rewording fails in the commit that reworded it.&lt;/p&gt;

&lt;p&gt;The general shape: for each claim in your docs, ask what file would contradict it, then read both in a test. It costs about twenty lines each and it converts documentation from a liability into an asset that CI defends.&lt;/p&gt;

&lt;h2&gt;
  
  
  A digest is evidence about a tree, not about a project
&lt;/h2&gt;

&lt;p&gt;The build is byte-for-byte reproducible, and the mechanism is three lines in &lt;code&gt;Cargo.toml&lt;/code&gt;:&lt;br&gt;
&lt;code&gt;codegen-units = 1&lt;/code&gt; removes parallel-codegen nondeterminism, and &lt;code&gt;debug = 0&lt;/code&gt; with &lt;code&gt;strip = "symbols"&lt;/code&gt; removes the absolute paths and symbol tables that otherwise record where the build happened.&lt;/p&gt;

&lt;p&gt;Two details make the check honest. The script builds in two directories of &lt;strong&gt;different lengths&lt;/strong&gt;, because equal-length paths give a false pass — a path baked into the binary would still compare equal. And it runs a control build with debug info left in, which yields a different digest every time; that is the sensitivity check that proves the comparison can fail at all. A test that cannot fail is not evidence.&lt;/p&gt;

&lt;p&gt;The published digests are dated samples tied to the commit that produced them, not constants.&lt;br&gt;
The binary was 468,704 bytes at commit 34 and 490,336 at commit 65, because 31 commits of source landed in between. Publish the recipe; date the digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reporting speed without lying
&lt;/h2&gt;

&lt;p&gt;The README prints 26.5 MiB/s for parsing and 197.9 for serializing, over a 1,196,671-byte document the test generates for itself. Then it spends a paragraph explaining why you should not trust that figure: five runs of one unchanged binary, across two boots of the same laptop, span 17.7 to 41.8 MiB/s. A factor of 2.4, from source that did not change.&lt;/p&gt;

&lt;p&gt;So the suite asserts a &lt;strong&gt;floor&lt;/strong&gt; and never the figure — 5 MiB/s release, 1 debug, raisable through an environment variable and lowerable by nothing. A regression that changes the shape of the algorithm fails a test; an unlucky sample on a busy machine does not. And what gets printed is the mean over the timing window rather than the fastest round in it, because interference only ever slows a round down, which makes the fastest round the flattering one to report and not the one a caller experiences.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;No arithmetic, no variables, no user-defined functions. &lt;code&gt;JQ_COLORS&lt;/code&gt; is not parsed. There is no &lt;code&gt;--max-depth&lt;/code&gt; for either nesting limit. &lt;code&gt;1e2&lt;/code&gt; does not become &lt;code&gt;1E+2&lt;/code&gt;. All of that is in the README, and one of those 14 tests fails the build if the code enforces a limit the README does not state — which is the only reason I trust the list to still be complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;git clone https://github.com/pal-123456789/jaq-lite
cd jaq-lite
cargo test --release
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;191 tests, no network access after the clone, and one package in the lockfile.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/pal-123456789/jaq-lite" rel="noopener noreferrer"&gt;pal-123456789/jaq-lite&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/pal-123456789/jaq-lite/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;v0.1.0 release&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://youtu.be/roh0pGwUPsQ" rel="noopener noreferrer"&gt;five-minute walkthrough&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you take one thing from this: the corpus came first, and it is the reason I know a number instead of an opinion. Whatever format you are parsing, find the conformance suite before you write the parser. Nineteen files is not a lot to fix on day one, and it is a very unpleasant thing to discover in production.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>json</category>
      <category>testing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How We Ported Python’s natsort to Rust — And the 133,000 Fuzz Inputs That Proved Us Wrong</title>
      <dc:creator>Pal</dc:creator>
      <pubDate>Wed, 05 Aug 2026 15:26:51 +0000</pubDate>
      <link>https://dev.to/pal11103/how-we-ported-pythons-natsort-to-rust-and-the-133000-fuzz-inputs-that-proved-us-wrong-5</link>
      <guid>https://dev.to/pal11103/how-we-ported-pythons-natsort-to-rust-and-the-133000-fuzz-inputs-that-proved-us-wrong-5</guid>
      <description>&lt;p&gt;&lt;strong&gt;A post-mortem on building natsort-rs, fixing a 45x performance bug, and catching subtle edge cases with a custom Pytest monkeypatch bridge.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Why natsort?&lt;/strong&gt;&lt;br&gt;
Porting Python code to Rust using the "Astral playbook" sounds straightforward on paper: rewrite the hot path, compile to native binary, profit. We chose Seth M. Morton’s popular natsort library—a ubiquitous utility for natural sorting (item2 before item10).&lt;br&gt;
In Python, natsort relies heavily on dynamic typing, tuple-based comparisons, and fallback numeric parsing (try_int, try_float). Translating Python’s dynamic Union[str, int, float] return types into Rust required introducing explicit tagged enums (ParsedComponent) and a hand-crafted Ord trait implementation in key.rs.&lt;br&gt;
Zero unsafe blocks. Pure memory safety. But getting it to build was only 10% of the battle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Benchmark That Humbling Us (And the Fix)&lt;/strong&gt;&lt;br&gt;
When we ran our first full 10,000-item workload benchmark against Python's natsort, the result was embarrassing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python natsort&lt;/strong&gt;: ~0.24s mean&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our Compiled Rust Port&lt;/strong&gt;: ~0.90s mean&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An native binary running nearly 4x slower than interpreted Python was unacceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Root Cause&lt;/strong&gt;: In our initial implementation, natsorted() recompiled the number-matching regular expression from scratch for every single item in the input array. For 10,000 items, we were recompiling regex 10,000 times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt;: We refactored split.rs to compile the regex once per natsorted() invocation and pass a reference down through the key generation pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Real Result&lt;/strong&gt;: Latency dropped from ~900ms to ~20ms—shifting our port from 4x slower than Python to 11.8x faster on identical workloads. Disclosing this mistake is important: benchmarking without profiling is just guessing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Edge Case That Ate Hours: Bare "nan" Tokens&lt;/strong&gt;&lt;br&gt;
To prove behavioral equivalence, we didn't just write unit tests—we built a differential fuzz harness (fuzz/differential_fuzz.py) that generated randomized strings, floats, and signs, feeding them simultaneously into real Python natsort and our Rust CLI.&lt;br&gt;
At iteration 125,000 (REAL mode, Seed 2), the fuzzer flagged a mismatch (1 out of 500 batches / 0.2% failure rate).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Input&lt;/strong&gt;: A bare text token "nAn" without any accompanying digits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python's Behavior&lt;/strong&gt;: Python's parse_string_factory applied try_float() to every split component—even ones that didn't match the numeric regex. Because Python’s float("nan") parses as NaN, Python treated "nAn" as a float and sorted it first under default NaN ordering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our Rust Port&lt;/strong&gt;: Our regex splitter only attempted float parsing on substrings containing digits or adjacent signs. It treated "nAn" as plain text, placing it alphabetically amidst text strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt;: We rewrote split_into_components_with_regex to split into all pieces (matched and unmatched), uniformly apply float parsing, and insert NUMAFTER-aware separators between adjacent numeric components. We re-ran 133,000+ randomized inputs through the fuzzer across DEFAULT and REAL modes: 0 mismatches.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Pytest Bridge: What Fuzzing Missed&lt;/strong&gt;&lt;br&gt;
Fuzzing is great for random input distribution, but structured logic bugs require deterministic tests.&lt;br&gt;
Instead of re-writing natsort’s test suite in Rust (which risks recreating our own biases), we built a Pytest monkeypatch bridge in tests/original/conftest.py. It intercept calls to natsort.natsorted at module load time and shells out directly to our compiled Rust binary using raw bitmask CLI flags (--alg=).&lt;br&gt;
This bridge caught two major bugs that 133,000 fuzz items completely missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The NUMAFTER Leading Placeholder Bug&lt;/strong&gt;: When an input string started with a number (e.g., "100_apples"), our sequence-alternation logic inserted a hardcoded empty string as a leading placeholder rather than a NUMAFTER-aware sentinel value. This masked NUMAFTER's effect entirely whenever the first component was numeric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing PRESORT&lt;/strong&gt;: ns.PRESORT had zero implementation in our Rust port—an outright missing feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once both were fixed, running pytest test_natsorted.py directly against the unmodified upstream Python test file yielded 16 passed, 35 honestly skipped (unsupported locale/path flags), 0 failed, and 0 errors.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;What We Walked Back &amp;amp; Honest Disclosures&lt;/strong&gt;&lt;br&gt;
If you want honest numbers over confident claims, here is what natsort-rs does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Unicode Claim Correction&lt;/strong&gt;: We initially documented that Rust's char::to_digit(10) supported non-ASCII Unicode decimal digits (like Devanagari or Arabic-Indic numerals). A clippy review revealed char::to_digit is strictly ASCII-only at any radix. We updated our documentation to explicitly state ASCII-only numeral support rather than leaving an overclaim standing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numeric Bounds&lt;/strong&gt;: We bounded integers to i64 and floats to f64. Numbers exceeding 9.2 × 10¹⁸ fall back gracefully to text comparison rather than arbitrary-precision integer comparisons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy OS Locales&lt;/strong&gt;: We explicitly skipped ns.LOCALE and os_sorted. Replicating 30-entry legacy OSX/BSD lookup tables or linking against heavy ICU C-libraries added build complexity for non-deterministic OS features.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codebase&lt;/strong&gt;: 0 unsafe blocks, clean clippy &amp;amp; fmt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing&lt;/strong&gt;: 42 Rust-native tests (including 7 proptest property-based tests verifying Ord transitivity and reflexivity).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parity&lt;/strong&gt;: 133,000+ fuzz items (0 mismatches) + Upstream Pytest suite bridge (16 passed, 0 failed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt;: 9–12x speedup over Python.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check out the code, benchmark methodology, and full 12-section decision log on GitHub:&lt;br&gt;
🔗 &lt;br&gt;
&lt;a href="//github.com/pal-123456789/port_mortem-natsort-rust"&gt;GitHub Link&lt;/a&gt;&lt;br&gt;
&lt;a href="https://x.com/i/status/2085012198171226326" rel="noopener noreferrer"&gt;X Link&lt;/a&gt;&lt;br&gt;
&lt;a href="https://youtu.be/pX7gl6dFbbY?si=ekWIXhqtSTd94e0h" rel="noopener noreferrer"&gt;YouTube Link&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Submitted for Hackathon Raptors: Port Mortem 2026 (Track D: Python -&amp;gt; Rust) 🦖&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
