From ea776391ca8c7a82b863135558d6586912249a2e Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 17:28:25 -0600 Subject: [PATCH 1/3] test(renderer): version 1 scene compatibility corpus from v1.1.0-RC.2 (P0) Version 1 scenes (qqq-zpl-scene/1) are frozen. This adds 42 render requests and the exact ZPL the released renderer returned for each, so later work can prove that labels already printing are untouched. - renderer/src/test/resources/compat-v1: cases, stored ZPL, SHA256SUMS and a README with provenance and rules. The bytes were produced by the source of tag v1.1.0-RC.2 (e9ffba48), not by this branch, and the same test passed against the tag on Temurin 21.0.12 (Windows) and 21.0.9 (the CI image). - renderer ZplSceneOneCompatibilityTest: fails on the first differing byte, on a manifest that does not name exactly the cases, and on a corpus that misses a version 1 element type, property or resolution. - bridges/renderer ZplSceneOneCompatibilityTest: every stored output has its manifest hash and passes the unchanged ZplOutputPolicy. It reads the renderer's directory in place, so there is one copy of the corpus. Covered: the four shapes Forge publishes at 203 and 300 dpi (GS1-128 with fit and quiet, text in font 0, rasterText with fontAsset), the README and BARCODE-FIT.md examples, and one scene per element and option at 203, 300 and 600 dpi. All values are synthetic. Co-Authored-By: Claude Fable 5.2 --- .../ZplSceneOneCompatibilityTest.java | 80 ++++++ .../ZplSceneOneCompatibilityTest.java | 229 ++++++++++++++++++ .../src/test/resources/compat-v1/README.md | 47 ++++ .../src/test/resources/compat-v1/SHA256SUMS | 42 ++++ .../resources/compat-v1/assets/test-mark.png | Bin 0 -> 333 bytes .../cases/barcode-fit-linear-203.json | 127 ++++++++++ .../cases/barcode-fit-linear-300.json | 127 ++++++++++ .../cases/barcode-fit-linear-600.json | 127 ++++++++++ .../cases/barcode-fit-matrix-203.json | 71 ++++++ .../cases/barcode-fit-matrix-300.json | 71 ++++++ .../cases/barcode-fit-matrix-600.json | 71 ++++++ .../compat-v1/cases/barcode-scale-203.json | 63 +++++ .../compat-v1/cases/barcode-scale-300.json | 63 +++++ .../compat-v1/cases/barcode-scale-600.json | 63 +++++ .../resources/compat-v1/cases/box-203.json | 51 ++++ .../resources/compat-v1/cases/box-300.json | 51 ++++ .../resources/compat-v1/cases/box-600.json | 51 ++++ .../cases/forge-gs1-compact-203.json | 44 ++++ .../cases/forge-gs1-compact-300.json | 44 ++++ .../compat-v1/cases/forge-gs1-raster-203.json | 57 +++++ .../compat-v1/cases/forge-gs1-raster-300.json | 57 +++++ .../cases/forge-plain-code128-203.json | 44 ++++ .../cases/forge-plain-code128-300.json | 44 ++++ .../compat-v1/cases/forge-plain-qr-203.json | 44 ++++ .../cases/forge-scanner-code128-203.json | 52 ++++ .../cases/forge-scanner-ean13-203.json | 52 ++++ .../cases/forge-scanner-ean13-300.json | 52 ++++ .../resources/compat-v1/cases/image-203.json | 37 +++ .../resources/compat-v1/cases/image-300.json | 37 +++ .../resources/compat-v1/cases/image-600.json | 37 +++ .../cases/raster-text-layout-203.json | 96 ++++++++ .../cases/raster-text-layout-300.json | 96 ++++++++ .../cases/raster-text-layout-600.json | 96 ++++++++ .../cases/raster-text-rotation-203.json | 108 +++++++++ .../cases/raster-text-rotation-300.json | 108 +++++++++ .../cases/raster-text-rotation-600.json | 108 +++++++++ .../cases/readme-barcode-fit-203.json | 45 ++++ .../cases/readme-barcode-fit-300.json | 45 ++++ .../cases/readme-raster-text-203.json | 45 ++++ .../cases/readme-raster-text-300.json | 45 ++++ .../cases/readme-raster-text-600.json | 45 ++++ .../compat-v1/cases/readme-zpl-scene-203.json | 60 +++++ .../compat-v1/cases/readme-zpl-scene-300.json | 60 +++++ .../compat-v1/cases/readme-zpl-scene-600.json | 60 +++++ .../compat-v1/cases/text-font0-203.json | 81 +++++++ .../compat-v1/cases/text-font0-300.json | 81 +++++++ .../compat-v1/cases/text-font0-600.json | 81 +++++++ .../compat-v1/zpl/barcode-fit-linear-203.zpl | 11 + .../compat-v1/zpl/barcode-fit-linear-300.zpl | 11 + .../compat-v1/zpl/barcode-fit-linear-600.zpl | 11 + .../compat-v1/zpl/barcode-fit-matrix-203.zpl | 6 + .../compat-v1/zpl/barcode-fit-matrix-300.zpl | 6 + .../compat-v1/zpl/barcode-fit-matrix-600.zpl | 6 + .../compat-v1/zpl/barcode-scale-203.zpl | 6 + .../compat-v1/zpl/barcode-scale-300.zpl | 6 + .../compat-v1/zpl/barcode-scale-600.zpl | 6 + .../test/resources/compat-v1/zpl/box-203.zpl | 6 + .../test/resources/compat-v1/zpl/box-300.zpl | 6 + .../test/resources/compat-v1/zpl/box-600.zpl | 6 + .../compat-v1/zpl/forge-gs1-compact-203.zpl | 4 + .../compat-v1/zpl/forge-gs1-compact-300.zpl | 4 + .../compat-v1/zpl/forge-gs1-raster-203.zpl | 4 + .../compat-v1/zpl/forge-gs1-raster-300.zpl | 4 + .../compat-v1/zpl/forge-plain-code128-203.zpl | 3 + .../compat-v1/zpl/forge-plain-code128-300.zpl | 3 + .../compat-v1/zpl/forge-plain-qr-203.zpl | 3 + .../zpl/forge-scanner-code128-203.zpl | 3 + .../compat-v1/zpl/forge-scanner-ean13-203.zpl | 3 + .../compat-v1/zpl/forge-scanner-ean13-300.zpl | 3 + .../resources/compat-v1/zpl/image-203.zpl | 4 + .../resources/compat-v1/zpl/image-300.zpl | 4 + .../resources/compat-v1/zpl/image-600.zpl | 4 + .../compat-v1/zpl/raster-text-layout-203.zpl | 7 + .../compat-v1/zpl/raster-text-layout-300.zpl | 7 + .../compat-v1/zpl/raster-text-layout-600.zpl | 7 + .../zpl/raster-text-rotation-203.zpl | 8 + .../zpl/raster-text-rotation-300.zpl | 8 + .../zpl/raster-text-rotation-600.zpl | 8 + .../compat-v1/zpl/readme-barcode-fit-203.zpl | 3 + .../compat-v1/zpl/readme-barcode-fit-300.zpl | 3 + .../compat-v1/zpl/readme-raster-text-203.zpl | 3 + .../compat-v1/zpl/readme-raster-text-300.zpl | 3 + .../compat-v1/zpl/readme-raster-text-600.zpl | 3 + .../compat-v1/zpl/readme-zpl-scene-203.zpl | 6 + .../compat-v1/zpl/readme-zpl-scene-300.zpl | 6 + .../compat-v1/zpl/readme-zpl-scene-600.zpl | 6 + .../compat-v1/zpl/text-font0-203.zpl | 9 + .../compat-v1/zpl/text-font0-300.zpl | 9 + .../compat-v1/zpl/text-font0-600.zpl | 9 + 89 files changed, 3433 insertions(+) create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplSceneOneCompatibilityTest.java create mode 100644 renderer/src/test/java/com/kingsrook/qbits/renderer/ZplSceneOneCompatibilityTest.java create mode 100644 renderer/src/test/resources/compat-v1/README.md create mode 100644 renderer/src/test/resources/compat-v1/SHA256SUMS create mode 100644 renderer/src/test/resources/compat-v1/assets/test-mark.png create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-fit-linear-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-fit-linear-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-fit-linear-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-fit-matrix-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-fit-matrix-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-fit-matrix-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-scale-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-scale-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/barcode-scale-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/box-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/box-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/box-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-gs1-compact-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-gs1-compact-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-gs1-raster-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-gs1-raster-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-plain-code128-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-plain-code128-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-plain-qr-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-scanner-code128-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-scanner-ean13-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/forge-scanner-ean13-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/image-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/image-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/image-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/raster-text-layout-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/raster-text-layout-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/raster-text-layout-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/raster-text-rotation-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/raster-text-rotation-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/raster-text-rotation-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-barcode-fit-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-barcode-fit-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-raster-text-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-raster-text-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-raster-text-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-zpl-scene-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-zpl-scene-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/readme-zpl-scene-600.json create mode 100644 renderer/src/test/resources/compat-v1/cases/text-font0-203.json create mode 100644 renderer/src/test/resources/compat-v1/cases/text-font0-300.json create mode 100644 renderer/src/test/resources/compat-v1/cases/text-font0-600.json create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-fit-linear-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-fit-linear-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-fit-linear-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-fit-matrix-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-fit-matrix-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-fit-matrix-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-scale-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-scale-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/barcode-scale-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/box-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/box-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/box-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-gs1-compact-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-gs1-compact-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-gs1-raster-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-gs1-raster-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-plain-code128-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-plain-code128-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-plain-qr-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-scanner-code128-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-scanner-ean13-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/forge-scanner-ean13-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/image-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/image-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/image-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/raster-text-layout-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/raster-text-layout-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/raster-text-layout-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/raster-text-rotation-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/raster-text-rotation-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/raster-text-rotation-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-barcode-fit-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-barcode-fit-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-raster-text-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-raster-text-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-raster-text-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-zpl-scene-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-zpl-scene-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/readme-zpl-scene-600.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/text-font0-203.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/text-font0-300.zpl create mode 100644 renderer/src/test/resources/compat-v1/zpl/text-font0-600.zpl diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplSceneOneCompatibilityTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplSceneOneCompatibilityTest.java new file mode 100644 index 0000000..d79770c --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplSceneOneCompatibilityTest.java @@ -0,0 +1,80 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration.renderer; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The released version 1 bytes, as the host receives them: each stored ZPL of + ** the renderer's `compat-v1` corpus is exactly the bytes its manifest names and + ** passes the unchanged host printer gate for the stock it was rendered for. The + ** corpus has one copy, in the renderer module, so the two tests cannot drift. + *******************************************************************************/ +class ZplSceneOneCompatibilityTest +{ + private static final Path CORPUS = Path.of("..", "..", "renderer", "src", "test", "resources", "compat-v1"); + + + + /*************************************************************************** + ** One manifest line per stored output: hash, two spaces, path. + ***************************************************************************/ + static List outputs() throws Exception + { + List lines = Files.readAllLines(CORPUS.resolve("SHA256SUMS"), StandardCharsets.US_ASCII); + assertFalse(lines.isEmpty()); + return (lines); + } + + + + /*************************************************************************** + ** A released label that the host gate refused could never have printed. + ***************************************************************************/ + @ParameterizedTest(name = "{0}") + @MethodSource("outputs") + void releasedBytesAreIntactAndPassTheHostPrinterGate(String line) throws Exception + { + assertTrue(line.matches("[0-9a-f]{64} zpl/[a-z0-9-]+\\.zpl"), line); + String name = line.substring(70, line.length() - 4); + byte[] bytes = Files.readAllBytes(CORPUS.resolve("zpl").resolve(name + ".zpl")); + assertEquals(line.substring(0, 64), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), name); + JsonNode stock = RendererJson.JSON.readTree(Files.readAllBytes(CORPUS.resolve("cases").resolve(name + ".json"))).path("stock"); + GenerationPorts.Stock printed = new GenerationPorts.Stock(stock.path("widthMicrons").intValue(), stock.path("heightMicrons").intValue(), stock.path("topMicrons").intValue(), + stock.path("rightMicrons").intValue(), stock.path("bottomMicrons").intValue(), stock.path("leftMicrons").intValue(), stock.path("dpi").intValue()); + assertDoesNotThrow(() -> ZplOutputPolicy.validate(bytes, printed), name); + } +} diff --git a/renderer/src/test/java/com/kingsrook/qbits/renderer/ZplSceneOneCompatibilityTest.java b/renderer/src/test/java/com/kingsrook/qbits/renderer/ZplSceneOneCompatibilityTest.java new file mode 100644 index 0000000..f637c92 --- /dev/null +++ b/renderer/src/test/java/com/kingsrook/qbits/renderer/ZplSceneOneCompatibilityTest.java @@ -0,0 +1,229 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.renderer; + + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** Version 1 scenes are frozen. Every scene of the corpus in `compat-v1` must + ** render to exactly the bytes the released renderer (tag `v1.1.0-RC.2`) produced + ** for it. The stored bytes are never regenerated: a difference here is a change + ** to labels that are already printing, and is fixed in the code, not the corpus. + *******************************************************************************/ +class ZplSceneOneCompatibilityTest +{ + private static final String ROOT = "/compat-v1/"; + + //////////////////////////////////////////////////////////////////////////////////////////// + // every property a version 1 element reads, by element type; the corpus must use each one // + //////////////////////////////////////////////////////////////////////////////////////////// + private static final Map> OPTIONS = Map.of( + "text", Set.of("x", "y", "width", "height", "value", "valuePath"), + "rasterText", Set.of("x", "y", "fontAsset", "fontSize", "width", "height", "rotation", "align", "lineHeight", "threshold", "value", "valuePath"), + "box", Set.of("x", "y", "width", "height", "thickness"), + "image", Set.of("x", "y", "asset"), + "barcode", Set.of("x", "y", "symbology", "symbologyPath", "allowedSymbologies", "value", "valuePath", "scale", "quiet", "options", "payloadEncoding", + "fit", "scannerSymbologyPath", "aimIdentifierPath")); + + + + /*************************************************************************** + ** Each case file is one render request: a stock, a context, assets and a scene. + ***************************************************************************/ + static List cases() throws Exception + { + try(Stream files = Files.list(Path.of(ZplSceneOneCompatibilityTest.class.getResource(ROOT + "cases").toURI()))) + { + return (files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".json")).map(name -> name.substring(0, name.length() - 5)).sorted().toList()); + } + } + + + + /*************************************************************************** + ** The released bytes, or the first position at which this build differs. What + ** this build produced is left in `target/compat-v1-actual` for inspection. + ***************************************************************************/ + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + void rendersExactlyTheReleasedBytes(String name) throws Exception + { + byte[] actual = render(name); + byte[] expected = resource("zpl/" + name + ".zpl"); + if(expected == null || !Arrays.equals(expected, actual)) + { + Path directory = Files.createDirectories(Path.of("target", "compat-v1-actual")); + Files.write(directory.resolve(name + ".zpl"), actual); + } + assertNotNull(expected, "No released bytes are stored for " + name); + int difference = Arrays.mismatch(expected, actual); + if(difference >= 0) + { + fail("Version 1 scene " + name + " no longer renders the released bytes: first difference at byte " + difference + + " (released " + expected.length + " bytes, now " + actual.length + ")"); + } + assertEquals(manifest().get(name), Wire.sha256(actual)); + } + + + + /*************************************************************************** + ** The manifest names exactly the cases, and each stored file has its hash, so + ** a case cannot be dropped or its bytes edited without this failing. + ***************************************************************************/ + @Test + void manifestNamesExactlyTheCasesAndTheirStoredBytes() throws Exception + { + Map manifest = manifest(); + assertEquals(cases(), List.copyOf(manifest.keySet())); + assertFalse(manifest.isEmpty()); + for(Map.Entry entry : manifest.entrySet()) + { + byte[] stored = resource("zpl/" + entry.getKey() + ".zpl"); + assertNotNull(stored, entry.getKey()); + assertEquals(entry.getValue(), Wire.sha256(stored), entry.getKey()); + } + } + + + + /*************************************************************************** + ** The corpus is only a proof if it reaches every element type and every + ** property version 1 reads, at each resolution the renderer accepts. + ***************************************************************************/ + @Test + void corpusUsesEveryVersionOneElementOptionAndResolution() throws Exception + { + Map> used = new TreeMap<>(); + Set resolutions = new TreeSet<>(); + for(String name : cases()) + { + JsonNode definition = definition(name); + resolutions.add(definition.path("stock").path("dpi").intValue()); + assertEquals("qqq-zpl-scene/1", definition.path("scene").path("schema").textValue(), name); + for(JsonNode element : definition.path("scene").path("elements")) + { + Set properties = used.computeIfAbsent(element.path("type").textValue(), type -> new HashSet<>()); + element.fieldNames().forEachRemaining(properties::add); + } + } + assertEquals(Set.of(203, 300, 600), resolutions); + assertEquals(OPTIONS.keySet(), used.keySet()); + for(Map.Entry> entry : OPTIONS.entrySet()) + { + assertTrue(used.get(entry.getKey()).containsAll(entry.getValue()), () -> entry.getKey() + " is missing one of " + entry.getValue()); + } + } + + + + /*************************************************************************** + ** Render through the public protocol entry point, as the isolated worker does. + ***************************************************************************/ + private static byte[] render(String name) throws Exception + { + JsonNode definition = definition(name); + ObjectNode request = Wire.JSON.createObjectNode(); + request.put("protocol", Wire.PROTOCOL).put("requestId", "compat-v1-" + name).put("operation", "RENDER").put("format", "ZPL").put("maximumBytes", 8 * 1024 * 1024); + request.set("stock", definition.get("stock")); + request.set("context", definition.get("context")); + request.put("source", Wire.JSON.writeValueAsString(definition.get("scene"))); + var assets = request.putArray("assets"); + for(JsonNode declared : definition.path("assets")) + { + byte[] bytes; + try(InputStream stream = ZplSceneOneCompatibilityTest.class.getResourceAsStream(declared.path("resource").textValue())) + { + bytes = java.util.Objects.requireNonNull(stream, declared.path("resource").textValue()).readAllBytes(); + } + ObjectNode asset = ((ObjectNode) declared.deepCopy()).put("base64", Base64.getEncoder().encodeToString(bytes)).put("sha256", Wire.sha256(bytes)); + asset.remove("resource"); + assets.add(asset); + } + Map response = RendererMain.process(request); + assertEquals("OK", response.get("outcome"), name); + return (Base64.getDecoder().decode((String) response.get("base64"))); + } + + + + /*************************************************************************** + ** One case definition. + ***************************************************************************/ + private static JsonNode definition(String name) throws Exception + { + return (Wire.JSON.readTree(java.util.Objects.requireNonNull(resource("cases/" + name + ".json"), name))); + } + + + + /*************************************************************************** + ** `SHA256SUMS` in the form `sha256sum` writes and checks: hash, two spaces, path. + ***************************************************************************/ + private static Map manifest() throws Exception + { + Map hashes = new TreeMap<>(); + byte[] sums = resource("SHA256SUMS"); + for(String line : sums == null ? new String[0] : new String(sums, StandardCharsets.US_ASCII).split("\n")) + { + assertTrue(line.matches("[0-9a-f]{64} zpl/[a-z0-9-]+\\.zpl"), line); + hashes.put(line.substring(70, line.length() - 4), line.substring(0, 64)); + } + return (hashes); + } + + + + /*************************************************************************** + ** Corpus bytes, or null when the corpus has no such file. + ***************************************************************************/ + private static byte[] resource(String path) throws Exception + { + try(InputStream stream = ZplSceneOneCompatibilityTest.class.getResourceAsStream(ROOT + path)) + { + return (stream == null ? null : stream.readAllBytes()); + } + } +} diff --git a/renderer/src/test/resources/compat-v1/README.md b/renderer/src/test/resources/compat-v1/README.md new file mode 100644 index 0000000..c850b90 --- /dev/null +++ b/renderer/src/test/resources/compat-v1/README.md @@ -0,0 +1,47 @@ +# Version 1 compatibility corpus + +Scenes with schema `qqq-zpl-scene/1` are frozen: a label that prints today must print the same bytes after any +later change. This directory holds 42 render requests and the exact ZPL the released renderer produced for each. + +| Path | Content | +|---|---| +| `cases/.json` | One render request: `stock` (with its `dpi`), `context`, `assets` and the `scene`. | +| `zpl/.zpl` | The bytes the released renderer returned for that request. | +| `SHA256SUMS` | The SHA-256 of every stored output, in the form `sha256sum -c` reads. | +| `assets/test-mark.png` | A synthetic 48 x 24 image: a frame, a grey ramp and a transparency ramp. | + +Fonts come from the existing test resources (`/fonts/Abel-Regular.ttf`, `/fonts/jetbrains-mono/`). + +## What it covers + +- The shapes Forge publishes, at the 203 and 300 dpi its printers report: the plain Echo label (dynamic symbology + from an allowlist, fitted), the scanner-fidelity label (scanner label and AIM identifier), the compact GS1-128 + label (`fit` and `quiet`, with the human-readable line as `text` in font 0) and the centred GS1-128 document + (`rasterText` with `fontAsset`; its barcode carries an `align` key, which version 1 ignores). +- The examples of `renderer/README.md` and `renderer/BARCODE-FIT.md`, verbatim. +- One scene per element type and option at 203, 300 and 600 dpi: `text`, `box`, `image`, `rasterText` (alignment, + rotation, line height, threshold, wrapping, three faces) and `barcode` (scale, options, bound values, and all 13 + fit-qualified symbologies). + +Every value is synthetic (`TEST-0001`, `(01)00012345678905(11)260101(21)TEST0001`, `example.invalid`). + +Not covered, because they need the native Zint provider that exists only in the renderer image: `payloadEncoding` +`BASE64` and the Zint-backed symbologies. The image qualifications cover those. + +## Where the bytes came from + +They were produced by the source of tag `v1.1.0-RC.2` (commit `e9ffba484e8d97f9853cb071cdac14f3bfd7acda`, +renderer `qqq-renderer-0.1.0`, catalog `qqq-barcodes-9`), not by the branch that added this directory: +`ZplSceneOneCompatibilityTest` and `cases/` were copied into a checkout of that tag and run there with no stored +outputs. The test fails in that state and leaves what it rendered in `target/compat-v1-actual`; those files are +`zpl/`. The run was on Temurin 21.0.12 on Windows, and the same test then passed against the same tag on Temurin +21.0.9 on Linux (the CI image), so the `rasterText` pixels do not depend on either platform. + +## Rules + +- Never regenerate `zpl/` or `SHA256SUMS`, and never edit a case. If `ZplSceneOneCompatibilityTest` fails, the + code changed what a version 1 scene prints; fix the code. The first differing byte is in the failure message + and the new bytes are in `renderer/target/compat-v1-actual`. +- A new case may be added only with bytes produced the same way, from a tag that is in production. +- `bridges/renderer` reads this directory in place (its own `ZplSceneOneCompatibilityTest` proves each stored + output passes `ZplOutputPolicy`), so there is one copy. diff --git a/renderer/src/test/resources/compat-v1/SHA256SUMS b/renderer/src/test/resources/compat-v1/SHA256SUMS new file mode 100644 index 0000000..8563880 --- /dev/null +++ b/renderer/src/test/resources/compat-v1/SHA256SUMS @@ -0,0 +1,42 @@ +ff0e556b910d9cf3a62cec78f388788c7a466942d8d43462d6cc8ff93d8a961c zpl/barcode-fit-linear-203.zpl +29f10d73be75d3c50d6f4b6a034a5d3819173593effa13e043d62f8a9b49e342 zpl/barcode-fit-linear-300.zpl +6ba73d75d91b5b7a204b2d8cc0c691438a0608872a0904ba8aa3fc3542c4a04e zpl/barcode-fit-linear-600.zpl +7c38e5f1d3816375b002f34a97473d543b57f538d91a6b93aec56f7fad96fc8c zpl/barcode-fit-matrix-203.zpl +dfaecb170fabd6a9d0214075f24f11c374edfd36a657e7d1acc0fe9fb480b365 zpl/barcode-fit-matrix-300.zpl +c2f397bc2503f087b342038c455f7aff9f279139dabce79f12d2e03f3a8ed242 zpl/barcode-fit-matrix-600.zpl +bde75c5c99ef064df4159385686c0a068ae0a82310a31b7703f16d8473d25f26 zpl/barcode-scale-203.zpl +02b16ec29d0cb9452cc787ca970181e53f6d88a5fa495aa16b6e131309c02b5f zpl/barcode-scale-300.zpl +7ce8d401428ff1c1ccf7587d1b36e5810a0c8762ffb5d217cf7e787b3a4d5806 zpl/barcode-scale-600.zpl +d24c7334422a6268566f6fc5826ed1a6f746a3c06fee15f251eabae93b15b0c5 zpl/box-203.zpl +97ee29fa76f9359351101c58aa9a72ca81b5e355cc609e42d1780af817f37562 zpl/box-300.zpl +d6874a29d5459c3d2f4b027108c9829cb9b0d5bdff7878fc8c4a5da8c55d5c72 zpl/box-600.zpl +89fe7d52804801fae99e53bfabada63eb08f85222f599abaa4bb4aea26869f64 zpl/forge-gs1-compact-203.zpl +5ac73d061da31c6a45da37e91eecb092803a5036cd8e39b96af26f109e2310f9 zpl/forge-gs1-compact-300.zpl +90065471a443d35c30a9426f9d0ac62773114ff57d484bea373b444e8ef3ee9a zpl/forge-gs1-raster-203.zpl +205a0ac35ce6df151b8c7feac081e8b1cab3fbdc8ca6a685e601a2782ddf3869 zpl/forge-gs1-raster-300.zpl +a626755d7fe9499b5b9b2dbb4bc64bdb60ba5857f267115b4762ab32276e8b30 zpl/forge-plain-code128-203.zpl +40552f1f04f58b67d69464e1b58a2ac3eeda98ac5af6d1a96a8b1b128637add5 zpl/forge-plain-code128-300.zpl +8d5391528d92f8afe8d6fb62df9f9ce3b6baa6bf3bfdf4133a7cfe60399b74e1 zpl/forge-plain-qr-203.zpl +a626755d7fe9499b5b9b2dbb4bc64bdb60ba5857f267115b4762ab32276e8b30 zpl/forge-scanner-code128-203.zpl +b5ea4f928c670ee06027b05406d45ab829c29d1676b05ca34728b350dfa60582 zpl/forge-scanner-ean13-203.zpl +b2b41c26316c843ef2156179a57e145c57f5006bcdc8f1497d2d263c743b948d zpl/forge-scanner-ean13-300.zpl +e45cb088bd56c470aa2ad0f7904e7cf97a75ada21b2cf0075679accc0a0456b6 zpl/image-203.zpl +b17fdf526032eb4fe6892f7737cb6c7a1069305c4ff85a9a4d902ebf29a1d778 zpl/image-300.zpl +0ae0dde4208c8696bfd36dd427e8f7735dbce3961572b6225f5cf4868ae55b2c zpl/image-600.zpl +0b100f9a37a78ceede0dbaaec9ba1e9bbf352b7675bd8900ede3faa5dc855ce7 zpl/raster-text-layout-203.zpl +ba4a8eea59afcfd67dbcf2982f70cc3b7083dd3259c439793d79f4cae78f6483 zpl/raster-text-layout-300.zpl +d7682aecebdbcc80fd2ade6cb2108cccd991fe05af35a28554111b00cdceb442 zpl/raster-text-layout-600.zpl +44231f071dc6131a13d9624454dcb796a7355d167040dfdc68ca7acefb12e06d zpl/raster-text-rotation-203.zpl +2a3a8a2529279a2ffe79771ea2f18aa66b08357a5ef5e49d52eaba60d3b2e781 zpl/raster-text-rotation-300.zpl +5e68955f87763277798413c26b9486f4ef5a60f3458c734612acbe6e9de813c3 zpl/raster-text-rotation-600.zpl +d4a61011f45eb4057dcbd5e2f7ee44e911b1c6ba9bba512a94ed45906143cce8 zpl/readme-barcode-fit-203.zpl +fc4fe98d71292b4cedb2bc15343c6b0ae1e71f3ad606422cd197429cf7d8658e zpl/readme-barcode-fit-300.zpl +4ff078a28668055a8a480fe909185c13977fdb665ab5d2acac882267e72a6fea zpl/readme-raster-text-203.zpl +850b76e86ca32fbee963052b3df69b431f021f76080b192b2954e11c9efde84a zpl/readme-raster-text-300.zpl +d094fd9bebc3d2131b7df1a5cb5b0fafcaefb226045f42985b91261e39e27ca4 zpl/readme-raster-text-600.zpl +f4ce3ef8723bc893784b1dbcb93ff831f22bdc0959b9500b0644a9ecdf9ce781 zpl/readme-zpl-scene-203.zpl +198762809d8d21e49c71dbcfcefb2f61315e5c182b0992ed136b10bb3968b5b0 zpl/readme-zpl-scene-300.zpl +48271003bc87ec1c14f53ccdb6810f810d08b63079169258915080a0f89a0688 zpl/readme-zpl-scene-600.zpl +2fd2eb3b5c9d761946b5c74656fc88838ed6f79848ea5eb296171539e25cd2e6 zpl/text-font0-203.zpl +dbd3697539e011d6c9d4c34185b849184fc22d5b836819df200d98478720ce06 zpl/text-font0-300.zpl +9ac70a9aa2bb8a5d41c13143617d338f1c3a50b0fea0f01d5054869c0bc3f31c zpl/text-font0-600.zpl diff --git a/renderer/src/test/resources/compat-v1/assets/test-mark.png b/renderer/src/test/resources/compat-v1/assets/test-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..11721c6a41f6510d66b1d7de13f3ba232740f205 GIT binary patch literal 333 zcmeAS@N?(olHy`uVBq!ia0vp^20$#q!3HGv{H?pgz`!Wt>EaktaqI2*iQ8HN7+5c; zbWYOnT%;n^IZ4H9k&4%(g&+zj);TFfLlVk7XFcCAe=YZK=4~pTLjNo|H|aPBimX`0 z<$HO-Yb&0^4Vkkx=x}e#ef+iRV4=*8b=>DaKlp9eq~IXY*0G4AP)6BPXh~+*_usub zrzbqO>{{dyISr_}GO)Rx@tb+0dcw{1&2V9_^Y8Rz`-?4NBO|Nfg4DYoWySMKc% z@2&eDAE=xMH1vDz<4QR}0Y)hL(aUgh$pkN{mnjoG#cqC4YLEGJVtIp8m&US|psHXu zOVN-ZS5wm~LGHG^CYM~~XT9|1Uc2R@AN$;ri@yCF9Su Date: Wed, 11 Nov 2026 17:28:42 -0600 Subject: [PATCH 2/3] build(coverage): gate renderer and bridge coverage at baseline; seeded mutation campaigns (P13) Coverage gates: - renderer and bridges/renderer gain JaCoCo (prepare-agent, report, check at verify) with a bundle rule at the measured baseline, rounded down: renderer 94% instructions and 91% lines, bridge 85% instructions and 82% lines. - New code is held to 100% line coverage from its first commit: packages com.kingsrook.qbits.renderer.scene2 and below, AssetInspector, ZplRaster, and the new foundation classes the label layout design names. The rules match nothing until those exist, so later packages do not edit these poms. - The foundation's 85% instruction and 90% class rule is unchanged. Fuzz scaffolding: - MutationCampaign in each module's test tree: a seeded, bounded mutation harness with no clock and no unseeded randomness; a finding replays from its seed and iteration. - One campaign per module in the normal test phase: what the version 1 scene parser accepts is a version 1 scene (checked by a second parser), and what the host printer gate accepts holds only the renderer's command set. - docs/security/fuzzing.md: targets, properties, limits, seeds and replay. Co-Authored-By: Claude Fable 5.2 --- README.md | 2 +- bridges/renderer/pom.xml | 17 ++ .../renderer/MutationCampaign.java | 218 ++++++++++++++++++ .../renderer/MutationCampaignTest.java | 175 ++++++++++++++ docs/security/fuzzing.md | 70 ++++++ foundation/pom.xml | 16 ++ renderer/pom.xml | 20 ++ .../qbits/renderer/MutationCampaign.java | 218 ++++++++++++++++++ .../qbits/renderer/MutationCampaignTest.java | 181 +++++++++++++++ 9 files changed, 916 insertions(+), 1 deletion(-) create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaign.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java create mode 100644 docs/security/fuzzing.md create mode 100644 renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaign.java create mode 100644 renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaignTest.java diff --git a/README.md b/README.md index 0860404..5670134 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ The root `pom.xml` is an aggregator only and lists every module, so one build pr Run `./mvnw verify` (`.\mvnw.cmd verify` on Windows) at the repository root. The foundation's tests include disposable MySQL 8.4 and PostgreSQL suites started with Testcontainers, so Docker access is required. They verify publication concurrency/commit/rollback, physical pooled-connection restoration, mapping/settings drift refusal, bounded lock contention, current-authorized context selection and immutable pins, audit/receipt failures, restart and incomplete results. They do not qualify production storage, hostile workers or physical printer output. -The build enforces Checkstyle plus 85% instruction/90% class coverage in the foundation. The published `qbit-build-parent` POMs leave `${revision}` in their own version, which causes a Maven parent-resolution warning despite a successful build. That upstream publication issue remains open; it is neither suppressed nor patched. +The build enforces Checkstyle plus 85% instruction/90% class coverage in the foundation, and holds the renderer (94% instructions, 91% lines) and its bridge (85% instructions, 82% lines) to their measured baselines; the new packages and classes named in each `pom.xml` must reach 100% line coverage. Bounded mutation campaigns over the scene parser and the host printer gate run in the test phase; see [docs/security/fuzzing.md](docs/security/fuzzing.md). The published `qbit-build-parent` POMs leave `${revision}` in their own version, which causes a Maven parent-resolution warning despite a successful build. That upstream publication issue remains open; it is neither suppressed nor patched. ## Maven publication diff --git a/bridges/renderer/pom.xml b/bridges/renderer/pom.xml index d5a7aba..c6cc17d 100644 --- a/bridges/renderer/pom.xml +++ b/bridges/renderer/pom.xml @@ -37,6 +37,23 @@ org.apache.maven.pluginsmaven-surefire-plugin3.5.4 org.apache.maven.pluginsmaven-dependency-plugin3.8.1runtime-distributionpackagecopy-dependenciesruntime${project.build.directory}/lib org.apache.maven.pluginsmaven-checkstyle-plugin3.3.1com.puppycrawl.toolscheckstyle10.12.6${project.basedir}/checkstyle/config.xmlcheckstyle.reactor.root=${project.basedir}truetruewarningtruevalidatecheck + + + org.jacocojacoco-maven-plugin0.8.15 + prepare-agent + reportverifyreport + coverageverifycheck + BUNDLE + INSTRUCTIONCOVEREDRATIO0.85 + LINECOVEREDRATIO0.82 + + CLASScom.kingsrook.qbits.documentgeneration.renderer.ZplRastercom.kingsrook.qbits.documentgeneration.renderer.ZplRaster.* + LINECOVEREDRATIO1.00 + + + org.codehaus.mojoflatten-maven-plugin1.7.3 diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaign.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaign.java new file mode 100644 index 0000000..6a2c95f --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaign.java @@ -0,0 +1,218 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration.renderer; + + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.SplittableRandom; +import java.util.function.Predicate; + + +/******************************************************************************* + ** Seeded, bounded mutation campaign for a parser of untrusted input. A seed, an + ** iteration number, the corpus and the dictionary fully determine every input: + ** there is no clock and no unseeded randomness, so a finding replays exactly. + ** Targets, limits and the replay command are in `docs/security/fuzzing.md`. The + ** same class is kept in the test tree of `renderer`. + *******************************************************************************/ +final class MutationCampaign +{ + static final int MAXIMUM_ITERATIONS = 100_000; + static final int MAXIMUM_INPUT_BYTES = 1024 * 1024; + + private static final byte[] INTERESTING = {0, 9, 10, 13, ' ', '"', '\\', '^', '~', '_', ',', '{', '}', '[', ']', '0', '9', '-', (byte) 0x7F, (byte) 0x80, (byte) 0xC0, (byte) 0xFF}; + + private final List corpus; + private final List dictionary; + + + + /*************************************************************************** + ** The parser under test. Returning normally means the input was accepted; a + ** target asserts its own property on what it accepts. + ***************************************************************************/ + @FunctionalInterface + interface Target + { + /************************************************************************ + ** Parse one generated input. + ************************************************************************/ + void accept(byte[] input) throws Exception; + } + + + + /*************************************************************************** + ** An input that was neither accepted nor refused in the declared way, with + ** the coordinates that regenerate it: `input(seed, iteration)`. + ***************************************************************************/ + record Finding(long seed, int iteration, String failure) + { + } + + + + /*************************************************************************** + ** What a campaign saw. A campaign that accepted or refused nothing has not + ** exercised both sides of the parser, so callers assert on the counts too. + ***************************************************************************/ + record Result(int accepted, int refused, List findings) + { + } + + + + /*************************************************************************** + ** Valid inputs to start from, and tokens of the format to splice in. + ***************************************************************************/ + MutationCampaign(List corpus, List dictionary) + { + if(corpus.isEmpty() || corpus.stream().anyMatch(seedInput -> seedInput.length > MAXIMUM_INPUT_BYTES)) + { + throw new IllegalArgumentException("A campaign needs a corpus of inputs within the input limit."); + } + this.corpus = corpus.stream().map(byte[]::clone).toList(); + this.dictionary = dictionary.stream().map(token -> token.getBytes(StandardCharsets.UTF_8)).toList(); + } + + + + /*************************************************************************** + ** The seeds for this run: the fixed ones, or only `-Dfuzz.seed=` when a + ** finding is replayed or a longer campaign is run by hand. + ***************************************************************************/ + static long[] seeds(long... fixed) + { + Long replay = Long.getLong("fuzz.seed"); + return (replay == null ? fixed : new long[] {replay}); + } + + + + /*************************************************************************** + ** The iteration count for this run: `-Dfuzz.iterations=`, never unbounded. + ***************************************************************************/ + static int iterations(int fallback) + { + int iterations = Integer.getInteger("fuzz.iterations", fallback); + if(iterations < 1 || iterations > MAXIMUM_ITERATIONS) + { + throw new IllegalArgumentException("fuzz.iterations must be between 1 and " + MAXIMUM_ITERATIONS); + } + return (iterations); + } + + + + /*************************************************************************** + ** The input of one iteration: a corpus entry with one to four mutations. + ***************************************************************************/ + byte[] input(long seed, int iteration) + { + SplittableRandom random = new SplittableRandom(seed + 0x9E3779B97F4A7C15L * (iteration + 1L)); + byte[] value = corpus.get(random.nextInt(corpus.size())).clone(); + int steps = 1 + random.nextInt(4); + for(int step = 0; step < steps; step++) + { + value = mutate(value, random); + } + return (value.length > MAXIMUM_INPUT_BYTES ? Arrays.copyOf(value, MAXIMUM_INPUT_BYTES) : value); + } + + + + /*************************************************************************** + ** Run iterations 0 to `iterations - 1`. A thrown failure that `refusal` + ** recognises is the parser saying no; any other, including a failed assertion + ** in the target and a stack overflow, is a finding. + ***************************************************************************/ + Result run(long seed, int iterations, Target target, Predicate refusal) + { + if(iterations < 1 || iterations > MAXIMUM_ITERATIONS) + { + throw new IllegalArgumentException("A campaign runs between 1 and " + MAXIMUM_ITERATIONS + " iterations."); + } + int accepted = 0; + int refused = 0; + List findings = new ArrayList<>(); + for(int iteration = 0; iteration < iterations; iteration++) + { + try + { + target.accept(input(seed, iteration)); + accepted++; + } + catch(Exception | AssertionError | StackOverflowError failure) + { + if(refusal.test(failure)) + { + refused++; + } + else + { + findings.add(new Finding(seed, iteration, failure.toString())); + } + } + } + return (new Result(accepted, refused, List.copyOf(findings))); + } + + + + /*************************************************************************** + ** One structural or byte-level change at a random position. + ***************************************************************************/ + private byte[] mutate(byte[] value, SplittableRandom random) + { + int at = value.length == 0 ? 0 : random.nextInt(value.length); + int span = value.length == 0 ? 0 : 1 + random.nextInt(Math.min(16, value.length - at)); + byte[] noise = new byte[1 + random.nextInt(8)]; + random.nextBytes(noise); + return (switch(random.nextInt(8)) + { + case 0 -> splice(value, at, span, new byte[0]); + case 1 -> splice(value, at, 0, Arrays.copyOfRange(value, at, at + span)); + case 2 -> Arrays.copyOf(value, at); + case 3 -> splice(value, at, 0, dictionary.isEmpty() ? noise : dictionary.get(random.nextInt(dictionary.size()))); + case 4 -> splice(value, at, 0, noise); + case 5 -> splice(value, at, Math.min(1, value.length), new byte[] {INTERESTING[random.nextInt(INTERESTING.length)]}); + case 6 -> splice(value, at, Math.min(1, value.length), new byte[] {noise[0]}); + default -> splice(value, at, Math.min(1, value.length), value.length == 0 ? noise : new byte[] {(byte) (value[at] ^ (1 << random.nextInt(8)))}); + }); + } + + + + /*************************************************************************** + ** Replace `removed` bytes at `at` with `inserted`. + ***************************************************************************/ + private static byte[] splice(byte[] value, int at, int removed, byte[] inserted) + { + byte[] result = new byte[value.length - removed + inserted.length]; + System.arraycopy(value, 0, result, 0, at); + System.arraycopy(inserted, 0, result, at, inserted.length); + System.arraycopy(value, at + removed, result, at + inserted.length, value.length - at - removed); + return (result); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java new file mode 100644 index 0000000..63f8673 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java @@ -0,0 +1,175 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration.renderer; + + +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The mutation harness is deterministic and reports what it should, and one + ** bounded campaign runs in the normal test phase over the host printer gate. + ** Seeds, limits and the replay command: `docs/security/fuzzing.md`. + *******************************************************************************/ +class MutationCampaignTest +{ + private static final long[] SEEDS = {20261111L, 7919L}; + private static final int ITERATIONS = 20_000; + private static final Path CORPUS = Path.of("..", "..", "renderer", "src", "test", "resources", "compat-v1", "zpl"); + private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(76200, 50800, 0, 0, 0, 0, 203); + private static final Set COMMANDS = Set.of("XA", "XZ", "PW", "LL", "LH", "CI", "FO", "A0", "FH", "FD", "FS", "GB", "GF"); + + + + /*************************************************************************** + ** A seed and an iteration name one input, on every run and every machine. + ***************************************************************************/ + @Test + void aSeedAndAnIterationAlwaysGiveTheSameInput() + { + MutationCampaign campaign = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB")); + MutationCampaign again = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB")); + Set distinct = new HashSet<>(); + for(int iteration = 0; iteration < 200; iteration++) + { + byte[] input = campaign.input(42L, iteration); + assertArrayEquals(input, again.input(42L, iteration)); + assertTrue(input.length <= MutationCampaign.MAXIMUM_INPUT_BYTES); + distinct.add(HexFormat.of().formatHex(input)); + } + assertTrue(distinct.size() > 100, "mutations should rarely repeat: " + distinct.size()); + assertNotEquals(HexFormat.of().formatHex(campaign.input(42L, 0)), HexFormat.of().formatHex(campaign.input(43L, 0))); + } + + + + /*************************************************************************** + ** The harness fails when it should: a crash and an unsafe acceptance are both + ** findings, each carrying the coordinates that regenerate its input. + ***************************************************************************/ + @Test + void aCrashAndAnUnsafeAcceptanceAreFindingsThatReplay() + { + MutationCampaign campaign = new MutationCampaign(List.of("0123456789".getBytes(StandardCharsets.US_ASCII)), List.of()); + MutationCampaign.Result result = campaign.run(42L, 300, input -> + { + if(input.length % 3 == 0) + { + throw new IllegalArgumentException("REFUSED"); + } + if(input.length % 3 == 1) + { + throw new IllegalStateException("crash"); + } + assertTrue(input.length < 11, "accepted an input it should have refused"); + }, IllegalArgumentException.class::isInstance); + assertEquals(300, result.accepted() + result.refused() + result.findings().size()); + assertTrue(result.accepted() > 0 && result.refused() > 0); + assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().startsWith("java.lang.IllegalStateException"))); + assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().contains("AssertionFailedError"))); + for(MutationCampaign.Finding finding : result.findings()) + { + int length = campaign.input(finding.seed(), finding.iteration()).length; + assertTrue(length % 3 == 1 || (length % 3 == 2 && length >= 11), finding.toString()); + } + } + + + + /*************************************************************************** + ** A campaign is always bounded and always has something to mutate. + ***************************************************************************/ + @Test + void campaignsAreBounded() + { + MutationCampaign campaign = new MutationCampaign(List.of(new byte[0]), List.of()); + assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, 0, input -> {}, failure -> true)); + assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, MutationCampaign.MAXIMUM_ITERATIONS + 1, input -> {}, failure -> true)); + assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(), List.of())); + assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(new byte[MutationCampaign.MAXIMUM_INPUT_BYTES + 1]), List.of())); + assertEquals(50, campaign.run(1L, 50, input -> {}, failure -> false).accepted()); + } + + + + /*************************************************************************** + ** Property: whatever the host printer gate accepts holds only the commands + ** the renderer may emit, and no tilde command. Every other input is refused + ** with the gate's own IllegalArgumentException, or with the strict decoder's + ** CharacterCodingException for field text that is not UTF-8; any other + ** failure is a finding. + ***************************************************************************/ + @Test + void theHostPrinterGateAcceptsOnlyTheRendererCommandSet() throws Exception + { + List corpus = new ArrayList<>(); + for(String name : List.of("box-203", "text-font0-203", "image-203", "barcode-scale-203", "raster-text-rotation-203")) + { + byte[] released = Files.readAllBytes(CORPUS.resolve(name + ".zpl")); + ZplOutputPolicy.validate(released, STOCK); + corpus.add(released); + } + MutationCampaign campaign = new MutationCampaign(corpus, List.of("^XA", "^XZ\n", "^FO", "^GFA,", "^GB", "^FS\n", "^A0N,", "^FH_^FD", "^PW", "^LL", "^LH0,0", "^CI28\n", + "~DG", "~JR", "^XG", "^DF", "^JU", "^FX", "^FB", "99999", "0", ",", "\n", "_5E", "_7E")); + int iterations = MutationCampaign.iterations(ITERATIONS); + for(long seed : MutationCampaign.seeds(SEEDS)) + { + MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::gate, + failure -> failure instanceof IllegalArgumentException || failure instanceof CharacterCodingException); + assertEquals(List.of(), result.findings(), "replay with -Dfuzz.seed=" + seed + " -Dfuzz.iterations=" + iterations); + assertFalse(result.accepted() == 0 || result.refused() == 0, "the campaign must reach both acceptance and refusal: " + result); + } + } + + + + /*************************************************************************** + ** The target, and an independent reading of what it let through. + ***************************************************************************/ + private static void gate(byte[] zpl) throws Exception + { + ZplOutputPolicy.validate(zpl, STOCK); + for(int index = 0; index < zpl.length; index++) + { + assertNotEquals('~', (char) zpl[index], "accepted a tilde command"); + if(zpl[index] == '^') + { + assertTrue(index + 2 < zpl.length && COMMANDS.contains(new String(zpl, index + 1, 2, StandardCharsets.US_ASCII)), "accepted a command outside the renderer set"); + } + } + } +} diff --git a/docs/security/fuzzing.md b/docs/security/fuzzing.md new file mode 100644 index 0000000..9517642 --- /dev/null +++ b/docs/security/fuzzing.md @@ -0,0 +1,70 @@ +# 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` | +| Host printer gate, `ZplOutputPolicy.validate` | `bridges/renderer` | ZPL bytes returned by the worker | `MutationCampaignTest.theHostPrinterGateAcceptsOnlyTheRendererCommandSet` | + +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`): bit +flips, byte and range edits, truncation, duplication and tokens of the format spliced in. Each campaign checks +unsafe acceptance, not only crashes: + +- **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. Any exception is a refusal, because the + worker turns every exception into its opaque error answer. A stack overflow or a failed acceptance check is a + finding. +- **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. + +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. + +```sh +# 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 and generated scenes are only validated, not rendered. + +## 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. The version 2 scene parser and `ZplRaster` are the next +two targets. + +## Review record + +- **Added:** 2026-11-11, with the two campaigns above. +- 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. diff --git a/foundation/pom.xml b/foundation/pom.xml index 762c68c..119cd2f 100644 --- a/foundation/pom.xml +++ b/foundation/pom.xml @@ -48,6 +48,22 @@ coverageverifycheckBUNDLE INSTRUCTIONCOVEREDRATIO0.85 CLASSCOVEREDRATIO0.90 + + + CLASS + com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic* + com.kingsrook.qbits.documentgeneration.GenerationPorts.Preview* + com.kingsrook.qbits.documentgeneration.GenerationPreviewProcesses* + com.kingsrook.qbits.documentgeneration.GenerationPreviewWidget* + com.kingsrook.qbits.documentgeneration.GenerationAssetProcesses* + com.kingsrook.qbits.documentgeneration.GenerationAssetTables* + com.kingsrook.qbits.documentgeneration.GenerationAssetStore* + com.kingsrook.qbits.documentgeneration.GenerationUpgradeProcesses* + com.kingsrook.qbits.documentgeneration.DocumentPreviewService* + com.kingsrook.qbits.documentgeneration.DocumentAvailability* + + LINECOVEREDRATIO1.00 diff --git a/renderer/pom.xml b/renderer/pom.xml index 8acc633..3a7bf73 100644 --- a/renderer/pom.xml +++ b/renderer/pom.xml @@ -36,6 +36,26 @@ org.apache.maven.pluginsmaven-dependency-plugin3.9.0runtime-librariespackagecopy-dependenciesruntime${project.build.directory}/lib org.apache.maven.pluginsmaven-jar-plugin3.4.2com.kingsrook.qbits.renderer.RendererMaintruelib/ org.apache.maven.pluginsmaven-checkstyle-plugin3.3.1com.puppycrawl.toolscheckstyle10.12.6${project.basedir}/checkstyle/config.xmlcheckstyle.reactor.root=${project.basedir}truetruewarningtruevalidatecheck + + + org.jacocojacoco-maven-plugin0.8.15 + prepare-agent + reportverifyreport + coverageverifycheck + BUNDLE + INSTRUCTIONCOVEREDRATIO0.94 + LINECOVEREDRATIO0.91 + + PACKAGEcom.kingsrook.qbits.renderer.scene2com.kingsrook.qbits.renderer.scene2.* + LINECOVEREDRATIO1.00 + + CLASScom.kingsrook.qbits.renderer.AssetInspectorcom.kingsrook.qbits.renderer.AssetInspector.* + LINECOVEREDRATIO1.00 + + + org.codehaus.mojoflatten-maven-plugin1.7.3 diff --git a/renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaign.java b/renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaign.java new file mode 100644 index 0000000..2b1fd4d --- /dev/null +++ b/renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaign.java @@ -0,0 +1,218 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.renderer; + + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.SplittableRandom; +import java.util.function.Predicate; + + +/******************************************************************************* + ** Seeded, bounded mutation campaign for a parser of untrusted input. A seed, an + ** iteration number, the corpus and the dictionary fully determine every input: + ** there is no clock and no unseeded randomness, so a finding replays exactly. + ** Targets, limits and the replay command are in `docs/security/fuzzing.md`. The + ** same class is kept in the test tree of `bridges/renderer`. + *******************************************************************************/ +final class MutationCampaign +{ + static final int MAXIMUM_ITERATIONS = 100_000; + static final int MAXIMUM_INPUT_BYTES = 1024 * 1024; + + private static final byte[] INTERESTING = {0, 9, 10, 13, ' ', '"', '\\', '^', '~', '_', ',', '{', '}', '[', ']', '0', '9', '-', (byte) 0x7F, (byte) 0x80, (byte) 0xC0, (byte) 0xFF}; + + private final List corpus; + private final List dictionary; + + + + /*************************************************************************** + ** The parser under test. Returning normally means the input was accepted; a + ** target asserts its own property on what it accepts. + ***************************************************************************/ + @FunctionalInterface + interface Target + { + /************************************************************************ + ** Parse one generated input. + ************************************************************************/ + void accept(byte[] input) throws Exception; + } + + + + /*************************************************************************** + ** An input that was neither accepted nor refused in the declared way, with + ** the coordinates that regenerate it: `input(seed, iteration)`. + ***************************************************************************/ + record Finding(long seed, int iteration, String failure) + { + } + + + + /*************************************************************************** + ** What a campaign saw. A campaign that accepted or refused nothing has not + ** exercised both sides of the parser, so callers assert on the counts too. + ***************************************************************************/ + record Result(int accepted, int refused, List findings) + { + } + + + + /*************************************************************************** + ** Valid inputs to start from, and tokens of the format to splice in. + ***************************************************************************/ + MutationCampaign(List corpus, List dictionary) + { + if(corpus.isEmpty() || corpus.stream().anyMatch(seedInput -> seedInput.length > MAXIMUM_INPUT_BYTES)) + { + throw new IllegalArgumentException("A campaign needs a corpus of inputs within the input limit."); + } + this.corpus = corpus.stream().map(byte[]::clone).toList(); + this.dictionary = dictionary.stream().map(token -> token.getBytes(StandardCharsets.UTF_8)).toList(); + } + + + + /*************************************************************************** + ** The seeds for this run: the fixed ones, or only `-Dfuzz.seed=` when a + ** finding is replayed or a longer campaign is run by hand. + ***************************************************************************/ + static long[] seeds(long... fixed) + { + Long replay = Long.getLong("fuzz.seed"); + return (replay == null ? fixed : new long[] {replay}); + } + + + + /*************************************************************************** + ** The iteration count for this run: `-Dfuzz.iterations=`, never unbounded. + ***************************************************************************/ + static int iterations(int fallback) + { + int iterations = Integer.getInteger("fuzz.iterations", fallback); + if(iterations < 1 || iterations > MAXIMUM_ITERATIONS) + { + throw new IllegalArgumentException("fuzz.iterations must be between 1 and " + MAXIMUM_ITERATIONS); + } + return (iterations); + } + + + + /*************************************************************************** + ** The input of one iteration: a corpus entry with one to four mutations. + ***************************************************************************/ + byte[] input(long seed, int iteration) + { + SplittableRandom random = new SplittableRandom(seed + 0x9E3779B97F4A7C15L * (iteration + 1L)); + byte[] value = corpus.get(random.nextInt(corpus.size())).clone(); + int steps = 1 + random.nextInt(4); + for(int step = 0; step < steps; step++) + { + value = mutate(value, random); + } + return (value.length > MAXIMUM_INPUT_BYTES ? Arrays.copyOf(value, MAXIMUM_INPUT_BYTES) : value); + } + + + + /*************************************************************************** + ** Run iterations 0 to `iterations - 1`. A thrown failure that `refusal` + ** recognises is the parser saying no; any other, including a failed assertion + ** in the target and a stack overflow, is a finding. + ***************************************************************************/ + Result run(long seed, int iterations, Target target, Predicate refusal) + { + if(iterations < 1 || iterations > MAXIMUM_ITERATIONS) + { + throw new IllegalArgumentException("A campaign runs between 1 and " + MAXIMUM_ITERATIONS + " iterations."); + } + int accepted = 0; + int refused = 0; + List findings = new ArrayList<>(); + for(int iteration = 0; iteration < iterations; iteration++) + { + try + { + target.accept(input(seed, iteration)); + accepted++; + } + catch(Exception | AssertionError | StackOverflowError failure) + { + if(refusal.test(failure)) + { + refused++; + } + else + { + findings.add(new Finding(seed, iteration, failure.toString())); + } + } + } + return (new Result(accepted, refused, List.copyOf(findings))); + } + + + + /*************************************************************************** + ** One structural or byte-level change at a random position. + ***************************************************************************/ + private byte[] mutate(byte[] value, SplittableRandom random) + { + int at = value.length == 0 ? 0 : random.nextInt(value.length); + int span = value.length == 0 ? 0 : 1 + random.nextInt(Math.min(16, value.length - at)); + byte[] noise = new byte[1 + random.nextInt(8)]; + random.nextBytes(noise); + return (switch(random.nextInt(8)) + { + case 0 -> splice(value, at, span, new byte[0]); + case 1 -> splice(value, at, 0, Arrays.copyOfRange(value, at, at + span)); + case 2 -> Arrays.copyOf(value, at); + case 3 -> splice(value, at, 0, dictionary.isEmpty() ? noise : dictionary.get(random.nextInt(dictionary.size()))); + case 4 -> splice(value, at, 0, noise); + case 5 -> splice(value, at, Math.min(1, value.length), new byte[] {INTERESTING[random.nextInt(INTERESTING.length)]}); + case 6 -> splice(value, at, Math.min(1, value.length), new byte[] {noise[0]}); + default -> splice(value, at, Math.min(1, value.length), value.length == 0 ? noise : new byte[] {(byte) (value[at] ^ (1 << random.nextInt(8)))}); + }); + } + + + + /*************************************************************************** + ** Replace `removed` bytes at `at` with `inserted`. + ***************************************************************************/ + private static byte[] splice(byte[] value, int at, int removed, byte[] inserted) + { + byte[] result = new byte[value.length - removed + inserted.length]; + System.arraycopy(value, 0, result, 0, at); + System.arraycopy(inserted, 0, result, at, inserted.length); + System.arraycopy(value, at + removed, result, at + inserted.length, value.length - at - removed); + return (result); + } +} diff --git a/renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaignTest.java b/renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaignTest.java new file mode 100644 index 0000000..709b02c --- /dev/null +++ b/renderer/src/test/java/com/kingsrook/qbits/renderer/MutationCampaignTest.java @@ -0,0 +1,181 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.renderer; + + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The mutation harness is deterministic and reports what it should, and one + ** bounded campaign runs in the normal test phase over the version 1 scene + ** parser. Seeds, limits and the replay command: `docs/security/fuzzing.md`. + *******************************************************************************/ +class MutationCampaignTest +{ + private static final long[] SEEDS = {20261111L, 7919L}; + private static final int ITERATIONS = 20_000; + private static final Set ELEMENTS = Set.of("text", "rasterText", "box", "image", "barcode"); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // a second, differently configured parser, so the acceptance check does not reuse the target // + //////////////////////////////////////////////////////////////////////////////////////////////// + private static final ObjectMapper INDEPENDENT = new ObjectMapper(); + + + + /*************************************************************************** + ** A seed and an iteration name one input, on every run and every machine. + ***************************************************************************/ + @Test + void aSeedAndAnIterationAlwaysGiveTheSameInput() + { + MutationCampaign campaign = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB")); + MutationCampaign again = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB")); + Set distinct = new java.util.HashSet<>(); + for(int iteration = 0; iteration < 200; iteration++) + { + byte[] input = campaign.input(42L, iteration); + assertArrayEquals(input, again.input(42L, iteration)); + assertTrue(input.length <= MutationCampaign.MAXIMUM_INPUT_BYTES); + distinct.add(Wire.sha256(input)); + } + assertTrue(distinct.size() > 100, "mutations should rarely repeat: " + distinct.size()); + assertNotEquals(Wire.sha256(campaign.input(42L, 0)), Wire.sha256(campaign.input(43L, 0))); + } + + + + /*************************************************************************** + ** The harness fails when it should: a crash and an unsafe acceptance are both + ** findings, each carrying the coordinates that regenerate its input. + ***************************************************************************/ + @Test + void aCrashAndAnUnsafeAcceptanceAreFindingsThatReplay() + { + MutationCampaign campaign = new MutationCampaign(List.of("0123456789".getBytes(StandardCharsets.US_ASCII)), List.of()); + MutationCampaign.Result result = campaign.run(42L, 300, input -> + { + if(input.length % 3 == 0) + { + throw new IllegalArgumentException("REFUSED"); + } + if(input.length % 3 == 1) + { + throw new IllegalStateException("crash"); + } + assertTrue(input.length < 11, "accepted an input it should have refused"); + }, IllegalArgumentException.class::isInstance); + assertEquals(300, result.accepted() + result.refused() + result.findings().size()); + assertTrue(result.accepted() > 0 && result.refused() > 0); + assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().startsWith("java.lang.IllegalStateException"))); + assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().contains("AssertionFailedError"))); + for(MutationCampaign.Finding finding : result.findings()) + { + int length = campaign.input(finding.seed(), finding.iteration()).length; + assertTrue(length % 3 == 1 || (length % 3 == 2 && length >= 11), finding.toString()); + } + } + + + + /*************************************************************************** + ** A campaign is always bounded and always has something to mutate. + ***************************************************************************/ + @Test + void campaignsAreBounded() + { + MutationCampaign campaign = new MutationCampaign(List.of(new byte[0]), List.of()); + assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, 0, input -> {}, failure -> true)); + assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, MutationCampaign.MAXIMUM_ITERATIONS + 1, input -> {}, failure -> true)); + assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(), List.of())); + assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(new byte[MutationCampaign.MAXIMUM_INPUT_BYTES + 1]), List.of())); + assertEquals(50, campaign.run(1L, 50, input -> {}, failure -> false).accepted()); + } + + + + /*************************************************************************** + ** Property: whatever the version 1 scene parser accepts is, read by a second + ** parser, a version 1 scene of known elements within the element limit. Every + ** other input is refused with an exception (the worker turns any exception + ** into its opaque error answer); a stack overflow would be a finding. + ***************************************************************************/ + @Test + void theVersionOneSceneParserAcceptsOnlyVersionOneScenes() throws Exception + { + List corpus = new ArrayList<>(); + for(String name : List.of("text-font0-203", "box-203", "barcode-scale-203", "barcode-fit-matrix-203", "barcode-fit-linear-203")) + { + try(InputStream stream = MutationCampaignTest.class.getResourceAsStream("/compat-v1/cases/" + name + ".json")) + { + corpus.add(Wire.JSON.writeValueAsBytes(Wire.JSON.readTree(stream).get("scene"))); + } + } + MutationCampaign campaign = new MutationCampaign(corpus, List.of("\"type\"", "\"barcode\"", "\"rasterText\"", "\"fit\"", "\"valuePath\"", "\"symbologyPath\"", "\"allowedSymbologies\"", + "\"qqq-zpl-scene/1\"", "\"qqq-zpl-scene/2\"", "{", "}", "[", "]", ":", ",", "99999999999", "-1", "null", "\"/\"", "\\u0000")); + int iterations = MutationCampaign.iterations(ITERATIONS); + for(long seed : MutationCampaign.seeds(SEEDS)) + { + MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::validateScene, Exception.class::isInstance); + assertEquals(List.of(), result.findings(), "replay with -Dfuzz.seed=" + seed + " -Dfuzz.iterations=" + iterations); + assertFalse(result.accepted() == 0 || result.refused() == 0, "the campaign must reach both acceptance and refusal: " + result); + } + } + + + + /*************************************************************************** + ** The target: static validation of one scene on a stock all corpus scenes fit. + ***************************************************************************/ + private static void validateScene(byte[] scene) throws Exception + { + String source = new String(scene, StandardCharsets.UTF_8); + ObjectNode request = Wire.JSON.createObjectNode(); + request.put("protocol", Wire.PROTOCOL).put("requestId", "mutation-campaign").put("operation", "VALIDATE").put("format", "ZPL").put("maximumBytes", 1024 * 1024).put("source", source); + request.set("stock", Wire.JSON.valueToTree(Map.of("widthMicrons", 76200, "heightMicrons", 152400, "topMicrons", 2000, "rightMicrons", 2000, "bottomMicrons", 2000, "leftMicrons", 2000, "dpi", 203))); + request.putObject("context"); + request.putArray("assets"); + ZplRenderer.validate(new RenderInput(request)); + JsonNode accepted = INDEPENDENT.readTree(source); + assertEquals("qqq-zpl-scene/1", accepted.path("schema").textValue()); + assertTrue(accepted.path("elements").isArray() && accepted.path("elements").size() <= 1000); + for(JsonNode element : accepted.path("elements")) + { + assertTrue(ELEMENTS.contains(element.path("type").textValue()), () -> "accepted element type " + element.path("type")); + } + } +} -- 2.54.0 From c6aa0bf6b0f48ed582d3aeefc06f8d5ce03ba4f9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 17:28:43 -0600 Subject: [PATCH 3/3] feat(foundation): preview, diagnostic and render target ports with registration hooks (P6a) The contracts the preview, bridge and render target packages plug into. No label prints differently and no existing host adapter changes. GenerationPorts: - Diagnostic (with Severity): a code from a closed vocabulary, an author element id, a box and named integers. It has no field that holds free text. - PreviewRequest, PreviewPage and Preview, and IsolatedRenderer.preview as a default that refuses, so an adapter written before it compiles unchanged. - 15 version 2 FailureCode values after the 13 released ones, and RenderException(FailureCode, Diagnostic) beside the existing constructor. - Integer targetDpi on RenderRequest (absent, 203, 300 or 600); the seven-argument constructor is kept and nothing reads the value yet. Registration points, each an empty class that is already called: GenerationPreviewProcesses, GenerationAssetProcesses, GenerationUpgradeProcesses and GenerationAssetTables from the producer, and GenerationPreviewWidget.section(table) from GenerationTables. Bridge: IsolatedRendererBridge maps any FailureCode name from a 0.1.0 worker to a typed refusal, so growing the enum alone would have widened what such a worker can name. The 13 released codes are now the set a 0.1.0 worker may name; BridgeTest asserts the other 15 stay generic. Tests: record validation, default refusals, source compatibility of the existing constructors, and that the producer registers exactly the tables, processes, choices, widgets and template sections it did before (the lists were captured from the untouched v1.1.0-RC.2 tag). Co-Authored-By: Claude Fable 5.2 --- .../renderer/IsolatedRendererBridge.java | 11 + .../renderer/BridgeTest.java | 14 +- .../DocumentGenerationQBitProducer.java | 6 +- .../GenerationAssetProcesses.java | 46 +++ .../GenerationAssetTables.java | 47 +++ .../documentgeneration/GenerationPorts.java | 232 ++++++++++++- .../GenerationPreviewProcesses.java | 46 +++ .../GenerationPreviewWidget.java | 45 +++ .../documentgeneration/GenerationTables.java | 1 + .../GenerationUpgradeProcesses.java | 46 +++ .../GenerationPortsContractTest.java | 305 ++++++++++++++++++ .../GenerationRegistrationHooksTest.java | 111 +++++++ 12 files changed, 906 insertions(+), 4 deletions(-) create mode 100644 foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationAssetProcesses.java create mode 100644 foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationAssetTables.java create mode 100644 foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java create mode 100644 foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java create mode 100644 foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationUpgradeProcesses.java create mode 100644 foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java create mode 100644 foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java index 5fecc70..dd8850b 100644 --- a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java @@ -43,6 +43,13 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; *******************************************************************************/ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRenderer { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // the refusals renderer 0.1.0 can name. FailureCode also holds the version 2 scene refusals; a 0.1.0 worker // + // cannot produce those, so one that names them is rejected like any other unknown code // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + private static final Set RENDERER_0_1_REFUSALS = java.util.EnumSet.range( + GenerationPorts.FailureCode.BARCODE_FIT_TOO_SMALL, GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION); + private final RendererLimits config; private final AssetReader assets; private final Exchange exchange; @@ -268,6 +275,10 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen { throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); } + if(!RENDERER_0_1_REFUSALS.contains(code)) + { + throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); + } throw new GenerationPorts.RenderException(code); } int expectedFields = operation.equals("VALIDATE") ? 5 : 10; diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java index 00bab4c..85b0062 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java @@ -183,13 +183,25 @@ class BridgeTest @Test void exactWorkerErrorsPreserveClosedFailureCodes() throws Exception { - assertEquals(13, GenerationPorts.FailureCode.values().length); + var released = java.util.EnumSet.range(GenerationPorts.FailureCode.BARCODE_FIT_TOO_SMALL, GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION); + assertEquals(13, released.size()); + assertEquals(28, GenerationPorts.FailureCode.values().length); for(var code : GenerationPorts.FailureCode.values()) { var bridge = new IsolatedRendererBridge(config(directory), (actor, asset, maximum) -> new byte[0], (id, input, maximum) -> (RendererJson.JSON.writeValueAsString(Map.of("protocol", RendererJson.PROTOCOL, "requestId", id, "outcome", "ERROR", "rendererVersion", "qqq-renderer-0.1.0", "catalogVersion", "qqq-barcodes-9", "code", code.name())) + "\n").getBytes(StandardCharsets.UTF_8)); var request = request(Map.of(), List.of()); + if(!released.contains(code)) + { + // A version 2 scene refusal named by a 0.1.0 worker stays as unknown as it was before the vocabulary grew. + for(var generic : List.of(assertThrows(QException.class, () -> bridge.render(request)), assertThrows(QException.class, () -> bridge.validate("actor-1", request.revision())))) + { + assertEquals(QException.class, generic.getClass(), code.name()); + assertEquals("Isolated renderer rejected or could not complete the request.", generic.getMessage()); + } + continue; + } var failure = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(request)); assertEquals(code, failure.code()); assertEquals(code.name(), failure.getMessage()); diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationQBitProducer.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationQBitProducer.java index 74a6719..3841ac1 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationQBitProducer.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationQBitProducer.java @@ -24,6 +24,7 @@ package com.kingsrook.qbits.documentgeneration; import java.io.IOException; import java.io.InputStream; import java.util.Properties; +import java.util.stream.Stream; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -138,7 +139,7 @@ public class DocumentGenerationQBitProducer implements QBitMetaDataProducer + Stream.concat(GenerationTables.tables(config).stream(), GenerationAssetTables.tables(config).stream()).forEach(table -> { table.setSourceQBitName(bit.getName()); output.add(table); @@ -146,6 +147,9 @@ public class DocumentGenerationQBitProducer implements QBitMetaDataProducer tables(DocumentGenerationQBitConfig config) + { + return (List.of()); + } +} diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java index 8c8e2ca..8b15a70 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java @@ -38,6 +38,17 @@ public final class GenerationPorts { } + /*************************************************************************** + ** A render target is absent or one of the resolutions the renderer supports. + ***************************************************************************/ + private static void renderTarget(Integer dpi) + { + if(dpi != null && !List.of(203, 300, 600).contains(dpi)) + { + throw new IllegalArgumentException("A render target is 203, 300 or 600 dpi."); + } + } + /******************************************************************************* ** Initial output contracts; new formats require a qualified renderer adapter. *******************************************************************************/ @@ -93,7 +104,99 @@ public final class GenerationPorts SCANNER_BARCODE_PAYLOAD_UNSUPPORTED, SCANNER_BARCODE_CHECKSUM_REQUIRED, SCANNER_BARCODE_CHECKSUM_MISMATCH, - UNSUPPORTED_BARCODE_OPTION + UNSUPPORTED_BARCODE_OPTION, + ////////////////////////////////////////////////////////////////////////////////////// + // version 2 scene refusals; no released renderer names these for a version 1 scene // + ////////////////////////////////////////////////////////////////////////////////////// + SCENE_INVALID, + STOCK_MISMATCH, + TARGET_REQUIRES_SCENE_2, + ELEMENT_OUTSIDE_PRINTABLE_AREA, + TEXT_DOES_NOT_FIT, + VALUE_MISSING, + VALUE_TOO_LONG, + FONT_UNKNOWN, + FONT_MISSING_GLYPH, + RESERVED_FONT_NAME, + BARCODE_DOES_NOT_FIT, + GS1_INVALID, + ASSET_UNKNOWN, + PREVIEW_TOO_LARGE, + RENDERER_TOO_OLD + } + + /*************************************************************************** + ** One renderer finding about a layout: a code from a closed vocabulary, the + ** element id the template author wrote, and numbers. It has no free text, so + ** no record value, context text or exception message can travel in it. + ***************************************************************************/ + public record Diagnostic(String code, Severity severity, String element, List box, Map measured, Map limit) + { + private static final java.util.Set WARNINGS = java.util.Set.of("TEXT_TRUNCATED", "TEXT_SMALL_FOR_DPI", "TEXT_LIGHT_FACE", "FIT_NOT_PROVEN", + "BARCODE_MODULE_BELOW_RECOMMENDED", "QUIET_ZONE_INTRUDED", "ELEMENTS_OVERLAP", "IMAGE_UPSCALED", "DEVICE_UNIT_USED", "PRINTER_FONT_NOT_SHOWN"); + private static final java.util.Set INFORMATION = java.util.Set.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED"); + + /************************************************************************ + ** An error stops printing; a warning and information do not. + ************************************************************************/ + public enum Severity + { + ERROR, WARNING, INFO + } + + /************************************************************************ + ** Admit only the vocabulary: an error is a FailureCode name, a warning or + ** information is one of the fixed names. The element is absent or an + ** author-written id, the box is absent or x, y, width and height in dots, + ** and measured values and limits are at most eight named integers each. + ************************************************************************/ + public Diagnostic + { + if(severity == null || code == null || !known(code, severity)) + { + throw new IllegalArgumentException("Unknown diagnostic code for its severity."); + } + if(element != null && !element.matches("[A-Za-z][A-Za-z0-9_-]{0,31}")) + { + throw new IllegalArgumentException("Invalid diagnostic element id."); + } + if(box != null && (box.size() != 4 || box.stream().anyMatch(java.util.Objects::isNull) || box.get(2) < 0 || box.get(3) < 0)) + { + throw new IllegalArgumentException("A diagnostic box is x, y, width and height in dots."); + } + box = box == null ? null : List.copyOf(box); + measured = numbers(measured); + limit = numbers(limit); + } + + /************************************************************************ + ** The closed vocabulary, by severity. + ************************************************************************/ + private static boolean known(String code, Severity severity) + { + return (switch(severity) + { + case ERROR -> java.util.Arrays.stream(FailureCode.values()).anyMatch(failure -> failure.name().equals(code)); + case WARNING -> WARNINGS.contains(code); + case INFO -> INFORMATION.contains(code); + }); + } + + /************************************************************************ + ** Freeze named integers; a name is a short identifier, never a sentence. + ************************************************************************/ + private static Map numbers(Map values) + { + if(values == null) + { + return (Map.of()); + } + if(values.size() > 8 || values.entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,31}") || entry.getValue() == null)) + { + throw new IllegalArgumentException("Diagnostic values are at most eight named integers."); + } + return (Map.copyOf(values)); + } } /*************************************************************************** @@ -103,14 +206,29 @@ public final class GenerationPorts { private static final long serialVersionUID = 1L; private final FailureCode code; + private final transient Diagnostic diagnostic; /*************************************************************************** ** Construct a native process error whose public message is the safe code. ***************************************************************************/ public RenderException(FailureCode code) + { + this(code, null); + } + + /*************************************************************************** + ** A refusal that also says where and by how much: the diagnostic, when + ** present, is the error with this same code. The message stays the code. + ***************************************************************************/ + public RenderException(FailureCode code, Diagnostic diagnostic) { super(java.util.Objects.requireNonNull(code).name()); + if(diagnostic != null && (diagnostic.severity() != Diagnostic.Severity.ERROR || !code.name().equals(diagnostic.code()))) + { + throw new IllegalArgumentException("A refusal's diagnostic must be the error with the same code."); + } this.code = code; + this.diagnostic = diagnostic; } /*************************************************************************** @@ -120,6 +238,14 @@ public final class GenerationPorts { return (code); } + + /*************************************************************************** + ** The detail the worker gave with the refusal, or null when it gave none. + ***************************************************************************/ + public Diagnostic diagnostic() + { + return (diagnostic); + } } /******************************************************************************* @@ -223,7 +349,99 @@ public final class GenerationPorts ** Bounded worker job with all revision inputs captured before dispatch. *******************************************************************************/ public record RenderRequest(String requestId, String actorId, Source source, Format format, Revision revision, - Map context, Integer maximumBytes) {} + Map context, Integer maximumBytes, Integer targetDpi) + { + /*************************************************************************** + ** The render target is absent (the stock's own resolution) or a resolution + ** the renderer supports. + ***************************************************************************/ + public RenderRequest + { + renderTarget(targetDpi); + } + + /*************************************************************************** + ** Existing callers render at the stock's own resolution. + ***************************************************************************/ + public RenderRequest(String requestId, String actorId, Source source, Format format, Revision revision, + Map context, Integer maximumBytes) + { + this(requestId, actorId, source, format, revision, context, maximumBytes, null); + } + } + + /*************************************************************************** + ** A layout preview job: the print path stopped before retention. It has no + ** request id and no source record because nothing is written for it. + ***************************************************************************/ + public record PreviewRequest(String actorId, Format format, Revision revision, Map context, Integer targetDpi, Integer maximumBytes) + { + /*************************************************************************** + ** Refuse a job that names no format or content, an unsupported render + ** target or no output bound. + ***************************************************************************/ + public PreviewRequest + { + if(format == null || revision == null || maximumBytes == null || maximumBytes < 1) + { + throw new IllegalArgumentException("A preview needs a format, a revision and an output limit."); + } + renderTarget(targetDpi); + } + } + + /*************************************************************************** + ** One preview page: a PNG whose pixels are printer dots at the render target. + ***************************************************************************/ + public record PreviewPage(byte[] png, Integer widthDots, Integer heightDots) + { + private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10}; + + /*************************************************************************** + ** Only a PNG with a stated size is a page; the bytes cannot change later. + ***************************************************************************/ + public PreviewPage + { + if(png == null || png.length < SIGNATURE.length || !java.util.Arrays.equals(png, 0, SIGNATURE.length, SIGNATURE, 0, SIGNATURE.length) + || widthDots == null || widthDots < 1 || heightDots == null || heightDots < 1) + { + throw new IllegalArgumentException("A preview page is a PNG with its size in dots."); + } + png = png.clone(); + } + + /*************************************************************************** + ** Never expose the stored byte array to a consumer. + ***************************************************************************/ + @Override + public byte[] png() + { + return (png.clone()); + } + } + + /*************************************************************************** + ** What a preview shows: the pages, whether they are exactly what prints, + ** whether the layout can print at all, every diagnostic, and the size and + ** hash of the output the pages were decoded from. + ***************************************************************************/ + public record Preview(List pages, Boolean exact, Boolean printable, List diagnostics, Long outputBytes, String outputSha256) + { + /*************************************************************************** + ** Freeze the lists and refuse a contradiction: a layout with an error + ** diagnostic is not printable. + ***************************************************************************/ + public Preview + { + pages = List.copyOf(pages); + diagnostics = List.copyOf(diagnostics); + if(exact == null || printable == null || outputBytes == null || outputBytes < 0 || outputSha256 == null || !outputSha256.matches("[a-f0-9]{64}") + || (printable && diagnostics.stream().anyMatch(diagnostic -> diagnostic.severity() == Diagnostic.Severity.ERROR))) + { + throw new IllegalArgumentException("Invalid preview result."); + } + } + } /******************************************************************************* ** Temporary bounded worker result, discarded after retained intake. @@ -393,6 +611,16 @@ public final class GenerationPorts throw new QException("Renderer barcode reference is unavailable."); } + /*********************************************************************** + ** Optional layout preview: the print path stopped before retention, with + ** the pages decoded from the validated output. A renderer that does not + ** provide it refuses, so an existing adapter is unchanged. + ***********************************************************************/ + default Preview preview(PreviewRequest request) throws QException + { + throw new QException("Renderer preview is unavailable."); + } + /******************************************************************************* ** Authorize assets and validate user source outside the application JVM. *******************************************************************************/ diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java new file mode 100644 index 0000000..52cf9e6 --- /dev/null +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java @@ -0,0 +1,46 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.documentgeneration; + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + +/******************************************************************************* + ** Layout preview processes for a template: sample, stress and record data. + ** The producer already calls this registration point; it registers nothing yet. + *******************************************************************************/ +final class GenerationPreviewProcesses +{ + /******************************************************************************* + ** Metadata namespace. + *******************************************************************************/ + private GenerationPreviewProcesses() + { + } + + /******************************************************************************* + ** Called once by the producer after the generation and authoring processes. + *******************************************************************************/ + static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException + { + } +} diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java new file mode 100644 index 0000000..bbfec1d --- /dev/null +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java @@ -0,0 +1,45 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.documentgeneration; + +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** The "Label preview" section of the template record screen. The template + ** table already offers itself here after its own sections; nothing is added yet. + *******************************************************************************/ +final class GenerationPreviewWidget +{ + /******************************************************************************* + ** Metadata namespace. + *******************************************************************************/ + private GenerationPreviewWidget() + { + } + + /******************************************************************************* + ** Called once for the template table, after its standard sections. + *******************************************************************************/ + static void section(QTableMetaData table) + { + } +} diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java index edf9163..d01d7b1 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java @@ -182,6 +182,7 @@ public final class GenerationTables table.withSection(new QFieldSection("advanced", new QIcon("settings"), Tier.T3, List.of("id", "createDate", "scriptArtifact", "assetIds")).withLabel("Advanced references") .withCollapsible(CollapsibleMetaData.INITIALLY_CLOSED) .withHelpContent(help("These are exact stored identities for support and provenance. Change scripts and included assets through the named actions; do not edit integrity references."))); + GenerationPreviewWidget.section(table); } /******************************************************************************* diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationUpgradeProcesses.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationUpgradeProcesses.java new file mode 100644 index 0000000..0809d7f --- /dev/null +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationUpgradeProcesses.java @@ -0,0 +1,46 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.documentgeneration; + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; + +/******************************************************************************* + ** The authoring action that converts a version 1 scene to version 2. + ** The producer already calls this registration point; it registers nothing yet. + *******************************************************************************/ +final class GenerationUpgradeProcesses +{ + /******************************************************************************* + ** Metadata namespace. + *******************************************************************************/ + private GenerationUpgradeProcesses() + { + } + + /******************************************************************************* + ** Called once by the producer after the generation and authoring processes. + *******************************************************************************/ + static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException + { + } +} diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java new file mode 100644 index 0000000..5488f8a --- /dev/null +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java @@ -0,0 +1,305 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic.Severity; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/******************************************************************************* + ** The preview, diagnostic and render target ports: what each value admits, what + ** the defaults refuse, and that every existing constructor and adapter still + ** compiles and means what it did. + *******************************************************************************/ +class GenerationPortsContractTest +{ + private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(101600, 25400, 3000, 3000, 3000, 3000, 203); + private static final GenerationPorts.Revision REVISION = new GenerationPorts.Revision(1, 2, "orders", STOCK, null, "{}", null, List.of()); + private static final GenerationPorts.Source SOURCE = new GenerationPorts.Source("orders", "1"); + private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 0}; + private static final String SHA = "0".repeat(64); + + /*************************************************************************** + ** An adapter written before the preview port: it implements what it always + ** did, and this class compiling is the source compatibility proof. + ***************************************************************************/ + private static final class ExistingAdapter implements GenerationPorts.IsolatedRenderer + { + /************************************************************************ + ** Accept every revision. + ************************************************************************/ + @Override + public void validate(String actorId, GenerationPorts.Revision revision) + { + } + + /************************************************************************ + ** Return fixed bytes. + ************************************************************************/ + @Override + public GenerationPorts.Content render(GenerationPorts.RenderRequest request) + { + return (new GenerationPorts.Content(new byte[] {1})); + } + } + + /*************************************************************************** + ** The seven-argument request and the one-argument refusal are unchanged, and + ** an existing adapter refuses the optional operations it never had. + ***************************************************************************/ + @Test + void existingConstructorsAndAdaptersMeanWhatTheyDid() throws Exception + { + var request = new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of("record", Map.of()), 1024); + assertNull(request.targetDpi()); + assertEquals(request, new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of("record", Map.of()), 1024, null)); + var refusal = new GenerationPorts.RenderException(FailureCode.BARCODE_FIT_TOO_SMALL); + assertEquals(FailureCode.BARCODE_FIT_TOO_SMALL, refusal.code()); + assertEquals("BARCODE_FIT_TOO_SMALL", refusal.getMessage()); + assertNull(refusal.diagnostic()); + assertThrows(NullPointerException.class, () -> new GenerationPorts.RenderException(null)); + var adapter = new ExistingAdapter(); + assertArrayEquals(new byte[] {1}, adapter.render(request).bytes()); + adapter.validate("actor-1", REVISION); + assertEquals("Renderer preview is unavailable.", assertThrows(QException.class, + () -> adapter.preview(new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), null, 1024))).getMessage()); + assertEquals("Renderer barcode reference is unavailable.", assertThrows(QException.class, adapter::barcodeReference).getMessage()); + } + + /*************************************************************************** + ** Stored error codes are read back by name, so the released names keep their + ** order and the version 2 refusals follow them. + ***************************************************************************/ + @Test + void failureCodesKeepTheReleasedNamesThenAddTheSceneTwoRefusals() + { + List names = Arrays.stream(FailureCode.values()).map(Enum::name).toList(); + assertEquals(List.of("BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_UNSUPPORTED", "BARCODE_FIT_BOX_OUTSIDE_STOCK", "BARCODE_FIT_GEOMETRY_MISMATCH", "INVALID_BARCODE_FIT_PROFILE", + "INVALID_BARCODE_FIT_REQUEST", "BARCODE_SYMBOLOGY_NOT_ALLOWED", "SYMBOLOGY_ALLOWLIST_REQUIRED", "SCANNER_BARCODE_PROFILE_UNSUPPORTED", "SCANNER_BARCODE_PAYLOAD_UNSUPPORTED", + "SCANNER_BARCODE_CHECKSUM_REQUIRED", "SCANNER_BARCODE_CHECKSUM_MISMATCH", "UNSUPPORTED_BARCODE_OPTION"), names.subList(0, 13)); + assertEquals(List.of("SCENE_INVALID", "STOCK_MISMATCH", "TARGET_REQUIRES_SCENE_2", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "TEXT_DOES_NOT_FIT", "VALUE_MISSING", "VALUE_TOO_LONG", + "FONT_UNKNOWN", "FONT_MISSING_GLYPH", "RESERVED_FONT_NAME", "BARCODE_DOES_NOT_FIT", "GS1_INVALID", "ASSET_UNKNOWN", "PREVIEW_TOO_LARGE", "RENDERER_TOO_OLD"), names.subList(13, names.size())); + } + + /*************************************************************************** + ** A render target is absent or a resolution the renderer supports, for a + ** render and for a preview alike. + ***************************************************************************/ + @ParameterizedTest + @ValueSource(ints = {0, 72, 200, 204, 301, 1200, -203}) + void anUnsupportedRenderTargetIsRefused(int dpi) + { + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of(), 1024, dpi)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), dpi, 1024)); + } + + /*************************************************************************** + ** Each supported resolution is carried unchanged. + ***************************************************************************/ + @ParameterizedTest + @ValueSource(ints = {203, 300, 600}) + void aSupportedRenderTargetIsCarried(int dpi) + { + assertEquals(dpi, new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of(), 1024, dpi).targetDpi()); + assertEquals(dpi, new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), dpi, 1024).targetDpi()); + } + + /*************************************************************************** + ** A preview job names its format, its content and an output bound. + ***************************************************************************/ + @Test + void aPreviewRequestNeedsAFormatARevisionAndALimit() + { + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", null, REVISION, Map.of(), null, 1024)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, null, Map.of(), null, 1024)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), null, null)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), null, 0)); + assertEquals(REVISION, new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, REVISION, Map.of(), null, 1).revision()); + } + + /*************************************************************************** + ** A page is a PNG with a size, and neither the caller nor a consumer can + ** change its bytes afterwards. + ***************************************************************************/ + @Test + void aPreviewPageIsAnImmutablePngWithItsSize() + { + byte[] supplied = PNG.clone(); + var page = new GenerationPorts.PreviewPage(supplied, 812, 203); + supplied[8] = 9; + page.png()[8] = 9; + assertArrayEquals(PNG, page.png()); + assertEquals(812, page.widthDots()); + assertEquals(203, page.heightDots()); + byte[] jpeg = PNG.clone(); + jpeg[1] = 'J'; + for(byte[] bytes : new byte[][] {null, new byte[0], Arrays.copyOf(PNG, 7), jpeg}) + { + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(bytes, 812, 203)); + } + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, null, 203)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, 0, 203)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, 812, null)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, 812, 0)); + } + + /*************************************************************************** + ** A preview freezes its lists, states its output exactly, and cannot call a + ** layout with an error printable. + ***************************************************************************/ + @Test + void aPreviewIsFrozenAndCannotContradictItsDiagnostics() + { + var page = new GenerationPorts.PreviewPage(PNG, 812, 203); + var error = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", null, null, null); + var warning = new Diagnostic("PRINTER_FONT_NOT_SHOWN", Severity.WARNING, null, null, null, null); + List pages = new ArrayList<>(List.of(page)); + List diagnostics = new ArrayList<>(List.of(warning)); + var preview = new GenerationPorts.Preview(pages, false, true, diagnostics, 1234L, SHA); + pages.clear(); + diagnostics.clear(); + assertEquals(List.of(page), preview.pages()); + assertEquals(List.of(warning), preview.diagnostics()); + assertThrows(UnsupportedOperationException.class, () -> preview.pages().clear()); + assertThrows(UnsupportedOperationException.class, () -> preview.diagnostics().clear()); + assertEquals(false, new GenerationPorts.Preview(List.of(), true, false, List.of(error), 0L, SHA).printable()); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(warning, error), 1234L, SHA)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), null, true, List.of(), 1234L, SHA)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, null, List.of(), 1234L, SHA)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), null, SHA)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), -1L, SHA)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), 1234L, null)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), 1234L, SHA.toUpperCase().replace('0', 'A'))); + assertThrows(NullPointerException.class, () -> new GenerationPorts.Preview(null, true, true, List.of(), 1234L, SHA)); + assertThrows(NullPointerException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, null, 1234L, SHA)); + } + + /*************************************************************************** + ** The design's example diagnostic, and every code of the first release under + ** its own severity only. + ***************************************************************************/ + @Test + void aDiagnosticAdmitsOnlyItsClosedVocabulary() + { + var example = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", List.of(24, 131, 764, 56), + Map.of("widthDots", 902, "sizeCentipoints", 700, "lines", 2), Map.of("widthDots", 764, "lines", 2)); + assertEquals(List.of(24, 131, 764, 56), example.box()); + assertEquals(902, example.measured().get("widthDots")); + assertEquals(2, example.limit().get("lines")); + for(FailureCode code : FailureCode.values()) + { + assertEquals(code.name(), new Diagnostic(code.name(), Severity.ERROR, null, null, null, null).code()); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code.name(), Severity.WARNING, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code.name(), Severity.INFO, null, null, null, null)); + } + for(String code : List.of("TEXT_TRUNCATED", "TEXT_SMALL_FOR_DPI", "TEXT_LIGHT_FACE", "FIT_NOT_PROVEN", "BARCODE_MODULE_BELOW_RECOMMENDED", "QUIET_ZONE_INTRUDED", "ELEMENTS_OVERLAP", + "IMAGE_UPSCALED", "DEVICE_UNIT_USED", "PRINTER_FONT_NOT_SHOWN")) + { + assertEquals(Severity.WARNING, new Diagnostic(code, Severity.WARNING, null, null, null, null).severity()); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.ERROR, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.INFO, null, null, null, null)); + } + for(String code : List.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED")) + { + assertEquals(Severity.INFO, new Diagnostic(code, Severity.INFO, null, null, null, null).severity()); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.ERROR, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.WARNING, null, null, null, null)); + } + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("RENDER_FAILED", Severity.ERROR, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("Customer Jane Example could not be printed", Severity.ERROR, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic(null, Severity.ERROR, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("TEXT_DOES_NOT_FIT", null, null, null, null, null)); + } + + /*************************************************************************** + ** Nothing but an author-written id and numbers fits beside the code: a + ** sentence, a record value or a message has no field that will hold it. + ***************************************************************************/ + @Test + void aDiagnosticCannotHoldFreeText() + { + assertEquals("order_id-2", new Diagnostic("VALUE_MISSING", Severity.ERROR, "order_id-2", null, null, null).element()); + for(String element : List.of("", "9caption", "two words", "Jane Example, 1 Test Street", "a".repeat(33), "caption\n", "/record/name")) + { + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("VALUE_MISSING", Severity.ERROR, element, null, null, null), element); + } + for(List box : List.of(List.of(), List.of(1, 2, 3), List.of(1, 2, 3, 4, 5), List.of(1, 2, -3, 4), List.of(1, 2, 3, -4), Arrays.asList(1, null, 3, 4))) + { + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", Severity.ERROR, "caption", box, null, null), String.valueOf(box)); + } + assertEquals(List.of(-12, -4, 0, 0), new Diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", Severity.ERROR, "caption", List.of(-12, -4, 0, 0), null, null).box()); + Map nullValue = new HashMap<>(); + nullValue.put("widthDots", null); + Map nullName = new HashMap<>(); + nullName.put(null, 1); + Map tooMany = new HashMap<>(); + for(int index = 0; index < 9; index++) + { + tooMany.put("value" + index, index); + } + for(Map values : List.of(Map.of("Width", 1), Map.of("width dots", 1), Map.of("the value Jane Example is too long", 1), Map.of("w".repeat(33), 1), nullValue, nullName, tooMany)) + { + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", null, values, null)); + assertThrows(IllegalArgumentException.class, () -> new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", null, null, values)); + } + List box = new ArrayList<>(List.of(1, 2, 3, 4)); + Map measured = new HashMap<>(Map.of("widthDots", 902)); + var diagnostic = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", box, measured, null); + box.set(0, 99); + measured.put("widthDots", 1); + assertEquals(List.of(1, 2, 3, 4), diagnostic.box()); + assertEquals(Map.of("widthDots", 902), diagnostic.measured()); + assertEquals(Map.of(), diagnostic.limit()); + assertThrows(UnsupportedOperationException.class, () -> diagnostic.box().set(0, 99)); + assertThrows(UnsupportedOperationException.class, () -> diagnostic.measured().put("widthDots", 1)); + } + + /*************************************************************************** + ** A refusal carries only the error diagnostic with its own code, and its + ** public message is still the code alone. + ***************************************************************************/ + @Test + void aRefusalCarriesOnlyItsOwnErrorDiagnostic() + { + var detail = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", List.of(24, 131, 764, 56), Map.of("widthDots", 902), Map.of("widthDots", 764)); + var refusal = new GenerationPorts.RenderException(FailureCode.TEXT_DOES_NOT_FIT, detail); + assertEquals(FailureCode.TEXT_DOES_NOT_FIT, refusal.code()); + assertEquals(detail, refusal.diagnostic()); + assertEquals("TEXT_DOES_NOT_FIT", refusal.getMessage()); + assertNull(new GenerationPorts.RenderException(FailureCode.TEXT_DOES_NOT_FIT, null).diagnostic()); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.RenderException(FailureCode.VALUE_MISSING, detail)); + assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.RenderException(FailureCode.TEXT_DOES_NOT_FIT, new Diagnostic("TEXT_SHRUNK", Severity.INFO, "caption", null, null, null))); + assertThrows(NullPointerException.class, () -> new GenerationPorts.RenderException(null, detail)); + } +} diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java new file mode 100644 index 0000000..4b24792 --- /dev/null +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java @@ -0,0 +1,111 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration; + +import java.util.List; +import java.util.TreeSet; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/******************************************************************************* + ** The registration points for the preview, asset and upgrade packages exist and + ** are called, and add nothing: a host that registers the QBit gets exactly the + ** tables, processes, choices, widgets and template sections it got before. + *******************************************************************************/ +class GenerationRegistrationHooksTest +{ + /*************************************************************************** + ** The complete default wiring, as the released foundation registers it. + ***************************************************************************/ + @Test + void theProducerRegistersExactlyWhatItDidBeforeTheHooks() throws Exception + { + QInstance instance = instance(); + new DocumentGenerationQBitProducer().withQBitConfig(config()).produce(instance); + assertEquals(List.of("document_generation_asset", "document_generation_batch", "document_generation_binding", "document_generation_control", "document_generation_event", + "document_generation_item", "document_generation_queue", "document_generation_request", "document_generation_revision", "document_generation_stock", + "document_generation_template", "document_generation_type", "orders"), + List.copyOf(new TreeSet<>(instance.getTables().keySet())), "tables"); + assertEquals(List.of("document_generation_binding_author_create", "document_generation_queue_document_advance", "document_generation_queue_document_cancel", + "document_generation_queue_document_reconcileItem", "document_generation_queue_document_recover", "document_generation_queue_document_status", + "document_generation_request_document_download", "document_generation_request_document_reconcile", "document_generation_request_document_reprint", + "document_generation_request_document_status", "document_generation_stock_author_create", "document_generation_stock_author_rename", + "document_generation_template_author_asset", "document_generation_template_author_catalog", "document_generation_template_author_context", + "document_generation_template_author_create", "document_generation_template_author_edit", "document_generation_template_author_naming", + "document_generation_template_author_packingSlip", "document_generation_template_author_preview", "document_generation_template_author_publish", + "document_generation_template_author_removeAsset", "document_generation_template_author_rename", "document_generation_template_author_replaceAsset", + "document_generation_type_author_create", "document_generation_type_author_rename", "orders_document_enqueue", "orders_document_generate"), + List.copyOf(new TreeSet<>(instance.getProcesses().keySet())), "processes"); + assertEquals(List.of("document_generation_asset", "document_generation_context_publications", "document_generation_formats", "document_generation_preview_source", + "document_generation_preview_source_orders", "document_generation_retained_assets", "document_generation_revision", + "document_generation_source_tables", "document_generation_stock", "document_generation_template", "document_generation_type"), + List.copyOf(new TreeSet<>(instance.getPossibleValueSources().keySet())), "possible value sources"); + assertEquals(List.of(), + List.copyOf(new TreeSet<>(instance.getWidgets().keySet())), "widgets"); + assertEquals(List.of("overview", "layout", "context", "advanced"), + instance.getTable(GenerationTables.TEMPLATE).getSections().stream().map(QFieldSection::getName).toList(), "template sections"); + } + + /*************************************************************************** + ** Each registration point, called directly, changes nothing. + ***************************************************************************/ + @Test + void eachRegistrationPointAddsNothingYet() throws Exception + { + DocumentGenerationQBitConfig config = config(); + assertEquals(List.of(), GenerationAssetTables.tables(config)); + QInstance instance = instance(); + GenerationPreviewProcesses.register(instance, config); + GenerationAssetProcesses.register(instance, config); + GenerationUpgradeProcesses.register(instance, config); + assertEquals(List.of(), List.copyOf(instance.getProcesses().keySet())); + assertEquals(List.of("orders"), List.copyOf(instance.getTables().keySet())); + QTableMetaData table = new QTableMetaData().withName("template"); + GenerationPreviewWidget.section(table); + assertEquals(new QTableMetaData().withName("template").getSections(), table.getSections()); + } + + /*************************************************************************** + ** The smallest host: one backend and one source table. + ***************************************************************************/ + private static QInstance instance() + { + QInstance instance = new QInstance(); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("orders").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))); + return (instance); + } + + /*************************************************************************** + ** Default limits, with one source table so the source choices are registered. + ***************************************************************************/ + private static DocumentGenerationQBitConfig config() + { + return (new DocumentGenerationQBitConfig().withBackendName("memory").withSourceTables(List.of("orders"))); + } +} -- 2.54.0