diff --git a/src/main/java/org/apache/commons/codec/binary/Base45.java b/src/main/java/org/apache/commons/codec/binary/Base45.java new file mode 100644 index 0000000000..32912258fa --- /dev/null +++ b/src/main/java/org/apache/commons/codec/binary/Base45.java @@ -0,0 +1,488 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.commons.codec.binary; + +import java.util.Arrays; + +import org.apache.commons.codec.CodecPolicy; + +/** + * Provides Base45 encoding and decoding as defined by RFC 9285. + *
+ * Base45 is designed for efficient encoding of binary data in environments where a subset of ASCII characters is available, specifically 45 characters chosen + * from the QR code alphanumeric mode character set. Base45 is used in European Union Digital COVID Certificates (EUDCC) and similar applications. + *
+ *+ * The Base45 alphabet consists of 45 characters: + *
+ * + *+ * Value Encoding Value Encoding Value Encoding Value Encoding + * 0 0 12 C 24 O 36 Space + * 1 1 13 D 25 P 37 $ + * 2 2 14 E 26 Q 38 % + * 3 3 15 F 27 R 39 * + * 4 4 16 G 28 S 40 + + * 5 5 17 H 29 T 41 - + * 6 6 18 I 30 U 42 . + * 7 7 19 J 31 V 43 / + * 8 8 20 K 32 W 44 : + * 9 9 21 L 33 X + * 10 A 22 M 34 Y + * 11 B 23 N 35 Z + *+ * + *
+ * Input bytes are grouped in pairs (2 bytes). Each pair is encoded as 3 Base45 characters. A single remaining byte is encoded as 2 Base45 characters. There is + * no padding. + *
+ *+ * Input characters are grouped in triples (3 characters). Each triple decodes to 2 bytes. A pair of trailing characters decodes to 1 byte. An input whose + * length modulo 3 equals 1 is invalid. + *
+ *+ * This class is thread-safe. + *
+ *+ * To create an instance, use the default constructor or the builder: + *
+ * + *+ * Base45 codec = new Base45(); + * + * // Or, use the builder to customize the encode table: + * Base45 custom = Base45.builder().setEncodeTable(...).get(); + *+ * + * @see RFC 9285 – The Base45 Data Encoding + * @since 1.23.0 + */ +public class Base45 extends BaseNCodec { + + /** + * Builds {@link Base45} instances. + *
+ * To configure a new instance, use a {@link Builder}. For example: + *
+ * + *+ * + * Base45 base45 = Base45.builder().get(); + *+ * + * @since 1.23.0 + */ + public static class Builder extends AbstractBuilder
+ * As specified in RFC 9285: {@code 0-9, A-Z, Space, $, %, *, +, -, ., /, :} + *
+ */ + // @formatter:off + static final byte[] ENCODE_TABLE = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // 0-9 + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', // 10-21 + 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 22-33 + 'Y', 'Z', // 34-35 + ' ', '$', '%', '*', '+', '-', '.', '/', ':', // 36-44 + }; + // @formatter:on + + /** + * Creates a new {@link Builder} for configuring a {@link Base45} instance. + * + * @return A new {@link Builder}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Constructs the decode table matching the given encode table. + * + * @param encodeTable The encode table. + * @return A new decode table. + * @throws IllegalArgumentException if the encode table does not contain exactly 45 unique entries. + */ + private static byte[] calculateDecodeTable(final byte[] encodeTable) { + if (encodeTable.length != BASE) { + throw new IllegalArgumentException("encodeTable must have exactly " + BASE + " entries."); + } + final byte[] decodeTable = new byte[DECODE_TABLE.length]; + Arrays.fill(decodeTable, (byte) -1); + for (int i = 0; i < encodeTable.length; i++) { + final int encodedByte = encodeTable[i] & 0xff; + if (encodedByte >= decodeTable.length || decodeTable[encodedByte] != -1) { + throw new IllegalArgumentException("encodeTable entries must be unique values in the range 0-127."); + } + decodeTable[encodedByte] = (byte) i; + } + return decodeTable; + } + + /** + * Gets the decode table that matches the given encode table. + * + * @param encodeTable The encode table used to determine the decode lookup table. + * @return The matching decode table. + */ + private static byte[] toDecodeTable(final byte[] encodeTable) { + final byte[] table = encodeTable != null ? encodeTable : ENCODE_TABLE; + if (Arrays.equals(table, ENCODE_TABLE)) { + return DECODE_TABLE; + } + return calculateDecodeTable(table); + } + + /** + * Constructs a Base45 codec using the default settings (strict decoding policy, the only supported policy). + */ + public Base45() { + this(builder()); + } + + /** + * Constructs a Base45 codec from a builder. + * + * @param builder The builder to configure this instance. + */ + private Base45(final Builder builder) { + super(builder); + } + + /** + * Decodes all of the provided data, starting at {@code inPos}, for {@code inAvail} bytes. + *+ * This method must be called at least twice: once with the data to decode, and once with {@code inAvail} set to {@code -1} to notify the decoder that EOF + * has been reached. + *
+ *+ * Input characters not in the Base45 alphabet are treated as follows: + *
+ *+ * This method must be called at least twice: once with the data to encode, and once with {@code inAvail} set to {@code -1} to notify the encoder that EOF + * has been reached. + *
+ *+ * Each pair of input bytes is encoded to 3 Base45 characters. A final single byte is encoded as 2 Base45 characters. No padding is used. + *
+ * + * @param input byte array of binary data to Base45-encode. + * @param inPos Position to start reading data from. + * @param inAvail Number of bytes available from {@code input} for encoding, or {@code -1} to signal EOF. + * @param context The context to be used. + */ + @Override + void encode(final byte[] input, int inPos, final int inAvail, final Context context) { + // package-protected for access from I/O streams + if (context.eof) { + return; + } + if (inAvail < 0) { + context.eof = true; + if (context.modulus == 1) { + // One remaining byte: encode as 2 Base45 characters. + final byte[] buffer = ensureBufferSize(TAIL_ENCODED_BLOCK, context); + final int n = context.ibitWorkArea & 0xFF; + buffer[context.pos++] = encodeTable[n % BASE]; + buffer[context.pos++] = encodeTable[n / BASE]; + } + // If modulus == 0, all bytes have been encoded; nothing to flush. + return; + } + for (int i = 0; i < inAvail; i++) { + final int b = input[inPos++] & 0xFF; + // Accumulate byte into work area and advance modulus. + context.modulus = (context.modulus + 1) % BYTES_PER_UNENCODED_BLOCK; + // Shift the accumulated value left by 8 bits and add the new byte. + context.ibitWorkArea = (context.ibitWorkArea << 8) + b; + if (context.modulus == 0) { + // We have a complete 2-byte group; encode as 3 Base45 characters. + final byte[] buffer = ensureBufferSize(BYTES_PER_ENCODED_BLOCK, context); + // The work area holds: b0 * 256 + b1 (a 16-bit value, 0–65535). + int n = context.ibitWorkArea & 0xFFFF; + buffer[context.pos++] = encodeTable[n % BASE]; + n /= BASE; + buffer[context.pos++] = encodeTable[n % BASE]; + n /= BASE; + buffer[context.pos++] = encodeTable[n]; + context.ibitWorkArea = 0; + } + } + } + + /** + * Gets the number of Base45-encoded characters needed to encode the given byte array, as specified by RFC 9285. + *+ * The formula is: {@code (n / 2) * 3 + (n % 2 != 0 ? 2 : 0)}, where {@code n} is the number of unencoded bytes. + *
+ * + * @param array The byte array to encode (used only for its length). + * @return The number of Base45 characters that would be produced by encoding {@code array}. + */ + @Override + public long getEncodedLength(final byte[] array) { + final long n = array.length; + return n / 2 * 3 + (n % 2 != 0 ? 2 : 0); + } + + /** + * Tests whether or not the {@code value} is a valid Base45 alphabet character. + * + * @param value The byte value to test. + * @return {@code true} if the byte corresponds to a character in the Base45 alphabet (RFC 9285); {@code false} otherwise. + */ + @Override + public boolean isInAlphabet(final byte value) { + final int v = value & 0xFF; + return v < decodeTable.length && decodeTable[v] >= 0; + } + + /** + * Tests a given byte array to see if it contains only valid characters within the alphabet. The method optionally treats whitespace as valid. + *+ * Unlike the {@link BaseNCodec} implementation, the pad character is not considered valid, because Base45 (RFC 9285) has no padding. + *
+ * + * @param arrayOctet byte array to test. + * @param allowWhitespacePad if {@code true}, then whitespace is also allowed. + * @return {@code true} if all bytes are valid characters in the alphabet or if the byte array is empty; {@code false}, otherwise. + */ + @Override + public boolean isInAlphabet(final byte[] arrayOctet, final boolean allowWhitespacePad) { + for (final byte octet : arrayOctet) { + if (!isInAlphabet(octet) && (!allowWhitespacePad || !Character.isWhitespace(octet))) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/org/apache/commons/codec/binary/Base45InputStream.java b/src/main/java/org/apache/commons/codec/binary/Base45InputStream.java new file mode 100644 index 0000000000..5b3c3990cf --- /dev/null +++ b/src/main/java/org/apache/commons/codec/binary/Base45InputStream.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.commons.codec.binary; + +import java.io.InputStream; + +/** + * Provides RFC 9285 Base45 decoding in a streaming fashion (unlimited size). + * + * @see Base45 + * @see RFC 9285 – The Base45 Data Encoding + * @since 1.23.0 + */ +public class Base45InputStream extends BaseNCodecInputStream+ * Test vectors are taken from RFC 9285 Section 4.4 and independently verified. + *
+ */ +class Base45Test { + + /** + * RFC 9285 Section 4.4 official test vectors: {plain-text, base45-encoded}. Verified against the RFC 9285 specification. + */ + // @formatter:off + private static final String[][] RFC9285_TEST_VECTORS = { + // RFC 9285 Section 4.4 Test Vectors + { "", "" }, // empty + { "ietf!", "QED8WEX0" }, // RFC 9285 Test Vector 1 + { "base-45", "UJCLQE7W581" }, // RFC 9285 Test Vector 2 + // Independently verified test vectors + { "AB", "BB8" }, // classic two-byte example + }; + // @formatter:on + + private void compare(final byte[] input, final ByteArrayOutputStream baos) throws IOException { + final byte[] encoded = baos.toByteArray(); + final ByteArrayInputStream bais = new ByteArrayInputStream(encoded); + final ByteArrayOutputStream decodedBaos = new ByteArrayOutputStream(); + try (Base45InputStream in = new Base45InputStream(bais)) { + IOUtils.copy(in, decodedBaos); + } + assertArrayEquals(input, decodedBaos.toByteArray()); + } + + /** + * Tests that the builder creates a functional Base45 codec. + */ + @Test + void testBuilder() { + final Base45 codec = Base45.builder().get(); + assertNotNull(codec); + assertEquals("QED8WEX0", codec.encodeToString("ietf!".getBytes(StandardCharsets.US_ASCII))); + } + + @Test + void testBuilderLenientDecodingPolicyThrows() { + assertThrows(IllegalArgumentException.class, () -> Base45.builder().setDecodingPolicy(CodecPolicy.LENIENT)); + assertEquals(CodecPolicy.STRICT, Base45.builder().setDecodingPolicy(null).get().getCodecPolicy()); + } + + /** + * Tests that {@code setEncodeTable} derives a matching decode table, so a codec with a custom alphabet can always decode its own output. + */ + @Test + void testBuilderSetEncodeTableDerivesDecodeTable() { + final byte[] custom = new byte[45]; + int k = 0; + for (char c = 'a'; c <= 'z'; c++) { + custom[k++] = (byte) c; + } + for (char c = 'A'; c <= 'I'; c++) { + custom[k++] = (byte) c; + } + for (char c = '0'; c <= '9'; c++) { + custom[k++] = (byte) c; + } + final Base45 codec = Base45.builder().setEncodeTable(custom).get(); + final byte[] input = "hello world".getBytes(StandardCharsets.US_ASCII); + final byte[] encoded = codec.encode(input); + for (final byte b : encoded) { + assertTrue(codec.isInAlphabet(b), "Encoded byte '" + (char) b + "' must be in the custom alphabet"); + } + assertArrayEquals(input, codec.decode(encoded), "A custom-alphabet codec must be able to decode its own output"); + // null resets to the default table. + assertArrayEquals("QED8WEX0".getBytes(StandardCharsets.US_ASCII), + Base45.builder().setEncodeTable((byte[]) null).get().encode("ietf!".getBytes(StandardCharsets.US_ASCII))); + } + + /** + * Tests that {@code setEncodeTable} rejects tables that are not exactly 45 unique entries. + */ + @Test + void testBuilderSetEncodeTableRejectsInvalidTable() { + assertThrows(IllegalArgumentException.class, () -> Base45.builder().setEncodeTable(new byte[] { 'a', 'b', 'c' }), "wrong size"); + assertThrows(IllegalArgumentException.class, () -> Base45.builder().setEncodeTable(), "zero entries"); + final byte[] duplicates = new byte[45]; + Arrays.fill(duplicates, (byte) 'a'); + assertThrows(IllegalArgumentException.class, () -> Base45.builder().setEncodeTable(duplicates), "duplicates"); + } + + @Test + void testBuilderSetLineLength() { + assertThrows(UnsupportedOperationException.class, () -> Base45.builder().setLineLength(0)); + } + + @Test + void testBuilderSetLineSeparator() { + assertThrows(UnsupportedOperationException.class, () -> Base45.builder().setLineSeparator((byte) 0)); + } + + @Test + void testBuilderSetPadding() { + assertThrows(UnsupportedOperationException.class, () -> Base45.builder().setPadding((byte) 0)); + } + + /** + * Tests the builder with strict decoding policy. + */ + @Test + void testBuilderStrictDecoding() { + final Base45 strict = Base45.builder().setDecodingPolicy(CodecPolicy.STRICT).get(); + assertTrue(strict.isStrictDecoding()); + assertTrue(new Base45().isStrictDecoding()); + // Valid data should still decode successfully + assertArrayEquals("ietf!".getBytes(StandardCharsets.US_ASCII), strict.decode("QED8WEX0")); + } + + /** + * Tests that strict decoding rejects trailing characters that represent invalid values. + */ + @Test + void testBuilderStrictDecodingRejectsHighPair() { + final Base45 strict = Base45.builder().setDecodingPolicy(CodecPolicy.STRICT).get(); + // ':' ':' = 2024 > 255; this should be rejected in both lenient and strict modes + assertThrows(IllegalArgumentException.class, () -> strict.decode("::"), "Strict mode: ':' ':' decodes to 2024 > 255, should be rejected"); + } + + /** + * Tests the codec type constants match RFC 9285 requirements. + */ + @Test + void testCodecConstants() { + assertEquals(3, Base45.BYTES_PER_ENCODED_BLOCK, "BYTES_PER_ENCODED_BLOCK should be 3 (3 Base45 chars per 2 bytes)"); + assertEquals(2, Base45.BYTES_PER_UNENCODED_BLOCK, "BYTES_PER_UNENCODED_BLOCK should be 2 (2 bytes per 3 Base45 chars)"); + } + + /** + * Tests that an empty string decodes to an empty byte array. + */ + @Test + void testDecodeEmpty() { + final Base45 codec = new Base45(); + assertArrayEquals(new byte[0], codec.decode("")); + assertArrayEquals(new byte[0], codec.decode(new byte[0])); + } + + /** + * Tests that invalid characters cause an exception. RFC 9285: "Receivers MUST reject any input string that is not valid Base45 encoding." + */ + @ParameterizedTest + @ValueSource(chars = { '!', '"', '#', '&', '\'', '(', ')', ',', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', 'a', 'z' }) + void testDecodeInvalidCharacters(final char c) { + final Base45 codec = new Base45(); + // Characters not in the Base45 alphabet (excluding whitespace and space) + final String input = "Q" + c + "D"; + assertThrows(IllegalArgumentException.class, () -> codec.decode(input), () -> "Should reject character '" + c + "' (ASCII " + (int) c + ")"); + } + + /** + * Tests that an encoded input length modulo 3 equal to 1 is rejected. RFC 9285: "It is an error if the remaining string is 1 character long." + */ + @Test + void testDecodeInvalidLengthMod3Equals1() { + final Base45 codec = new Base45(); + assertThrows(IllegalArgumentException.class, () -> codec.decode("Q"), "Single character input should be rejected"); + assertThrows(IllegalArgumentException.class, () -> codec.decode("QEDB"), "4-character input (length % 3 == 1) should be rejected"); + assertThrows(IllegalArgumentException.class, () -> codec.decode("QEDBWEC"), "7-character input (length % 3 == 1) should be rejected"); + } + + /** + * Tests decoding of "FGW" -> [0xFF, 0xFF]. + */ + @Test + void testDecodeMaxMax() { + assertArrayEquals(new byte[] { (byte) 0xFF, (byte) 0xFF }, new Base45().decode("FGW")); + } + + /** + * Tests that {@link Base45} implements BinaryDecoder correctly via the {@code decode(Object)} method. + */ + @Test + void testDecodeObject() throws DecoderException { + final Base45 codec = new Base45(); + final byte[] encoded = "QED8WEX0".getBytes(StandardCharsets.US_ASCII); + Object result = codec.decode((Object) encoded); + assertArrayEquals("ietf!".getBytes(StandardCharsets.US_ASCII), (byte[]) result); + // Also test with String input + result = codec.decode((Object) "QED8WEX0"); + assertArrayEquals("ietf!".getBytes(StandardCharsets.US_ASCII), (byte[]) result); + } + + /** + * Tests that decoding an unsupported Object type throws DecoderException. + */ + @Test + void testDecodeObjectThrowsForUnsupportedType() { + final Base45 codec = new Base45(); + assertThrows(DecoderException.class, () -> codec.decode(Integer.valueOf(42))); + } + + /** + * Tests that a 2-character pair that decodes to a value exceeding 255 is rejected. The maximum valid single-byte encoding decodes to at most 255. However, + * some 2-char combos decode to values 256-2024. + */ + @Test + void testDecodePairExceedingMaxByte() { + final Base45 codec = new Base45(); + // ':' ':' = 44 + 44*45 = 44 + 1980 = 2024 > 255 -> invalid + assertThrows(IllegalArgumentException.class, () -> codec.decode("::"), "Pair ':' ':' decodes to 2024 which exceeds 255 and should be rejected"); + // ':' '6' = 44 + 6*45 = 44 + 270 = 314 > 255 -> invalid + assertThrows(IllegalArgumentException.class, () -> codec.decode(":6"), "Pair ':' '6' decodes to 314 which exceeds 255 and should be rejected"); + } + + /** + * Tests the RFC 9285 Section 4.4 decoding test vectors. + */ + @Test + void testDecodeRfc9285TestVectors() { + final Base45 codec = new Base45(); + for (final String[] testCase : RFC9285_TEST_VECTORS) { + final String plainText = testCase[0]; + final String encoded = testCase[1]; + final byte[] expected = plainText.getBytes(StandardCharsets.ISO_8859_1); + final byte[] actual = codec.decode(encoded.getBytes(StandardCharsets.US_ASCII)); + assertArrayEquals(expected, actual, "RFC 9285 decode failed for: '" + encoded + "'"); + } + } + + /** + * Tests decoding of "U5" -> [0xFF] (single byte 255). + */ + @Test + void testDecodeSingleMax() { + assertArrayEquals(new byte[] { (byte) 0xFF }, new Base45().decode("U5")); + } + + /** + * Tests decoding of "00" -> [0x00] (single byte 0). + */ + @Test + void testDecodeSingleZero() { + assertArrayEquals(new byte[] { 0 }, new Base45().decode("00")); + } + + /** + * Tests that whitespace (CR, LF, TAB) characters not in the Base45 alphabet are silently skipped during decoding. This supports line-wrapped encoded data. + */ + @Test + void testDecodeSkipsNonAlphabetWhitespace() { + final Base45 codec = new Base45(); + // "QED8WEX0" split across lines with CR+LF + final byte[] expected = "ietf!".getBytes(StandardCharsets.US_ASCII); + // With CR LF between groups + assertArrayEquals(expected, codec.decode("QED\r\n8WEX0"), "Should skip CR+LF"); + // With LF only + assertArrayEquals(expected, codec.decode("QED\n8WEX0"), "Should skip LF"); + // With TAB + assertArrayEquals(expected, codec.decode("QED\t8WEX0"), "Should skip TAB"); + } + + /** + * Tests that spaces (ASCII 32) ARE in the Base45 alphabet and are NOT skipped during decoding. Space has alphabet value 36. + */ + @Test + void testDecodeSpaceIsInAlphabet() { + final Base45 codec = new Base45(); + // ' ' has value 36 in Base45 alphabet + // Verify space is in alphabet + assertTrue(codec.isInAlphabet((byte) ' '), "Space should be in Base45 alphabet"); + // Round-trip test for data that encodes to/contains a space + final byte[] input = { (byte) 0xF0, (byte) 0xF0 }; // Some value that produces a space in output + final byte[] encoded = codec.encode(input); + final byte[] decoded = codec.decode(encoded); + assertArrayEquals(input, decoded, "Round-trip with space in encoding failed"); + } + + /** + * Tests decoding via the String overload of {@code decode}. + */ + @Test + void testDecodeStringRfc9285TestVectors() { + final Base45 codec = new Base45(); + for (final String[] testCase : RFC9285_TEST_VECTORS) { + final String plainText = testCase[0]; + final String encoded = testCase[1]; + final byte[] expected = plainText.getBytes(StandardCharsets.ISO_8859_1); + final byte[] actual = codec.decode(encoded); + assertArrayEquals(expected, actual, () -> "RFC 9285 decode(String) failed for: '" + encoded + "'"); + } + } + + /** + * Tests that the DECODE_TABLE has exactly 128 entries (covering the full ASCII range). + */ + @Test + void testDecodeTableLength() { + assertEquals(128, Base45.DECODE_TABLE.length); + } + + /** + * Tests that an encoded 3-character triple that decodes to a value exceeding 65535 is rejected. The maximum valid 3-character value is 44 + 44*45 + 44*2025 + * = 91124, which exceeds 65535. + */ + @Test + void testDecodeTripleExceedingMaxValue() { + final Base45 codec = new Base45(); + // ':' ':' ':' = value 44, 44*45, 44*2025 = 44 + 1980 + 89100 = 91124 > 65535 + // In the alphabet ':' = 44 (last entry) + assertThrows(IllegalArgumentException.class, () -> codec.decode(":::"), + "Triple ':' ':' ':' decodes to 91124 which exceeds 65535 and should be rejected"); + } + + /** + * Tests decoding of "000" -> [0x00, 0x00]. + */ + @Test + void testDecodeZeroZero() { + assertArrayEquals(new byte[] { 0, 0 }, new Base45().decode("000")); + } + + /** + * Tests encoding of "AB" -> "BB8". A=65, B=66: n = 65*256+66 = 16706; c=11('B'), d=11('B'), e=8('8') -> "BB8" + */ + @Test + void testEncodeAB() { + final byte[] input = "AB".getBytes(StandardCharsets.US_ASCII); + assertEquals("BB8", new Base45().encodeToString(input)); + assertArrayEquals(input, new Base45().decode("BB8")); + } + + /** + * Tests the {@link Base45#encodeAsString(byte[])} convenience method. + */ + @Test + void testEncodeAsString() { + final Base45 codec = new Base45(); + final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII); + assertEquals("QED8WEX0", codec.encodeAsString(input)); + assertEquals(codec.encodeToString(input), codec.encodeAsString(input)); + } + + /** + * Tests encoding of all possible single-byte values for round-trip correctness. + */ + @Test + void testEncodeDecodeSingleByteRoundTrip() { + final Base45 codec = new Base45(); + for (int i = 0; i <= 255; i++) { + final byte[] input = { (byte) i }; + final byte[] encoded = codec.encode(input); + assertEquals(2, encoded.length, "Single byte should encode to 2 chars, byte value: " + i); + final byte[] decoded = codec.decode(encoded); + assertArrayEquals(input, decoded, "Round-trip failed for byte value: " + i); + } + } + + /** + * Verifies that all 45 ENCODE_TABLE entries are in the DECODE_TABLE with the correct index. + */ + @Test + void testEncodeDecodeTableConsistency() { + final byte[] encodeTable = Base45.ENCODE_TABLE; + final byte[] decodeTable = Base45.DECODE_TABLE; + for (int i = 0; i < encodeTable.length; i++) { + final int encoded = encodeTable[i] & 0xFF; + assertTrue(encoded < decodeTable.length, "Encode table char " + (char) encoded + " at index " + i + " exceeds decode table length"); + assertEquals(i, decodeTable[encoded], "Decode table mismatch for char '" + (char) encoded + "' at encode index " + i); + } + } + + /** + * Tests encoding of all possible two-byte values for round-trip correctness. Checks a sample to avoid exhaustive O(65536) iterations being slow. + */ + @Test + void testEncodeDecodeTwoByteRoundTrip() { + final Base45 codec = new Base45(); + // Test specific important values + final int[] interestingValues = { 0, 1, 44, 45, 254, 255, 256, 2024, 2025, 65534, 65535 }; + for (final int n : interestingValues) { + final byte[] input = { (byte) (n >> 8), (byte) (n & 0xFF) }; + final byte[] encoded = codec.encode(input); + assertEquals(3, encoded.length, "Two bytes should encode to 3 chars, n=" + n); + final byte[] decoded = codec.decode(encoded); + assertArrayEquals(input, decoded, "Round-trip failed for two-byte value n=" + n); + } + } + + /** + * Tests that the codec correctly handles inputs where encoded output contains spaces (space = Base45 value 36), ensuring they are preserved through the + * encode-decode cycle. + */ + @Test + void testEncodeDecodeWithSpaceInOutput() { + final Base45 codec = new Base45(); + // Find byte pairs that encode to include a space (' ' = value 36): + // We need n such that n % 45 == 36, or (n/45) % 45 == 36, or n/2025 == 36. + // For n % 45 == 36: e.g., n = 36 -> b0=0, b1=36 + final byte[] input = { 0, 36 }; // n = 36, first char = ' ' + final byte[] encoded = codec.encode(input); + assertTrue(codec.isInAlphabet(encoded[0]), "First encoded char should be in alphabet"); + assertEquals((byte) ' ', encoded[0], "First encoded char should be space (value 36)"); + assertArrayEquals(input, codec.decode(encoded)); + } + + /** + * Tests the relationship between input length and encoded length. Per RFC 9285: encoded_length = (n / 2) * 3 + (n % 2 != 0 ? 2 : 0). + */ + @Test + void testEncodedLength() { + final Base45 codec = new Base45(); + final int[] inputLengths = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000 }; + for (final int n : inputLengths) { + final byte[] input = new byte[n]; + final long expected = (long) n / 2 * 3 + (n % 2 != 0 ? 2 : 0); + assertEquals(expected, codec.getEncodedLength(input), "getEncodedLength incorrect for input length " + n); + } + } + + /** + * Tests that encoded output contains only valid Base45 characters. + */ + @Test + void testEncodedOutputIsInAlphabet() { + final Base45 codec = new Base45(); + final Random rng = new Random(42L); + for (int len = 0; len <= 100; len++) { + final byte[] input = new byte[len]; + rng.nextBytes(input); + final byte[] encoded = codec.encode(input); + for (final byte b : encoded) { + assertTrue(codec.isInAlphabet(b), "Encoded byte " + b + " ('" + (char) b + "') is not in Base45 alphabet"); + } + } + } + + /** + * Tests that an empty byte array encodes to an empty byte array. + */ + @Test + void testEncodeEmpty() { + final Base45 codec = new Base45(); + assertArrayEquals(new byte[0], codec.encode(new byte[0])); + assertEquals("", codec.encodeToString(new byte[0])); + } + + /** + * Tests that [0xFF, 0xFF] encodes to "FGW". n=65535: c=15('F'), d=16('G'), e=32('W') -> "FGW" + */ + @Test + void testEncodeMaxMax() { + final byte[] input = { (byte) 0xFF, (byte) 0xFF }; + assertEquals("FGW", new Base45().encodeToString(input)); + } + + /** + * Tests that {@link Base45} implements BinaryEncoder correctly via the {@code encode(Object)} method. + */ + @Test + void testEncodeObject() throws EncoderException { + final Base45 codec = new Base45(); + final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII); + final Object result = codec.encode((Object) input); + assertArrayEquals("QED8WEX0".getBytes(StandardCharsets.US_ASCII), (byte[]) result); + } + + /** + * Tests that encoding a non-byte-array Object throws EncoderException. + */ + @Test + void testEncodeObjectThrowsForNonByteArray() { + final Base45 codec = new Base45(); + assertThrows(EncoderException.class, () -> codec.encode("not a byte array")); + } + + /** + * Pins the {@code encode} postcondition that {@code Context#ibitWorkArea} is reset after each complete 2-byte group is emitted. + *+ * Regression guard: without the reset, the work area would hold stale high bits from previous groups and output correctness would depend solely on the + * {@code & 0xFFFF}/{@code & 0xFF} output masks. + *
+ */ + @Test + void testEncodeResetsWorkAreaAfterCompleteGroup() { + final Base45 codec = new Base45(); + final BaseNCodec.Context context = new BaseNCodec.Context(); + final byte[] data = { 1, 2, 3, 4, 5, 6 }; // three complete 2-byte groups + codec.encode(data, 0, data.length, context); + assertEquals(0, context.modulus, "modulus must be 0 after an even number of encoded bytes"); + assertEquals(0, context.ibitWorkArea, "encode() must reset ibitWorkArea after emitting each complete group, not leave stale accumulator data"); + } + + /** + * Tests the RFC 9285 Section 4.4 encoding test vectors. + */ + @Test + void testEncodeRfc9285TestVectors() { + final Base45 codec = new Base45(); + for (final String[] testCase : RFC9285_TEST_VECTORS) { + final String plainText = testCase[0]; + final String expected = testCase[1]; + final byte[] input = plainText.getBytes(StandardCharsets.ISO_8859_1); + final String actual = codec.encodeToString(input); + assertEquals(expected, actual, "RFC 9285 encode failed for: '" + plainText + "'"); + } + } + + /** + * Tests encoding of a single byte with value 65 ('A'). n=65: 65%45=20 ('K'), 65/45=1 ('1') -> "K1" + */ + @Test + void testEncodeSingleByteLetterA() { + final byte[] input = "A".getBytes(StandardCharsets.US_ASCII); // 'A' = 65 + assertEquals("K1", new Base45().encodeToString(input)); + assertArrayEquals(input, new Base45().decode("K1")); + } + + /** + * Tests encoding of a single byte with value 1. n=1: 1%45=1 ('1'), 1/45=0 ('0') -> "10" + */ + @Test + void testEncodeSingleByteOne() { + final byte[] input = { 1 }; + assertEquals("10", new Base45().encodeToString(input)); + assertArrayEquals(input, new Base45().decode("10")); + } + + /** + * Tests encoding of a single max-value byte (0xFF = 255). n=255: 255%45=30('U'), 255/45=5('5') -> "U5" + */ + @Test + void testEncodeSingleMaxByte() { + final byte[] input = { (byte) 0xFF }; + assertEquals("U5", new Base45().encodeToString(input)); + } + + /** + * Tests encoding of a single zero byte. n=0: c=0('0'), d=0('0') -> "00" + */ + @Test + void testEncodeSingleZeroByte() { + final byte[] input = { 0 }; + assertEquals("00", new Base45().encodeToString(input)); + } + + /** + * Verifies the first 10 entries (digits 0-9) of the ENCODE_TABLE. + */ + @Test + void testEncodeTableDigits() { + for (int i = 0; i <= 9; i++) { + assertEquals((byte) ('0' + i), Base45.ENCODE_TABLE[i], "ENCODE_TABLE[" + i + "] should be digit '" + (char) ('0' + i) + "'"); + } + } + + /** + * Verifies that the ENCODE_TABLE has exactly 45 entries. + */ + @Test + void testEncodeTableHas45Entries() { + assertEquals(45, Base45.ENCODE_TABLE.length); + } + + /** + * Verifies the special-character entries 36-44 of the ENCODE_TABLE. + */ + @Test + void testEncodeTableSpecialChars() { + assertEquals((byte) ' ', Base45.ENCODE_TABLE[36], "ENCODE_TABLE[36] should be space"); + assertEquals((byte) '$', Base45.ENCODE_TABLE[37], "ENCODE_TABLE[37] should be '$'"); + assertEquals((byte) '%', Base45.ENCODE_TABLE[38], "ENCODE_TABLE[38] should be '%'"); + assertEquals((byte) '*', Base45.ENCODE_TABLE[39], "ENCODE_TABLE[39] should be '*'"); + assertEquals((byte) '+', Base45.ENCODE_TABLE[40], "ENCODE_TABLE[40] should be '+'"); + assertEquals((byte) '-', Base45.ENCODE_TABLE[41], "ENCODE_TABLE[41] should be '-'"); + assertEquals((byte) '.', Base45.ENCODE_TABLE[42], "ENCODE_TABLE[42] should be '.'"); + assertEquals((byte) '/', Base45.ENCODE_TABLE[43], "ENCODE_TABLE[43] should be '/'"); + assertEquals((byte) ':', Base45.ENCODE_TABLE[44], "ENCODE_TABLE[44] should be ':'"); + } + + /** + * Verifies entries 10-35 (uppercase A-Z) of the ENCODE_TABLE. + */ + @Test + void testEncodeTableUppercase() { + for (int i = 0; i < 26; i++) { + assertEquals((byte) ('A' + i), Base45.ENCODE_TABLE[10 + i], "ENCODE_TABLE[" + (10 + i) + "] should be letter '" + (char) ('A' + i) + "'"); + } + } + + /** + * Tests compatibility with the {@code encode(byte[], int, int)} overload. + */ + @Test + void testEncodeWithOffsetAndLength() { + final Base45 codec = new Base45(); + final byte[] buffer = new byte[10]; + // Fill with "ietf!" bytes at offset 2 + final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(input, 0, buffer, 2, input.length); + final byte[] encoded = codec.encode(buffer, 2, 5); + assertEquals("QED8WEX0", new String(encoded, StandardCharsets.US_ASCII)); + } + + /** + * Companion to {@link #testEncodeResetsWorkAreaAfterCompleteGroup}: with a pending (odd) byte, the work area must hold exactly that pending byte, nothing + * more. + *+ * Regression guard for the {@code Base45#encode} work-area reset. + *
+ */ + @Test + void testEncodeWorkAreaHoldsOnlyPendingByte() { + final Base45 codec = new Base45(); + final BaseNCodec.Context context = new BaseNCodec.Context(); + final byte[] data = { 1, 2, 3, 4, 99 }; // two complete groups + one pending byte (99) + codec.encode(data, 0, data.length, context); + assertEquals(1, context.modulus, "one pending byte must leave modulus == 1"); + assertEquals(99, context.ibitWorkArea, "after two complete groups, ibitWorkArea must hold only the pending byte, not stale high bits"); + } + + /** + * Tests that two zero bytes encode to "000". n=0: c=0('0'), d=0('0'), e=0('0') -> "000" + */ + @Test + void testEncodeZeroZero() { + final byte[] input = { 0, 0 }; + assertEquals("000", new Base45().encodeToString(input)); + } + + /** + * Tests {@link Base45#getEncodedLength(byte[])} for known input lengths. + */ + @Test + void testGetEncodedLength() { + final Base45 codec = new Base45(); + assertEquals(0L, codec.getEncodedLength(new byte[0])); // 0 bytes -> 0 chars + assertEquals(2L, codec.getEncodedLength(new byte[1])); // 1 byte -> 2 chars + assertEquals(3L, codec.getEncodedLength(new byte[2])); // 2 bytes -> 3 chars + assertEquals(5L, codec.getEncodedLength(new byte[3])); // 3 bytes -> 5 chars + assertEquals(6L, codec.getEncodedLength(new byte[4])); // 4 bytes -> 6 chars + assertEquals(8L, codec.getEncodedLength(new byte[5])); // 5 bytes -> 8 chars + assertEquals(9L, codec.getEncodedLength(new byte[6])); // 6 bytes -> 9 chars + assertEquals(11L, codec.getEncodedLength(new byte[7])); // 7 bytes -> 11 chars + } + + /** + * Tests that the actual encoded length matches the value returned by getEncodedLength. + */ + @Test + void testGetEncodedLengthMatchesActual() { + final Base45 codec = new Base45(); + for (int len = 0; len <= 30; len++) { + final byte[] input = new byte[len]; + Arrays.fill(input, (byte) 0xAB); + final byte[] encoded = codec.encode(input); + assertEquals(codec.getEncodedLength(input), encoded.length, "getEncodedLength disagrees with actual length for input length " + len); + } + } + + /** + * Tests {@link Base45#isInAlphabet(byte)} for all 45 valid alphabet characters. + */ + @Test + void testIsInAlphabet_allValidChars() { + final Base45 codec = new Base45(); + final String alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + assertEquals(45, alphabet.length()); + for (final char c : alphabet.toCharArray()) { + assertTrue(codec.isInAlphabet((byte) c), "Character '" + c + "' (ASCII " + (int) c + ") should be in Base45 alphabet"); + } + } + + /** + * Tests {@link Base45#isInAlphabet(byte)} for characters NOT in the Base45 alphabet. + */ + @Test + void testIsInAlphabet_invalidChars() { + final Base45 codec = new Base45(); + // Control characters + for (int i = 0; i < 32; i++) { + if (i != ' ') { // space is at 32 + // None of 0-31 are in the alphabet + assertFalse(codec.isInAlphabet((byte) i), "Control char " + i + " should not be in Base45 alphabet"); + } + } + // Characters between valid ranges + assertFalse(codec.isInAlphabet((byte) '!'), "! should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '"'), "\" should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '#'), "# should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '&'), "& should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '\''), "' should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '('), "( should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) ')'), ") should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) ','), ", should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) ';'), "; should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '<'), "< should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '='), "= should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '>'), "> should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '?'), "? should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '@'), "@ should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '['), "[ should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '\\'), "\\ should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) ']'), "] should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '^'), "^ should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '_'), "_ should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) '`'), "` should not be in Base45 alphabet"); + // lowercase letters + for (char c = 'a'; c <= 'z'; c++) { + assertFalse(codec.isInAlphabet((byte) c), "Lowercase '" + c + "' should not be in Base45 alphabet"); + } + // High bytes (> 127) + assertFalse(codec.isInAlphabet((byte) 0x80), "Byte 0x80 should not be in Base45 alphabet"); + assertFalse(codec.isInAlphabet((byte) 0xFF), "Byte 0xFF should not be in Base45 alphabet"); + } + + /** + * Tests that the pad character is NOT treated as part of the alphabet, because Base45 (RFC 9285) has no padding, while whitespace is still honored per the + * {@code allowWhitespacePad} flag (consistent with {@code decode} skipping it). + */ + @Test + void testIsInAlphabetArrayDoesNotAllowPad() { + final Base45 codec = new Base45(); + assertFalse(codec.isInAlphabet("QED="), "'=' (inherited pad) must not be treated as in-alphabet for padding-less Base45"); + assertFalse(codec.isInAlphabet("QED=".getBytes(StandardCharsets.US_ASCII), true)); + assertFalse(codec.isInAlphabet("QED=".getBytes(StandardCharsets.US_ASCII), false)); + assertTrue(codec.isInAlphabet("QED\t8WE".getBytes(StandardCharsets.US_ASCII), true)); + assertFalse(codec.isInAlphabet("QED\t8WE".getBytes(StandardCharsets.US_ASCII), false)); + assertTrue(codec.isInAlphabet("QED8WEX0".getBytes(StandardCharsets.US_ASCII), true)); + } + + /** + * Tests {@link Base45#isInAlphabet(byte[])} for valid and invalid arrays. + */ + @Test + void testIsInAlphabetByteArray() { + final Base45 codec = new Base45(); + assertTrue(codec.isInAlphabet("QED8WEX0".getBytes(StandardCharsets.US_ASCII), false)); + assertTrue(codec.isInAlphabet(new byte[0], false)); + assertFalse(codec.isInAlphabet("QED!WEX0".getBytes(StandardCharsets.US_ASCII), false)); + assertFalse(codec.isInAlphabet("abc".getBytes(StandardCharsets.US_ASCII), false)); + } + + /** + * Tests that long inputs (more than 2 encoding blocks) encode and decode correctly. + */ + @Test + void testLongInputRoundTrip() { + final Base45 codec = new Base45(); + final byte[] input = new byte[1000]; + new Random(99999L).nextBytes(input); + final byte[] encoded = codec.encode(input); + assertEquals(codec.getEncodedLength(input), encoded.length); + assertArrayEquals(input, codec.decode(encoded)); + } + + /** + * Verifies RFC 9285 Test Vector 1: "ietf!" encodes to "QED8WEX0". + *+ * Manually verified: + *
+ * Manually verified: + *