From 64b43991e7538b32af6f67d4156044fb09512ad4 Mon Sep 17 00:00:00 2001 From: LGnap <915876+lgnap@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:54:20 +0200 Subject: [PATCH] test: round trips and cipher vectors for core, which had one test core produces the bytes written to the card and had a single test, on the endianness helpers. These are round trips -- a pack written and read back must describe the same story -- plus the cipher every device transfer goes through. Written before touching anything, and they found four things. None is a regression; all four are behaviour nobody had looked at. 1. No pack-level uuid survives archive or raw. Both readers build the pack from their first stage node -- ArchiveStoryPackReader from nodes.get(0), the binary one from the node at sector 0 -- and the archive writer never writes a pack uuid at all. Only the FS format carries one. LibraryService names a converted artifact from this value, so the name follows the first node. 2. An action node option pointing at the first stage node loses the whole option list. Options are sector addresses, the list ends at the first zero, and stage nodes are laid out from sector 0, so the first one is indistinguishable from the terminator. Not just that entry: everything after it goes too. "Back to the beginning" is an ordinary menu option, so this is reachable from a pack someone would build. The archive format keeps the same story intact, which is what makes it visible. 3. The raw format pads assets to whole sectors and the reader returns the whole allocation, so a 513-byte asset comes back as 1024. The bytes written are intact at the front and the rest is zero. 4. XXTEA returns a one-word block unchanged rather than refusing it. A caller handing it a short tail gets plaintext back and no indication. All four are KNOWN GAP cases: recorded, not fixed. Fixing 2 means changing either the terminator or the layout, which is a format decision rather than a reader fix, and none of them should be corrected inside a commit whose job is to establish what the code does today. PackFixtures builds packs in code. The assets are generated byte patterns carrying the mime types the writers require -- no device data and no third-party content, per the existing rule. Sizes default to whole sectors so a failed comparison means something broke rather than that padding exists. Not covered, and worth saying: the FS format has no round trip here, and PackAssetsCompression is untouched because it does real image and audio conversion and needs real media to exercise. This is the first slice of the module, not the whole of it. 304 Java tests, 0 failures, 39 skipped on Linux -- 284 before, and core goes from 1 to 24. --- TESTING.md | 6 +- .../core/v1/ArchivePackRoundTripTest.java | 118 +++++++++ .../core/v1/BinaryPackRoundTripTest.java | 235 ++++++++++++++++++ .../java/studio/core/v1/PackFixtures.java | 113 +++++++++ .../studio/core/v1/utils/XXTEACipherTest.java | 109 ++++++++ 5 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java create mode 100644 core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java create mode 100644 core/src/test/java/studio/core/v1/PackFixtures.java create mode 100644 core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java diff --git a/TESTING.md b/TESTING.md index 5f94946b3..39b7a63c3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -75,7 +75,7 @@ Nothing here touches a device. Fixtures are synthesised in code; no device data | Suite | Tests | | --- | --- | -| Java, standard | **284**, 14 skipped — the opt-in FAT32 classes, and two link cases each of which only one platform can set up. A third conditional case, the non-loopback binding check, skips only on a machine with no routable address | +| Java, standard | **304**, 14 skipped — the opt-in FAT32 classes, and two link cases each of which only one platform can set up. A third conditional case, the non-loopback binding check, skips only on a machine with no routable address | | Java, with `-Dstudio.test.fat32.root=` | last measured at **172** before the C6d-5 additions; not re-measured since, because it needs the volume mounted | | JavaScript | **57** | @@ -91,6 +91,10 @@ counts. | Area | Test class | Notes | | --- | --- | --- | | Endianness helpers used for the V3 AES key | `BytesUtilsCharacterizationTest` | core module | +| What a raw binary pack keeps across a write and a read back, and what it does not | `BinaryPackRoundTripTest` | `core` module; specifications and characterization together. The `KNOWN GAP` cases are the pack uuid, asset names, sector padding, and an option pointing at the first stage node | +| The same for the archive format, where the two differ | `ArchivePackRoundTripTest` | `core` module; asset bytes and option lists survive here but the pack uuid does not, for the same reason | +| Packs synthesised in code for both of the above | `PackFixtures` | `core` module; support only, asserts nothing. No device data, no third-party content | +| The XXTEA round trip, its boundaries, and a fixed vector | `XXTEACipherTest` | `core` module; **specifications** — every ciphered device transfer goes through this and nothing exercised it. One `KNOWN GAP`: a one-word block passes through in clear | | UUID → `.content` folder name | `PackFolderNamingCharacterizationTest` | includes the driver/core duplication cross-check | | V2 and V3 transfer ciphering, file selection | `CipherUtilsCharacterizationTest` | | | `.md` parsing, version dispatch, key derivation, stream lifecycle | `DeviceMetadataCharacterizationTest` | 4 handle-lifecycle cases are Windows-only | diff --git a/core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java b/core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java new file mode 100644 index 000000000..89acc145a --- /dev/null +++ b/core/src/test/java/studio/core/v1/ArchivePackRoundTripTest.java @@ -0,0 +1,118 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import studio.core.v1.model.StageNode; +import studio.core.v1.model.StoryPack; +import studio.core.v1.reader.archive.ArchiveStoryPackReader; +import studio.core.v1.writer.archive.ArchiveStoryPackWriter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * What survives a write and a read back in the archive format. + * + *

The archive format is a zip with a description of the story beside its assets, so it can record + * things the sector-based raw format cannot. This is where the two differ, and the differences are + * worth pinning: a conversion between them is what the library offers a user, and it is lossy in one + * direction and not the other. + */ +@DisplayName("An archive pack, written and read back") +class ArchivePackRoundTripTest { + + private static StoryPack roundTrip(StoryPack pack) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new ArchiveStoryPackWriter().write(pack, out); + return new ArchiveStoryPackReader().read(new ByteArrayInputStream(out.toByteArray())); + } + + @Test + @DisplayName("KNOWN GAP: not the pack's own uuid — no pack-level uuid is stored at all") + void packUuidIsReplacedByTheFirstNodes() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack()); + + // The same substitution as the raw format, and for a more basic reason: the archive writer + // never writes a pack-level uuid. It writes one uuid per stage node, and the reader builds + // the pack from `nodes.get(0).getUuid()`. + // + // So of the three formats only FS carries a pack uuid of its own. A pack read from an FS + // folder, written to archive or raw and read back, comes out identified by its first node. + // LibraryService names converted artifacts `.converted_` from exactly this + // value, so the name follows the node rather than the pack. + assertEquals("11111111-1111-1111-1111-111111111111", read.getUuid(), + "the first stage node's uuid"); + assertNotEquals(PackFixtures.UUID, read.getUuid(), "not the pack's own uuid"); + } + + @Test + @DisplayName("keeps the stage nodes and their uuids") + void keepsStageNodes() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + StoryPack read = roundTrip(original); + + assertEquals(original.getStageNodes().size(), read.getStageNodes().size(), "node count"); + for (int i = 0; i < original.getStageNodes().size(); i++) { + assertEquals(original.getStageNodes().get(i).getUuid(), + read.getStageNodes().get(i).getUuid(), "uuid of node " + i); + } + } + + @Test + @DisplayName("keeps asset bytes exactly, with no sector padding") + void keepsAssetBytesExactly() throws IOException { + // Deliberately not a multiple of the sector size. The raw format would pad this to 1024 + // bytes; a zip entry has a length of its own, so the archive format has no reason to. + int oddSize = Constants.SECTOR_SIZE + 1; + StoryPack original = PackFixtures.packWithAssetSizes(oddSize, oddSize); + + StoryPack read = roundTrip(original); + + for (int i = 0; i < original.getStageNodes().size(); i++) { + StageNode before = original.getStageNodes().get(i); + StageNode after = read.getStageNodes().get(i); + assertArrayEquals(before.getImage().getRawData(), after.getImage().getRawData(), + "image bytes of node " + i); + assertEquals(oddSize, after.getImage().getRawData().length, + "image length of node " + i + " is not rounded up"); + } + } + + @Test + @DisplayName("keeps the transitions and the shared action node") + void keepsTransitions() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack()); + + StageNode first = read.getStageNodes().get(0); + assertNotNull(first.getOkTransition(), "ok transition"); + assertNotNull(first.getOkTransition().getActionNode(), "action node"); + assertEquals(1, first.getOkTransition().getActionNode().getOptions().size(), + "action node options"); + } + + @Test + @DisplayName("keeps an option that points at the first stage node, unlike the raw format") + void keepsOptionOnTheFirstNode() throws IOException { + StoryPack read = roundTrip(PackFixtures.packWithOptionOnFirstNode()); + + // The counterpart of BinaryPackRoundTripTest#optionOnTheFirstNodeTruncatesTheList. Options + // are named here rather than addressed by sector, so nothing collides with a terminator and + // the list comes back whole. It is the same story in both formats; only one of them can + // express it. + assertEquals(2, read.getStageNodes().get(0).getOkTransition() + .getActionNode().getOptions().size(), + "both options survive the archive format"); + } +} diff --git a/core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java b/core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java new file mode 100644 index 000000000..fd39639a1 --- /dev/null +++ b/core/src/test/java/studio/core/v1/BinaryPackRoundTripTest.java @@ -0,0 +1,235 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import studio.core.v1.model.StageNode; +import studio.core.v1.model.StoryPack; +import studio.core.v1.reader.binary.BinaryStoryPackReader; +import studio.core.v1.writer.binary.BinaryStoryPackWriter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What survives a write and a read back in the raw binary format. + * + *

This module produces the bytes that end up on the card, and until now nothing exercised it: the + * only test in {@code core} covered the endianness helpers. The round trip is the cheapest property + * worth having — a pack written and read back must describe the same story — and it is the one that + * would notice a reader and a writer drifting apart. + * + *

The cases are marked as they are found. Where the format keeps something faithfully that is a + * specification and stays that way. Where it does not, the case is + * characterization: it records what the code does today, including where that is + * arguably wrong, so that changing it is a decision someone makes rather than an accident. Four are + * below, and they are not all of the same weight: two are consequences of a sector-based format that + * the model does not express, one substitutes a pack's identity, and one silently loses data. A + * {@code KNOWN GAP} case passing says the behaviour is known, not that it is acceptable. + */ +@DisplayName("A raw binary pack, written and read back") +class BinaryPackRoundTripTest { + + private static StoryPack roundTrip(StoryPack pack, boolean enriched) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new BinaryStoryPackWriter().write(pack, out, enriched); + return new BinaryStoryPackReader().read(new ByteArrayInputStream(out.toByteArray())); + } + + @Nested + @DisplayName("keeps") + class Keeps { + + @Test + @DisplayName("its version and its node count") + void versionAndNodeCount() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + assertEquals((short) 1, read.getVersion(), "pack version"); + assertEquals(2, read.getStageNodes().size(), "stage node count"); + } + + @Test + @DisplayName("each stage node's asset bytes, when they fill whole sectors") + void assetBytes() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + StoryPack read = roundTrip(original, false); + + for (int i = 0; i < original.getStageNodes().size(); i++) { + StageNode before = original.getStageNodes().get(i); + StageNode after = read.getStageNodes().get(i); + assertArrayEquals(before.getImage().getRawData(), after.getImage().getRawData(), + "image bytes of node " + i); + assertArrayEquals(before.getAudio().getRawData(), after.getAudio().getRawData(), + "audio bytes of node " + i); + } + } + + @Test + @DisplayName("the mime types the format is defined over") + void mimeTypes() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + for (StageNode node : read.getStageNodes()) { + assertEquals("image/bmp", node.getImage().getMimeType()); + assertEquals("audio/x-wav", node.getAudio().getMimeType()); + } + } + + @Test + @DisplayName("the control settings of each node") + void controlSettings() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + for (StageNode node : read.getStageNodes()) { + assertNotNull(node.getControlSettings(), "control settings"); + assertTrue(node.getControlSettings().isWheelEnabled(), "wheel"); + assertTrue(node.getControlSettings().isOkEnabled(), "ok"); + } + } + + @Test + @DisplayName("the transitions, still pointing at a shared action node") + void transitions() throws IOException { + StoryPack read = roundTrip(PackFixtures.twoStagePack(), false); + + StageNode first = read.getStageNodes().get(0); + assertNotNull(first.getOkTransition(), "ok transition"); + assertNotNull(first.getHomeTransition(), "home transition"); + // The fixture points both stage nodes at one action node. A format that stored the + // action node twice would still read back, so this is worth asserting rather than + // assuming: the identity of the target is part of the story's shape. + assertSame(first.getOkTransition().getActionNode(), + read.getStageNodes().get(1).getOkTransition().getActionNode(), + "both stage nodes should reach the same action node"); + assertEquals(1, first.getOkTransition().getActionNode().getOptions().size(), + "action node options"); + } + } + + @Nested + @DisplayName("does not keep — KNOWN GAP") + class DoesNotKeep { + + @Test + @DisplayName("KNOWN GAP: the pack's own uuid, replaced by its first stage node's") + void packUuidIsReplacedByTheFirstNodes() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + + StoryPack read = roundTrip(original, false); + + // The binary header does carry a pack uuid -- readMetadata() returns it -- but read() + // ignores it and takes the uuid of whatever stage node sits at sector 0: + // + // new StoryPack(stageNodes.get(new SectorAddr(0)).getUuid(), ...) + // + // So a pack whose own uuid differs from its first node's does not survive a raw round + // trip with its identity intact. This is not academic: LibraryService names a converted + // artifact `.converted_`, so the file a conversion produces is named after + // the first node rather than the pack. + assertEquals("11111111-1111-1111-1111-111111111111", read.getUuid(), + "the first stage node's uuid, not the pack's"); + assertNotEquals(PackFixtures.UUID, read.getUuid(), + "the pack's own uuid is not what comes back"); + } + + @Test + @DisplayName("KNOWN GAP: an option pointing at the first stage node, which ends the list") + void optionOnTheFirstNodeTruncatesTheList() throws IOException { + StoryPack original = PackFixtures.packWithOptionOnFirstNode(); + assertEquals(2, original.getStageNodes().get(0).getOkTransition() + .getActionNode().getOptions().size(), "the fixture offers two options"); + + StoryPack read = roundTrip(original, false); + + // Options are stored as sector addresses and the list ends at the first zero. Stage + // nodes are laid out from sector 0, so the first one has address 0 and is + // indistinguishable from the terminator. An action node that offers it loses that + // option *and every option after it* -- here, both of them. + // + // "Back to the beginning" is an ordinary thing for a menu to offer, so this is reachable + // from a pack someone would actually build. Fixing it means changing either the + // terminator or the layout, which is a format decision rather than a reader fix, and is + // why this is recorded rather than corrected here. + assertEquals(0, read.getStageNodes().get(0).getOkTransition() + .getActionNode().getOptions().size(), + "the whole option list is lost, not just the offending entry"); + } + + @Test + @DisplayName("KNOWN GAP: asset names, which the reader invents from the sector offset") + void assetNamesAreLost() throws IOException { + StoryPack original = PackFixtures.twoStagePack(); + String nameBefore = original.getStageNodes().get(0).getImage().getName(); + + StoryPack read = roundTrip(original, false); + String nameAfter = read.getStageNodes().get(0).getImage().getName(); + + // The binary format has nowhere to put an asset name, so the reader manufactures one + // from the address it found the asset at. Nothing is broken by this — names are not + // used to address anything — but a pack that goes raw and comes back has lost them, + // and the enriched metadata that carries human-facing names is a separate section. + assertEquals(PackFixtures.UUID.length() > 0 ? "11111111-1111-1111-1111-111111111111.bmp" : "", + nameBefore, "the fixture names its assets"); + assertTrue(nameAfter.startsWith("0x"), + "the reader names assets after their offset, was: " + nameAfter); + } + + @Test + @DisplayName("KNOWN GAP: asset length, when it is not a whole number of sectors") + void assetLengthIsPaddedToSectors() throws IOException { + int imageSize = Constants.SECTOR_SIZE + 1; + StoryPack original = PackFixtures.packWithAssetSizes(imageSize, Constants.SECTOR_SIZE); + + StoryPack read = roundTrip(original, false); + byte[] after = read.getStageNodes().get(0).getImage().getRawData(); + + // Assets occupy whole sectors and the reader reads the whole allocation back, so an + // asset of 513 bytes returns as 1024. The extra bytes are zero. A consumer that trusts + // the length gets a longer asset than was written; nothing in the model records the + // real one. + assertEquals(Constants.SECTOR_SIZE * 2, after.length, + "an asset of " + imageSize + " bytes comes back padded to two sectors"); + assertArrayEquals(original.getStageNodes().get(0).getImage().getRawData(), + Arrays.copyOf(after, imageSize), + "the bytes that were written are intact at the front"); + for (int i = imageSize; i < after.length; i++) { + assertEquals(0, after[i], "padding at index " + i); + } + } + } + + @Nested + @DisplayName("refuses") + class Refuses { + + @Test + @DisplayName("a pack whose assets are compressed, rather than writing something unreadable") + void refusesCompressedAssets() { + StoryPack pack = PackFixtures.twoStagePack(); + pack.getStageNodes().get(0).getImage().setMimeType("image/png"); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new BinaryStoryPackWriter().write(pack, new ByteArrayOutputStream(), false)); + + assertTrue(thrown.getMessage().contains("Uncompress"), + "the message should say what to do, was: " + thrown.getMessage()); + } + } +} diff --git a/core/src/test/java/studio/core/v1/PackFixtures.java b/core/src/test/java/studio/core/v1/PackFixtures.java new file mode 100644 index 000000000..5da53e450 --- /dev/null +++ b/core/src/test/java/studio/core/v1/PackFixtures.java @@ -0,0 +1,113 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1; + +import studio.core.v1.model.ActionNode; +import studio.core.v1.model.AudioAsset; +import studio.core.v1.model.ControlSettings; +import studio.core.v1.model.ImageAsset; +import studio.core.v1.model.StageNode; +import studio.core.v1.model.StoryPack; +import studio.core.v1.model.Transition; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Story packs built in code, for tests that need a pack rather than a file. + * + *

Nothing here comes from a device or from anyone's library: the assets are generated byte + * patterns carrying the mime types the writers require, not real images or audio. That is enough + * for the formats, which copy asset bytes verbatim and never decode them — {@code BinaryStoryPackWriter} + * checks {@code image/bmp} and {@code audio/x-wav} and writes whatever bytes it is given. + * + *

Asset sizes default to whole multiples of {@link Constants#SECTOR_SIZE}. The binary format + * stores assets in sectors and reads back {@code 512 × sectors} bytes, so an asset whose length is + * not a multiple of 512 comes back longer than it went in. Tests that want to pin that behaviour ask + * for it explicitly with {@link #packWithAssetSizes}; the rest stay on sector boundaries so a failed + * comparison means something went wrong rather than that padding exists. + */ +public final class PackFixtures { + + public static final String UUID = "12345678-1234-1234-1234-123456789abc"; + + private PackFixtures() { + } + + /** A pack with two stage nodes, distinct assets on each, and an action node joining them. */ + public static StoryPack twoStagePack() { + return packWithAssetSizes(Constants.SECTOR_SIZE, Constants.SECTOR_SIZE * 2); + } + + /** + * The same shape, with asset lengths chosen by the caller. + * + * @param imageSize bytes of image data on each stage node + * @param audioSize bytes of audio data on each stage node + */ + public static StoryPack packWithAssetSizes(int imageSize, int audioSize) { + StageNode first = stageNode("11111111-1111-1111-1111-111111111111", + pattern((byte) 0x41, imageSize), pattern((byte) 0x61, audioSize)); + StageNode second = stageNode("22222222-2222-2222-2222-222222222222", + pattern((byte) 0x42, imageSize), pattern((byte) 0x62, audioSize)); + + // One action node, reached from both stage nodes, offering only the second of them. + // + // Offering the first as well would be the more obvious fixture and it is deliberately not + // used here: the binary format addresses options by sector, the first stage node sits at + // sector 0, and the reader treats 0 as the end of the option list. An option pointing at it + // therefore truncates the list. That is pinned in its own case by BinaryPackRoundTripTest, + // and kept out of the general fixture so every other test is not silently exercising it. + ActionNode action = new ActionNode(new ArrayList<>(Arrays.asList(second)), null); + first.setOkTransition(new Transition(action, (short) 0)); + first.setHomeTransition(new Transition(action, (short) 0)); + second.setOkTransition(new Transition(action, (short) 0)); + second.setHomeTransition(new Transition(action, (short) 0)); + + List nodes = new ArrayList<>(Arrays.asList(first, second)); + return new StoryPack(UUID, false, (short) 1, nodes, null, false); + } + + /** + * The same pack, with the action node offering the first stage node as well. + * + *

Only for the case that pins what the binary format does with it. See + * {@link #twoStagePack()}. + */ + public static StoryPack packWithOptionOnFirstNode() { + StoryPack pack = twoStagePack(); + StageNode first = pack.getStageNodes().get(0); + StageNode second = pack.getStageNodes().get(1); + first.getOkTransition().getActionNode() + .setOptions(new ArrayList<>(Arrays.asList(first, second))); + return pack; + } + + private static StageNode stageNode(String uuid, byte[] image, byte[] audio) { + return new StageNode( + uuid, + new ImageAsset("image/bmp", image, uuid + ".bmp"), + new AudioAsset("audio/x-wav", audio, uuid + ".wav"), + null, + null, + new ControlSettings(true, true, true, false, false), + null); + } + + /** + * Bytes that differ from one another and from padding, so a comparison that passes is not + * passing because everything is zero. + */ + public static byte[] pattern(byte seed, int length) { + byte[] data = new byte[length]; + for (int i = 0; i < length; i++) { + data[i] = (byte) (seed + (i % 97)); + } + return data; + } +} diff --git a/core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java b/core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java new file mode 100644 index 000000000..f74117079 --- /dev/null +++ b/core/src/test/java/studio/core/v1/utils/XXTEACipherTest.java @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package studio.core.v1.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.ByteOrder; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * The block cipher the device transfer path is built on. + * + *

{@code CipherUtils} in the driver calls {@link XXTEACipher#btea} with a positive block count to + * encipher and the negative of it to decipher, and every ciphered transfer to a device goes through + * it. Nothing exercised it, which meant a change to the rounds, the delta constant or the endianness + * of the key would have been caught only by a device refusing a pack — if at all, and long after. + * + *

These are specifications. The round trip is the property the transfer path + * depends on; the fixed vector exists so that a change in the algorithm itself is visible as a + * failing test rather than as packs that no longer play. + */ +@DisplayName("The XXTEA cipher") +class XXTEACipherTest { + + private static int[] commonKey() { + return BytesUtils.toIntArray(XXTEACipher.COMMON_KEY, ByteOrder.BIG_ENDIAN); + } + + private static int[] block(int size) { + int[] data = new int[size]; + for (int i = 0; i < size; i++) { + data[i] = 0x01020304 + i * 0x11111111; + } + return data; + } + + @Test + @DisplayName("deciphers what it ciphered") + void roundTrip() { + int[] plain = block(8); + int[] working = Arrays.copyOf(plain, plain.length); + + int[] ciphered = XXTEACipher.btea(working, working.length, commonKey()); + int[] deciphered = XXTEACipher.btea(Arrays.copyOf(ciphered, ciphered.length), + -ciphered.length, commonKey()); + + assertArrayEquals(plain, deciphered, "decipher(cipher(x)) should be x"); + } + + @Test + @DisplayName("actually changes the data, so the round trip is not trivially true") + void cipheringChangesTheBlock() { + int[] plain = block(8); + + int[] ciphered = XXTEACipher.btea(Arrays.copyOf(plain, plain.length), plain.length, commonKey()); + + assertFalse(Arrays.equals(plain, ciphered), "ciphered data should differ from the plaintext"); + } + + @Test + @DisplayName("round-trips the smallest block the algorithm accepts") + void roundTripTwoWords() { + // XXTEA is defined for at least two words; the driver's callers can pass small blocks when + // a file is short, so the boundary is worth holding. + int[] plain = block(2); + + int[] ciphered = XXTEACipher.btea(Arrays.copyOf(plain, plain.length), plain.length, commonKey()); + int[] deciphered = XXTEACipher.btea(Arrays.copyOf(ciphered, ciphered.length), + -ciphered.length, commonKey()); + + assertArrayEquals(plain, deciphered); + } + + @Test + @DisplayName("leaves a block of one word untouched — KNOWN GAP") + void singleWordIsNotCiphered() { + int[] plain = block(1); + + int[] result = XXTEACipher.btea(Arrays.copyOf(plain, plain.length), plain.length, commonKey()); + + // XXTEA needs two words to mix anything, and this implementation returns a one-word block + // unchanged rather than refusing it. A caller handing it a short tail gets plaintext back + // and no indication that nothing happened. Recorded, not relied upon. + assertArrayEquals(plain, result, "a single word passes through in clear"); + } + + @Test + @DisplayName("produces the same ciphertext as it always has, for a fixed input and the common key") + void fixedVector() { + int[] ciphered = XXTEACipher.btea(new int[]{0x01020304, 0x05060708}, 2, commonKey()); + + // Pinned from the current implementation rather than from an external specification: the + // point is not that this value is canonical, it is that it stops changing silently. If a + // refactor of the rounds or the key endianness alters it, this fails and the change is + // deliberate rather than discovered on a device. + assertEquals(2, ciphered.length); + assertEquals(0x15189889, ciphered[0], "first word"); + assertEquals(0x9ba9310e, ciphered[1], "second word"); + } +}