Files
james.maesandClaude Fable 5.2 f9a5def4f6 feat(renderer): scene 2 barcodes: whole-dot module fit, alignment in the box, decode tests (P3a)
A `barcode` element of a version 2 scene is now drawn into the picture of
the label instead of being refused as RENDERER_TOO_OLD.

- ModuleFit: the module is the largest whole number of dots at which the
  symbol, quiet zones included, fits its box; never under `minModule`
  (default 0.25 mm linear, 0.375 mm two-dimensional, taken up to whole dots)
  and never over `maxModule`. Integer arithmetic only.
- A linear symbol is fitted along its width and its bars take the whole
  height of the box; qr, datamatrix, pdf417 and aztec are fitted on both
  axes. The fitted symbol is placed by `align` and `valign`, turned by
  `rotation`, and paints its own white ground, quiet zones included.
- A value that does not fit at the minimum module is BARCODE_DOES_NOT_FIT,
  validated or rendered, with the modules and dots it needs and the dots
  the box has. A smaller module is drawn only when the scene states one,
  and then with the warning BARCODE_MODULE_BELOW_RECOMMENDED every time.
- The thirteen fit-qualified symbologies, `value` and `valuePath`, the
  dynamic symbology allowlist and the scanner profile are carried over from
  version 1, whose own barcode path is not changed. Every refusal is a
  diagnostic of numbers; what an encoder says about a value is dropped.
- `gs1` and `hri` stay declared; an element that states either is refused
  as RENDERER_TOO_OLD until the package that draws them.

Tests: every symbology at 203, 300 and 600 dpi for a short, a typical and
the longest value, rebuilt from the emitted ZPL and decoded by ZXing
(GS1-128 with FNC1 as `]C1`); seeded properties of the fit and of the
picture; goldens for a GS1-128 on 4 x 1 in, linear and two-dimensional
symbols; the version 2 mutation campaign extended to barcode elements.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 17:25:40 -06:00

11 KiB

Parser fuzzing

Scope and properties

K22-TEST-25 applies: this repository parses untrusted input at two boundaries. Templates are written by users, and the isolated renderer is treated as compromisable, so its output is untrusted to the host.

Target Module Input Campaign
Version 1 scene parser, ZplRenderer.validate renderer A qqq-zpl-scene/1 source MutationCampaignTest.theVersionOneSceneParserAcceptsOnlyVersionOneScenes
Version 2 scene parser, canvas and emitter behind the worker's routing, RendererMain.process with VALIDATE and, for about one input in four, RENDER renderer A qqq-zpl-scene/2 source MutationCampaignTest.theVersionTwoSceneParserAcceptsOnlyVersionTwoScenes
Host printer gate, ZplOutputPolicy.validate bridges/renderer ZPL bytes returned by the worker MutationCampaignTest.theHostPrinterGateAcceptsOnlyTheRendererCommandSet
Page decoder for previews, ZplRaster.decode bridges/renderer ZPL bytes returned by the worker MutationCampaignTest.thePageDecoderDrawsExactlyWhatThePrinterGateAccepts
Answer reader, WorkerAnswer.read and settle bridges/renderer The worker's answer frame, with its diagnostics MutationCampaignTest.theAnswerReaderBelievesOnlyDiagnosticsOfTheSceneThatWasSent

The harness is MutationCampaign, one copy in each module's test tree (the modules share no test code). It mutates valid inputs from the version 1 compatibility corpus (renderer/src/test/resources/compat-v1) and the version 2 layout cases (renderer/src/test/resources/scene2/cases): bit flips, byte and range edits, truncation, duplication and tokens of the format spliced in. Each campaign checks unsafe acceptance, not only crashes:

  • Version 1 scene parser. An accepted input, read again by a second and differently configured JSON parser, is a version 1 scene of known element types within the element limit. A refusal is the parser's IllegalArgumentException or exactly one of the JSON reader's exceptions for text that is not one JSON value (JsonParseException, JsonEOFException, MismatchedInputException, StreamConstraintsException). Any other exception (a NullPointerException, an index out of bounds), a stack overflow or a failed acceptance check is a finding.

  • Version 2 scene parser. The target is the worker's own routing, so a mutated source may also become a version 1 scene, which is then held to the version 1 property. An accepted version 2 scene, read by the second parser, has only the declared top-level properties, a physical design size, at most 1000 elements with unique ids of the fixed pattern, only box, group, text and barcode elements with only their declared properties (images are refused until their package exists), groups at most four deep, every length a number and a unit, and overrides only for the three resolutions and for declared ids. A text element is read as it stands at each resolution (the defaults, then the element, then the overrides for that resolution): it names a bundled face, has a physical size from 1 to 1000 pt, exactly one of a literal value and a pointer into the record, and only fit, length, threshold, rotation, reverse and line height settings of the documented kinds and ranges. A barcode element is read the same way (the element, then the overrides): it states one fit-qualified symbology or a pointer with a list of distinct ones, exactly one of a literal value of 1 to 4000 characters that holds no encoder escape and a pointer into the record, a scanner profile whole or not at all, module sizes and a minimum height that are more than nothing, a quiet zone from the widest minimum of its symbologies to 64, a maxLength from 1 to 4000, one of the three named profiles, a quarter-turn rotation, and neither gs1 nor hri. The corpus holds the three text cases (text-faces, text-fit, text-turned) and the three barcode cases (barcode-linear, barcode-matrix, barcode-gs1) beside the layout cases. The spliced tokens include the text properties, a combining mark, a tab, half of a surrogate pair, a size of 1000pt and the number 1e400; and the barcode properties, symbology names in and out of the fit-qualified set, the profile names, an element string, a GTIN, the encoder's escape for a function character and characters outside Latin-1. A barcode encoder's own exception is not a refusal: a mutated value must end as a Refusal or as a label. The campaign asserts that it rendered labels holding a barcode. About one version 2 input in four, chosen by its bytes so that a replay does the same, is rendered instead of validated: the label is read back into a page, must be only the header, uncompressed graphic fields and the end of a label, must put no ink outside the printable area, and validation of the same input must give the same answer (accepted with the same findings, or refused with the same diagnostic). The one difference allowed is VALUE_MISSING: a text or a barcode bound to the record has no value in the campaign's empty record, and validation reads no record. A refusal is the scene's own Refusal, the exception that carries a diagnostic, and nothing else: the routing turns unreadable JSON and an unknown schema into one. A plain IllegalArgumentException is a finding, because the package raises those for its own broken invariants (CANVAS_BOUNDS, CANVAS_LIMIT, LENGTH_NOT_ABSOLUTE, LENGTH_NOT_PHYSICAL), which no scene may reach; so is the text package's IllegalStateException for ink outside a measured line (TEXT_INK_OUTSIDE_MEASURE), and any arithmetic or number-format failure. The one exception is a source the routing sends to the frozen version 1 parser, whose own refusal is a plain IllegalArgumentException; such sources are validated only. The campaign also asserts that it rendered labels.

  • Printer gate. An accepted input contains only the commands the renderer may emit and no tilde command. A refusal is the gate's IllegalArgumentException, or the strict decoder's CharacterCodingException for field text that is not UTF-8. Any other failure, or a failed acceptance check, is a finding.

  • Page decoder. The same mutated labels go to the gate and to the decoder. A label the gate accepts must decode, and for a label without printer font text the page must equal, dot for dot, a second drawing of its graphic fields and boxes made by a regular expression and none of the decoder's code. A label the gate refuses (which production never hands the decoder) must be drawn within the dot limit or refused with the decoder's own IllegalArgumentException. Any other failure is a finding.

  • Answer reader. The corpus is answer frames of both accepted renderers for a version 2 scene: a label with a warning, a refused answer, an error with its detail. An accepted input, read again by a plain JSON reader, is an answer of renderer 0.2.0 that is a whole label with no error or a refusal with no part of a label, and each of its diagnostics has only the known fields, names an element the scene declares and points only through array indexes, schema property names and declared ids. A typed refusal's diagnostic is held to the same rule. A rejection is the reader's IllegalArgumentException or the JSON reader's IOException; any other failure is a finding.

Each campaign also asserts that it reached both acceptance and refusal, so a campaign that only ever refuses cannot pass.

This is seeded generated-input fuzzing, not coverage-guided instrumentation. Do not claim OSS-Fuzz or ClusterFuzzLite enrolment.

Running and replaying

The campaigns run in the normal test phase, so every ./mvnw verify, and therefore every CI build of a pull request or a push, runs them. No network, clock or unseeded randomness is used: a seed and an iteration number determine the input.

# What CI runs: seeds 20261111 and 7919, 20000 iterations each, per target.
./mvnw -B -pl renderer,bridges/renderer -am verify

# Replay a finding, or run a longer campaign before a MINOR or MAJOR release (both seeds at 100000).
./mvnw -B -pl renderer -am test -Dtest=MutationCampaignTest -Dsurefire.failIfNoSpecifiedTests=false -Dfuzz.seed=20261111 -Dfuzz.iterations=100000
./mvnw -B -pl bridges/renderer -am test -Dtest=MutationCampaignTest -Dsurefire.failIfNoSpecifiedTests=false -Dfuzz.seed=20261111 -Dfuzz.iterations=100000

A failure lists each finding as Finding[seed, iteration, failure]. MutationCampaign.input(seed, iteration) returns that exact input against the same checkout. Reduce it to a named regression test beside the parser's other tests, fix the parser, and rerun both seeds. Report a suspected vulnerability privately rather than in a public issue.

Limits: inputs are at most 1 MiB, a campaign is at most 100000 iterations, and the corpus entries used are under 20 KiB. The harness writes no files, launches no process and reads no credentials. Generated ZPL is never sent to a printer. Generated version 2 scenes are rendered in memory on a 2 x 1 in stock at 203 dpi, a canvas of 374 by 171 dots; generated version 1 scenes are only validated.

Adding a target

A new parser of untrusted input adds one test method to the MutationCampaignTest of its module, with a corpus of valid inputs, a dictionary of the format's tokens, the refusal it declares and an independent check of what it accepts, and adds a row to the table above.

Review record

  • Added: 2026-11-11, with the version 1 scene parser and printer gate campaigns.
  • 2026-11-12: the version 2 scene parser campaign was added with the parser. The version 1 campaign had counted any exception as a refusal, so a crash in the parser would have passed; it now accepts only the declared refusals. Both seeds at 20000 iterations, and both at 100000, found nothing under the stricter rule.
  • 2026-11-12, review of the scene 2 core: the version 2 campaign had counted any IllegalArgumentException as a refusal and only validated. It now accepts only a Refusal and renders about a quarter of its inputs, reading each label back. Both seeds at 20000 iterations, and both at 100000, found nothing under the stricter rule. The defect the review found by reading (a length such as 84600in overflowing an int of micrometres, an ArithmeticException) was already outside the old rule; the mutations did not reach it because they rarely write a five-digit length, so it is covered by named tests instead (LengthTest, Scene2Test).
  • 2026-11-12, bridge preview: the page decoder and answer reader campaigns were added with their classes. Both seeds at 20000 iterations found nothing.
  • The first run of the printer gate campaign showed the CharacterCodingException refusal, which the property had not declared. It is a refusal, not a defect; the property now names it.
  • Not yet done: a weekly long campaign and the docs/security/assurance.json record that K22-TEST-25 also asks for. The long campaign is the manual command above until a scheduled job exists.
  • Reassess the targets when a parser boundary changes.