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
+ * 
+ * + *

Encoding

+ *

+ * 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. + *

+ * + *

Decoding

+ *

+ * 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 { + + /** + * Constructs a new instance using the Base45 alphabet as defined by RFC 9285. + */ + public Builder() { + super(ENCODE_TABLE); + setDecodingPolicy(CodecPolicy.STRICT); + setDecodeTableRaw(DECODE_TABLE); + setEncodeTableRaw(ENCODE_TABLE); + setEncodedBlockSize(BYTES_PER_ENCODED_BLOCK); + setUnencodedBlockSize(BYTES_PER_UNENCODED_BLOCK); + } + + @Override + public Base45 get() { + return new Base45(this); + } + + /** + * Sets the decoding policy. {@link CodecPolicy#STRICT} is the only supported policy. + * + * @param decodingPolicy The decoding policy; {@code null} resets to the default ({@link CodecPolicy#STRICT}). + * @return {@code this} instance. + * @throws IllegalArgumentException if the given policy is {@link CodecPolicy#LENIENT}. + */ + @Override + public Builder setDecodingPolicy(final CodecPolicy decodingPolicy) { + if (decodingPolicy == CodecPolicy.LENIENT) { + throw new IllegalArgumentException("CodecPolicy.STRICT is the only supported policy."); + } + return super.setDecodingPolicy(decodingPolicy != null ? decodingPolicy : CodecPolicy.STRICT); + } + + /** + * Sets the encode table and derives the matching decode table, so the codec can always decode its own output. + * + * @param encodeTable The encode table with exactly 45 unique entries, null resets to the default. + * @return {@code this} instance. + * @throws IllegalArgumentException if the encode table does not contain exactly 45 unique entries. + */ + @Override + public Builder setEncodeTable(final byte... encodeTable) { + super.setDecodeTableRaw(toDecodeTable(encodeTable)); + return super.setEncodeTable(encodeTable); + } + + /** + * Always throws UnsupportedOperationException: Unsupported by Base45 RFC 9285. + * + * @throws UnsupportedOperationException Always thrown: Unsupported by Base45 RFC 9285. + */ + @Override + public Builder setLineLength(final int lineLength) { + throw new UnsupportedOperationException("Unsupported by Base45 RFC 9285"); + } + + /** + * Always throws UnsupportedOperationException: Unsupported by Base45 RFC 9285. + * + * @throws UnsupportedOperationException Always thrown: Unsupported by Base45 RFC 9285. + */ + @Override + public Builder setLineSeparator(final byte... lineSeparator) { + throw new UnsupportedOperationException("Unsupported by Base45 RFC 9285"); + } + + /** + * Always throws UnsupportedOperationException: Unsupported by Base45 RFC 9285. + * + * @throws UnsupportedOperationException Always thrown: Unsupported by Base45 RFC 9285. + */ + @Override + public Builder setPadding(final byte padding) { + throw new UnsupportedOperationException("Unsupported by Base45 RFC 9285"); + } + } + + /** + * The number of characters in the Base45 alphabet. + */ + private static final int BASE = 45; + + /** + * The square of the Base45 alphabet size (45 * 45 = 2025), used during decoding. + */ + private static final int BASE_SQUARED = BASE * BASE; // 2025 + + /** + * Number of Base45 characters in an encoded block (encoding 2 unencoded bytes). + */ + static final int BYTES_PER_ENCODED_BLOCK = 3; + + private static final int TAIL_ENCODED_BLOCK = BYTES_PER_ENCODED_BLOCK - 1; + + /** + * Number of unencoded bytes per full encoding block. + */ + static final int BYTES_PER_UNENCODED_BLOCK = 2; + + /** + * Lookup table translating ASCII character values (0–127) to their Base45 alphabet index (0–44), or -1 if the character is not in the Base45 alphabet. + */ + // @formatter:off + static final byte[] DECODE_TABLE = { + // 0 1 2 3 4 5 6 7 8 9 A B C D E F + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 00-0f + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 10-1f + 36, -1, -1, -1, 37, 38, -1, -1, -1, -1, 39, 40, -1, 41, 42, 43, // 20-2f ' ','$','%','*','+','-','.','/ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 44, -1, -1, -1, -1, -1, // 30-3f '0'-'9', ':' + -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, // 40-4f 'A'-'O' + 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, -1, // 50-5f 'P'-'Z' + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 60-6f + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 70-7f + }; + + // @formatter:on + /** + * Lookup table translating 6-bit Base45 values (0–44) to their ASCII character equivalents. + *

+ * 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: + *

+ * + * + * @param input byte array of Base45-encoded character data to decode. + * @param inPos Position to start reading data from. + * @param inAvail Number of bytes available from {@code input} for decoding, or {@code -1} to signal EOF. + * @param context The context to be used. + * @throws IllegalArgumentException if the input contains an invalid character, if the encoded length modulo 3 equals 1, or if an encoded triple decodes to + * a value exceeding 65535, or if a trailing 2-character sequence decodes to a value greater than 255. + */ + @Override + void decode(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; + switch (context.modulus) { + case 0: + // Nothing to do; input length is a multiple of 3. + break; + case 1: + // RFC 9285: "It is an error if the remaining string length is 1 character." + throw new IllegalArgumentException("Invalid Base45 encoding: encoded input length modulo 3 must not equal 1."); + case 2: + // Two trailing characters decode to one byte. + // Maximum decodable value from two Base45 characters: 44 + 44*45 = 2024. + // Valid single-byte encodings have a decoded value in [0, 255]. + if (context.ibitWorkArea > 0xFF) { + throw new IllegalArgumentException("Invalid Base45 encoding: trailing 2-character sequence decodes to " + context.ibitWorkArea + + ", which exceeds the valid byte range (0-255)."); + } + ensureBufferSize(1, context)[context.pos++] = (byte) context.ibitWorkArea; + break; + default: + throw new IllegalStateException("Impossible modulus " + context.modulus); + } + return; + } + for (int i = 0; i < inAvail; i++) { + final int b = input[inPos++] & 0xFF; + // Characters not in the Base45 alphabet: + // - Whitespace (excluding space ' ' which IS in the alphabet): skip silently. + // - Any other non-alphabet character: throw. + if (b >= decodeTable.length || decodeTable[b] < 0) { + if (Character.isWhitespace(b)) { + // Skip whitespace characters that are not in the alphabet (e.g., CR, LF, TAB). + // Note: space (ASCII 32) is part of the Base45 alphabet and is handled above. + continue; + } + throw new IllegalArgumentException("Invalid Base45 character '" + (char) b + "' (value " + b + ")."); + } + final int value = decodeTable[b]; + switch (context.modulus) { + case 0: + // First character of a 3-character group: initialize accumulator. + context.ibitWorkArea = value; + context.modulus = 1; + break; + case 1: + // Second character of a 3-character group. + context.ibitWorkArea += value * BASE; + context.modulus = 2; + break; + case 2: + // Third character of a 3-character group: compute value and output 2 bytes. + context.ibitWorkArea += value * BASE_SQUARED; + context.modulus = 0; + if (context.ibitWorkArea > 0xFFFF) { + throw new IllegalArgumentException("Invalid Base45 encoding: 3-character sequence decodes to " + context.ibitWorkArea + + ", which exceeds the valid 16-bit range (0-65535)."); + } + final byte[] buffer = ensureBufferSize(BYTES_PER_UNENCODED_BLOCK, context); + buffer[context.pos++] = (byte) (context.ibitWorkArea >> 8); + buffer[context.pos++] = (byte) (context.ibitWorkArea & 0xFF); + context.ibitWorkArea = 0; + break; + default: + throw new IllegalStateException("Impossible modulus " + context.modulus); + } + } + } + + /** + * Encodes 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 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 { + + /** + * Builds instances of Base45InputStream. + */ + public static class Builder extends BaseNCodecInputStream.AbstracBuilder { + + /** + * Constructs a new instance. + */ + public Builder() { + // empty + } + + @Override + public Base45InputStream get() { + return new Base45InputStream(this); + } + + @Override + protected Base45 newBaseNCodec() { + return new Base45(); + } + } + + /** + * Constructs a new Builder. + * + * @return A new Builder. + */ + public static Builder builder() { + return new Builder(); + } + + private Base45InputStream(final Builder builder) { + super(builder); + } + + /** + * Constructs a Base45InputStream such that all data read is Base45-decoded from the original provided InputStream. + * + * @param inputStream InputStream to wrap. + */ + public Base45InputStream(final InputStream inputStream) { + super(builder().setInputStream(inputStream)); + } +} diff --git a/src/main/java/org/apache/commons/codec/binary/Base45OutputStream.java b/src/main/java/org/apache/commons/codec/binary/Base45OutputStream.java new file mode 100644 index 0000000000..11f33e021b --- /dev/null +++ b/src/main/java/org/apache/commons/codec/binary/Base45OutputStream.java @@ -0,0 +1,85 @@ +/* + * 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.OutputStream; + +/** + * Provides RFC 9285 Base45 encoding in a streaming fashion (unlimited size). + * + * @see Base45 + * @see RFC 9285 – The Base45 Data Encoding + * @since 1.23.0 + */ +public class Base45OutputStream extends BaseNCodecOutputStream { + + /** + * Builds instances of Base45OutputStream. + */ + public static class Builder extends BaseNCodecOutputStream.AbstractBuilder { + + /** + * Constructs a new instance. + */ + public Builder() { + setEncode(true); + } + + /** + * Builds a new Base45OutputStream instance with the configured settings. + * + * @return A new Base45OutputStream. + */ + @Override + public Base45OutputStream get() { + return new Base45OutputStream(this); + } + + /** + * Creates a new Base45 codec instance. + * + * @return A new Base45 codec. + */ + @Override + protected Base45 newBaseNCodec() { + return new Base45(); + } + } + + /** + * Constructs a new Builder. + * + * @return A new Builder. + */ + public static Builder builder() { + return new Builder(); + } + + private Base45OutputStream(final Builder builder) { + super(builder); + } + + /** + * Constructs a Base45OutputStream such that all data written is Base45-encoded to the original provided OutputStream. + * + * @param outputStream OutputStream to wrap. + */ + public Base45OutputStream(final OutputStream outputStream) { + this(builder().setOutputStream(outputStream)); + } +} diff --git a/src/test/java/org/apache/commons/codec/binary/Base45InputStreamTest.java b/src/test/java/org/apache/commons/codec/binary/Base45InputStreamTest.java new file mode 100644 index 0000000000..8f45168455 --- /dev/null +++ b/src/test/java/org/apache/commons/codec/binary/Base45InputStreamTest.java @@ -0,0 +1,341 @@ +/* + * 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 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Base45InputStream}. + */ +class Base45InputStreamTest { + + private static final byte[] CRLF = { (byte) '\r', (byte) '\n' }; + private static final byte[] LF = { (byte) '\n' }; + private static final String STRING_FIXTURE = "Hello World"; + + @Test + void testAvailable() throws Throwable { + final String encoded = new String(new Base45().encode(StringUtils.getBytesUtf8("foo"))); + final InputStream ins = new ByteArrayInputStream(StringUtils.getBytesIso8859_1(encoded)); + try (Base45InputStream b45stream = new Base45InputStream(ins)) { + final int initialAvailable = b45stream.available(); + assertTrue(initialAvailable > 0, "Initial available should be greater than 0"); + assertEquals(3, b45stream.skip(10), "Skip should return 3 (decoded bytes)"); + assertEquals(0, b45stream.available()); + assertEquals(-1, b45stream.read()); + assertEquals(-1, b45stream.read()); + } + } + + private void testBase45EmptyInputStream(final int chuckSize) throws Exception { + final byte[] emptyEncoded = {}; + final byte[] emptyDecoded = {}; + testByChunk(emptyEncoded, emptyDecoded, chuckSize, CRLF); + testByteByByte(emptyEncoded, emptyDecoded, chuckSize, CRLF); + } + + /** + * Tests the Base45InputStream implementation against empty input. + * + * @throws Exception for some failure scenarios. + */ + @Test + void testBase45EmptyInputStreamMimeChuckSize() throws Exception { + testBase45EmptyInputStream(BaseNCodec.MIME_CHUNK_SIZE); + } + + /** + * Tests the Base45InputStream implementation against empty input. + * + * @throws Exception for some failure scenarios. + */ + @Test + void testBase45EmptyInputStreamPemChuckSize() throws Exception { + testBase45EmptyInputStream(BaseNCodec.PEM_CHUNK_SIZE); + } + + @Test + void testBase45InputStreamByChunk() throws Exception { + // Hello World test. + byte[] decoded = StringUtils.getBytesUtf8(STRING_FIXTURE); + byte[] encoded = new Base45().encode(decoded); + testByChunk(encoded, decoded, BaseNCodec.MIME_CHUNK_SIZE, CRLF); + // test random data of sizes 0 through 150 + final BaseNCodec codec = new Base45(); + for (int i = 0; i <= 150; i++) { + final byte[][] randomData = BaseNTestData.randomData(codec, i); + encoded = randomData[1]; + decoded = randomData[0]; + testByChunk(encoded, decoded, 0, LF); + } + } + + @Test + void testBase45InputStreamByteByByte() throws Exception { + // Hello World test. + byte[] decoded = StringUtils.getBytesUtf8(STRING_FIXTURE); + byte[] encoded = new Base45().encode(decoded); + testByteByByte(encoded, decoded, BaseNCodec.MIME_CHUNK_SIZE, CRLF); + // test random data of sizes 0 through 150 + final BaseNCodec codec = new Base45(); + for (int i = 0; i <= 150; i++) { + final byte[][] randomData = BaseNTestData.randomData(codec, i); + encoded = randomData[1]; + decoded = randomData[0]; + testByteByByte(encoded, decoded, 0, LF); + } + } + + @Test + void testBuilder() { + assertNotNull(Base45InputStream.builder().getBaseNCodec()); + } + + /** + * Tests method does three tests on the supplied data: 1. encoded ---[DECODE]--> decoded 2. decoded ---[ENCODE]--> encoded 3. decoded + * ---[WRAP-WRAP-WRAP-etc...] --> decoded + *

+ * By "[WRAP-WRAP-WRAP-etc...]" we mean situation where the Base45InputStream wraps itself in encode and decode mode over and over again. + * + * @param encoded Base45 encoded data + * @param decoded The data from above, but decoded + * @param chunkSize chunk size (line-length) of the Base45 encoded data. + * @param separator Line separator in the Base45 encoded data. + * @throws Exception Usually signifies a bug in the Base45 commons-codec implementation. + */ + private void testByChunk(final byte[] encoded, final byte[] decoded, final int chunkSize, final byte[] separator) throws Exception { + try (InputStream in = Base45InputStream.builder().setByteArray(decoded).setEncode(true).get()) { + final byte[] output = IOUtils.toByteArray(in); + assertEquals(-1, in.read(), "EOF"); + assertEquals(-1, in.read(), "Still EOF"); + assertArrayEquals(encoded, output, "Streaming Base45 encode"); + } + try (InputStream in = new Base45InputStream(new ByteArrayInputStream(encoded))) { + final byte[] output = IOUtils.toByteArray(in); + assertEquals(-1, in.read(), "EOF"); + assertEquals(-1, in.read(), "Still EOF"); + assertArrayEquals(decoded, output, "Streaming Base45 decode"); + } + InputStream in = new ByteArrayInputStream(decoded); + for (int i = 0; i < 10; i++) { + in = Base45InputStream.builder().setInputStream(in).setEncode(true).get(); + in = Base45InputStream.builder().setInputStream(in).setEncode(false).get(); + } + final InputStream in1 = in; + final byte[] output = IOUtils.toByteArray(in1); + assertEquals(-1, in.read(), "EOF"); + assertEquals(-1, in.read(), "Still EOF"); + assertArrayEquals(decoded, output, "Streaming Base45 wrap-wrap-wrap!"); + in.close(); + } + + /** + * Tests method does three tests on the supplied data: 1. encoded ---[DECODE]--> decoded 2. decoded ---[ENCODE]--> encoded 3. decoded + * ---[WRAP-WRAP-WRAP-etc...] --> decoded + *

+ * By "[WRAP-WRAP-WRAP-etc...]" we mean situation where the Base45InputStream wraps itself in encode and decode mode over and over again. + * + * @param encoded Base45 encoded data + * @param decoded The data from above, but decoded + * @param chunkSize chunk size (line-length) of the Base45 encoded data. + * @param separator Line separator in the Base45 encoded data. + * @throws Exception Usually signifies a bug in the Base45 commons-codec implementation. + */ + private void testByteByByte(final byte[] encoded, final byte[] decoded, final int chunkSize, final byte[] separator) throws Exception { + InputStream in; + in = Base45InputStream.builder().setByteArray(decoded).setEncode(true).get(); + final InputStream in1 = in; + byte[] output = IOUtils.toByteArray(in1); + assertEquals(-1, in.read(), "EOF"); + assertEquals(-1, in.read(), "Still EOF"); + assertArrayEquals(encoded, output, "Streaming Base45 encode"); + in.close(); + in = new Base45InputStream(new ByteArrayInputStream(encoded)); + final InputStream in2 = in; + output = IOUtils.toByteArray(in2); + assertEquals(-1, in.read(), "EOF"); + assertEquals(-1, in.read(), "Still EOF"); + assertArrayEquals(decoded, output, "Streaming Base45 decode"); + in.close(); + in = new ByteArrayInputStream(decoded); + for (int i = 0; i < 10; i++) { + in = Base45InputStream.builder().setInputStream(in).setEncode(true).get(); + in = Base45InputStream.builder().setInputStream(in).setEncode(false).get(); + } + final InputStream in3 = in; + output = IOUtils.toByteArray(in3); + assertEquals(-1, in.read(), "EOF"); + assertEquals(-1, in.read(), "Still EOF"); + assertArrayEquals(decoded, output, "Streaming Base45 wrap-wrap-wrap!"); + } + + /** + * Tests markSupported. + * + * @throws Exception for some failure scenarios. + */ + @Test + void testMarkSupported() throws Exception { + final byte[] decoded = StringUtils.getBytesUtf8(STRING_FIXTURE); + try (Base45InputStream in = Base45InputStream.builder().setByteArray(decoded).setEncode(true).get()) { + // Always returns false for now. + assertFalse(in.markSupported(), "Base45InputStream.markSupported() is false"); + } + } + + /** + * Tests read returning 0 + * + * @throws Exception for some failure scenarios. + */ + @Test + void testRead0() throws Exception { + final byte[] decoded = StringUtils.getBytesUtf8(STRING_FIXTURE); + final byte[] buf = new byte[1024]; + int bytesRead = 0; + try (Base45InputStream in = Base45InputStream.builder().setByteArray(decoded).setEncode(true).get()) { + bytesRead = in.read(buf, 0, 0); + assertEquals(0, bytesRead, "Base45InputStream.read(buf, 0, 0) returns 0"); + } + } + + /** + * Tests read with null. + * + * @throws Exception for some failure scenarios. + */ + @Test + void testReadNull() throws Exception { + final byte[] decoded = StringUtils.getBytesUtf8(STRING_FIXTURE); + try (Base45InputStream in = Base45InputStream.builder().setByteArray(decoded).setEncode(true).get()) { + assertThrows(NullPointerException.class, () -> in.read(null, 0, 0)); + } + } + + /** + * Tests read throwing IndexOutOfBoundsException + * + * @throws Exception for some failure scenarios. + */ + @Test + void testReadOutOfBounds() throws Exception { + final byte[] decoded = StringUtils.getBytesUtf8(STRING_FIXTURE); + final byte[] buf = new byte[1024]; + try (Base45InputStream in = Base45InputStream.builder().setByteArray(decoded).setEncode(true).get()) { + assertThrows(IndexOutOfBoundsException.class, () -> in.read(buf, -1, 0), "Base45InputStream.read(buf, -1, 0)"); + assertThrows(IndexOutOfBoundsException.class, () -> in.read(buf, 0, -1), "Base45InputStream.read(buf, 0, -1)"); + assertThrows(IndexOutOfBoundsException.class, () -> in.read(buf, buf.length + 1, 0), "Base45InputStream.read(buf, buf.length + 1, 0)"); + assertThrows(IndexOutOfBoundsException.class, () -> in.read(buf, buf.length - 1, 2), "Base45InputStream.read(buf, buf.length - 1, 2)"); + } + } + + /** + * Tests skipping number of characters larger than the internal buffer. + * + * @throws Throwable for some failure scenarios. + */ + @Test + void testSkipBig() throws Throwable { + final String encoded = new String(new Base45().encode(StringUtils.getBytesUtf8("foo"))); + final InputStream ins = new ByteArrayInputStream(StringUtils.getBytesIso8859_1(encoded)); + try (Base45InputStream b45stream = new Base45InputStream(ins)) { + assertEquals(3, b45stream.skip(1024)); + // End of stream reached + assertEquals(-1, b45stream.read()); + assertEquals(-1, b45stream.read()); + } + } + + /** + * Tests skipping as a noop + * + * @throws Throwable for some failure scenarios. + */ + @Test + void testSkipNone() throws Throwable { + final String encoded = new String(new Base45().encode(StringUtils.getBytesUtf8("foo"))); + final InputStream ins = new ByteArrayInputStream(StringUtils.getBytesIso8859_1(encoded)); + try (Base45InputStream b45stream = new Base45InputStream(ins)) { + final byte[] actualBytes = new byte[3]; + assertEquals(0, b45stream.skip(0)); + b45stream.read(actualBytes, 0, actualBytes.length); + assertArrayEquals(actualBytes, new byte[] { 102, 111, 111 }); + // End of stream reached + assertEquals(-1, b45stream.read()); + } + } + + /** + * Tests skipping past the end of a stream. + * + * @throws Throwable for some failure scenarios. + */ + @Test + void testSkipPastEnd() throws Throwable { + final String encoded = new String(new Base45().encode(StringUtils.getBytesUtf8("foo"))); + final InputStream ins = new ByteArrayInputStream(StringUtils.getBytesIso8859_1(encoded)); + try (Base45InputStream b45stream = new Base45InputStream(ins)) { + // skip correctly decoded characters + assertEquals(3, b45stream.skip(10)); + // End of stream reached + assertEquals(-1, b45stream.read()); + assertEquals(-1, b45stream.read()); + } + } + + /** + * Tests skipping to the end of a stream. + * + * @throws Throwable for some failure scenarios. + */ + @Test + void testSkipToEnd() throws Throwable { + final String encoded = new String(new Base45().encode(StringUtils.getBytesUtf8("foo"))); + final InputStream ins = new ByteArrayInputStream(StringUtils.getBytesIso8859_1(encoded)); + try (Base45InputStream b45stream = new Base45InputStream(ins)) { + // skip correctly decoded characters + assertEquals(3, b45stream.skip(3)); + assertEquals(-1, b45stream.read()); + } + } + + /** + * Tests if negative arguments to skip are handled correctly. + * + * @throws Throwable for some failure scenarios. + */ + @Test + void testSkipWrongArgument() throws Throwable { + final String encoded = new String(new Base45().encode(StringUtils.getBytesUtf8("foo"))); + final InputStream ins = new ByteArrayInputStream(StringUtils.getBytesIso8859_1(encoded)); + try (Base45InputStream b45stream = new Base45InputStream(ins)) { + assertThrows(IllegalArgumentException.class, () -> b45stream.skip(-1)); + } + } +} diff --git a/src/test/java/org/apache/commons/codec/binary/Base45OutputStreamTest.java b/src/test/java/org/apache/commons/codec/binary/Base45OutputStreamTest.java new file mode 100644 index 0000000000..97a7eb8219 --- /dev/null +++ b/src/test/java/org/apache/commons/codec/binary/Base45OutputStreamTest.java @@ -0,0 +1,179 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Base45OutputStream}. + */ +class Base45OutputStreamTest extends AbstractBaseNOutputStreamTest { + + private static final byte[] CR_LF = { (byte) '\r', (byte) '\n' }; + private static final byte[] LF = { (byte) '\n' }; + + @Override + OutputStream newOutputStream() { + return new Base45OutputStream(new ByteArrayOutputStream()); + } + + private void testBase45EmptyOutputStream(final int chunkSize) throws Exception { + final byte[] emptyEncoded = {}; + final byte[] emptyDecoded = {}; + testByteByByte(emptyEncoded, emptyDecoded, chunkSize, CR_LF); + testByChunk(emptyEncoded, emptyDecoded, chunkSize, CR_LF); + } + + @Test + void testBase45EmptyOutputStreamMimeChunkSize() throws Exception { + testBase45EmptyOutputStream(BaseNCodec.MIME_CHUNK_SIZE); + } + + @Test + void testBase45EmptyOutputStreamPemChunkSize() throws Exception { + testBase45EmptyOutputStream(BaseNCodec.PEM_CHUNK_SIZE); + } + + @Test + void testBase45OutputStreamByChunk() throws Exception { + byte[] decoded = StringUtils.getBytesUtf8("Hello World"); + byte[] encoded = new Base45().encode(decoded); + testByChunk(encoded, decoded, BaseNCodec.MIME_CHUNK_SIZE, CR_LF); + final BaseNCodec codec = new Base45(); + for (int i = 0; i <= 150; i++) { + final byte[][] randomData = BaseNTestData.randomData(codec, i); + encoded = randomData[1]; + decoded = randomData[0]; + testByChunk(encoded, decoded, 0, LF); + } + } + + @Test + void testBase45OutputStreamByteByByte() throws Exception { + byte[] decoded = StringUtils.getBytesUtf8("Hello World"); + byte[] encoded = new Base45().encode(decoded); + testByteByByte(encoded, decoded, 76, CR_LF); + final BaseNCodec codec = new Base45(); + for (int i = 0; i <= 150; i++) { + final byte[][] randomData = BaseNTestData.randomData(codec, i); + encoded = randomData[1]; + decoded = randomData[0]; + testByteByByte(encoded, decoded, 0, LF); + } + } + + @Test + void testBuilder() { + assertNotNull(Base45OutputStream.builder().getBaseNCodec()); + } + + private void testByChunk(final byte[] encoded, final byte[] decoded, final int chunkSize, final byte[] separator) throws Exception { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + try (OutputStream out = Base45OutputStream.builder().setOutputStream(byteOut).setEncode(true).get()) { + out.write(decoded); + } + byte[] output = byteOut.toByteArray(); + assertArrayEquals(encoded, output, "Streaming chunked Base45 encode"); + byteOut = new ByteArrayOutputStream(); + try (OutputStream out = Base45OutputStream.builder().setOutputStream(byteOut).setEncode(false).get()) { + out.write(encoded); + } + output = byteOut.toByteArray(); + assertArrayEquals(decoded, output, "Streaming chunked Base45 decode"); + byteOut = new ByteArrayOutputStream(); + OutputStream out = byteOut; + for (int i = 0; i < 10; i++) { + out = Base45OutputStream.builder().setOutputStream(out).setEncode(false).get(); + out = Base45OutputStream.builder().setOutputStream(out).setEncode(true).get(); + } + out.write(decoded); + out.close(); + output = byteOut.toByteArray(); + assertArrayEquals(decoded, byteOut.toByteArray(), "Streaming chunked Base45 wrap-wrap-wrap!"); + } + + private void testByteByByte(final byte[] encoded, final byte[] decoded, final int chunkSize, final byte[] separator) throws Exception { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + try (OutputStream out = Base45OutputStream.builder().setOutputStream(byteOut).setEncode(true).get()) { + for (final byte element : decoded) { + out.write(element); + } + } + final byte[] output0 = byteOut.toByteArray(); + assertArrayEquals(encoded, output0, "Streaming byte-by-byte Base45 encode"); + byteOut = new ByteArrayOutputStream(); + try (OutputStream out = Base45OutputStream.builder().setOutputStream(byteOut).setEncode(false).get()) { + for (final byte element : encoded) { + out.write(element); + } + } + final byte[] output1 = byteOut.toByteArray(); + assertArrayEquals(decoded, output1, + () -> String.format("Streaming byte-by-byte Base45 decode, chunkSize=%d, separator=%s, encoded=%s, decoded=%s, output=%s", chunkSize, + Arrays.toString(separator), Arrays.toString(encoded), Arrays.toString(decoded), Arrays.toString(output1))); + byteOut = new ByteArrayOutputStream(); + try (OutputStream out = Base45OutputStream.builder().setOutputStream(byteOut).setEncode(false).get()) { + for (final byte element : encoded) { + out.write(element); + out.flush(); + } + } + byte[] output = byteOut.toByteArray(); + assertArrayEquals(decoded, output, "Streaming byte-by-byte flush() Base45 decode"); + byteOut = new ByteArrayOutputStream(); + OutputStream out = byteOut; + for (int i = 0; i < 10; i++) { + out = Base45OutputStream.builder().setOutputStream(out).setEncode(false).get(); + out = Base45OutputStream.builder().setOutputStream(out).setEncode(true).get(); + } + for (final byte element : decoded) { + out.write(element); + } + out.close(); + output = byteOut.toByteArray(); + assertArrayEquals(decoded, output, "Streaming byte-by-byte Base45 wrap-wrap-wrap!"); + } + + @Test + void testWriteOutOfBounds() throws Exception { + final byte[] buf = new byte[1024]; + final ByteArrayOutputStream bout = new ByteArrayOutputStream(); + try (Base45OutputStream out = new Base45OutputStream(bout)) { + assertThrows(IndexOutOfBoundsException.class, () -> out.write(buf, -1, 1), "Base45OutputStream.write(buf, -1, 1)"); + assertThrows(IndexOutOfBoundsException.class, () -> out.write(buf, 1, -1), "Base45OutputStream.write(buf, 1, -1)"); + assertThrows(IndexOutOfBoundsException.class, () -> out.write(buf, buf.length + 1, 0), "Base45OutputStream.write(buf, buf.length + 1, 0)"); + assertThrows(IndexOutOfBoundsException.class, () -> out.write(buf, buf.length - 1, 2), "Base45OutputStream.write(buf, buf.length - 1, 2)"); + } + } + + @Test + void testWriteToNullCoverage() throws Exception { + final ByteArrayOutputStream bout = new ByteArrayOutputStream(); + try (Base45OutputStream out = new Base45OutputStream(bout)) { + assertThrows(NullPointerException.class, () -> out.write(null, 0, 0)); + } + } +} diff --git a/src/test/java/org/apache/commons/codec/binary/Base45Test.java b/src/test/java/org/apache/commons/codec/binary/Base45Test.java new file mode 100644 index 0000000000..5297773245 --- /dev/null +++ b/src/test/java/org/apache/commons/codec/binary/Base45Test.java @@ -0,0 +1,943 @@ +/* + * 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 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Random; + +import org.apache.commons.codec.CodecPolicy; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.EncoderException; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link Base45} as defined by RFC 9285. + *

+ * 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: + *

    + *
  • Group [i=105, e=101]: n=26981; c=26 (Q), d=14 (E), e=13 (D) -> "QED"
  • + *
  • Group [t=116, f=102]: n=29798; c=8 (8), d=32 (W), e=14 (E) -> "8WE"
  • + *
  • Tail [!=33]: n=33; c=33 (X), d=0 (0) -> "X0"
  • + *
+ */ + @Test + void testRfc9285TestVector1_ietf() { + final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII); + assertEquals("QED8WEX0", new Base45().encodeToString(input)); + assertArrayEquals(input, new Base45().decode("QED8WEX0")); + } + + /** + * Verifies RFC 9285 Test Vector 2: "base-45" encodes to "UJCLQE7W581". + *

+ * Manually verified: + *

    + *
  • Group [b=98, a=97]: n=25185; c=30 (U), d=19 (J), e=12 (C) -> "UJC"
  • + *
  • Group [s=115, e=101]: n=29541; c=21 (L), d=26 (Q), e=14 (E) -> "LQE"
  • + *
  • Group [-=45, 4=52]: n=11572; c=7 (7), d=32 (W), e=5 (5) -> "7W5"
  • + *
  • Tail [5=53]: n=53; c=8 (8), d=1 (1) -> "81"
  • + *
+ */ + @Test + void testRfc9285TestVector2_base45() { + final byte[] input = "base-45".getBytes(StandardCharsets.US_ASCII); + assertEquals("UJCLQE7W581", new Base45().encodeToString(input)); + assertArrayEquals(input, new Base45().decode("UJCLQE7W581")); + } + + /** + * Tests round-trip for binary data with all byte values. + */ + @Test + void testRoundTripAllByteValues() { + final Base45 codec = new Base45(); + final byte[] allBytes = new byte[256]; + for (int i = 0; i < 256; i++) { + allBytes[i] = (byte) i; + } + final byte[] encoded = codec.encode(allBytes); + final byte[] decoded = codec.decode(encoded); + assertArrayEquals(allBytes, decoded, "Round-trip failed for all byte values"); + } + + /** + * Tests round-trip encoding/decoding of all byte lengths from 0 to 50. + */ + @Test + void testRoundTripAllLengths() { + final Base45 codec = new Base45(); + final Random rng = new Random(12345L); + for (int len = 0; len <= 50; len++) { + final byte[] input = new byte[len]; + rng.nextBytes(input); + final byte[] encoded = codec.encode(input); + final byte[] decoded = codec.decode(encoded); + assertArrayEquals(input, decoded, "Round-trip failed for length " + len); + } + } + + /** + * Tests round-trip for various well-known strings. + */ + @Test + void testRoundTripStrings() { + final Base45 codec = new Base45(); + // @formatter:off + final String[] inputs = { + "Hello, World!", + "The quick brown fox jumps over the lazy dog", + "Apache Commons Codec", + "0123456789", + "\u0000\u0001\u0002", + "Base45 (RFC 9285)", + }; + // @formatter:on + for (final String input : inputs) { + final byte[] bytes = input.getBytes(StandardCharsets.UTF_8); + final byte[] decoded = codec.decode(codec.encode(bytes)); + assertArrayEquals(bytes, decoded, () -> "Round-trip failed for: " + input); + } + } + + /** + * Tests that encoding a single byte and then decoding gives the correct value for boundary cases around multiples of 45. + */ + @Test + void testSingleByteAroundBase45Multiples() { + final Base45 codec = new Base45(); + // Test byte values at multiples of 45: 0, 45, 90, 135, 180, 225 + for (int i = 0; i <= 255; i += 45) { + final byte[] input = { (byte) i }; + final byte[] encoded = codec.encode(input); + assertEquals(2, encoded.length, "Encoded length should be 2 for single byte, value=" + i); + assertArrayEquals(input, codec.decode(encoded), "Round-trip failed for single byte value=" + i); + } + } + + /** + * Tests streaming encode/decode with non-aligned chunk sizes to verify the encoder accumulator is correctly reset between blocks. + */ + @Test + void testStreamingEncodeDecodeIncremental() throws IOException { + final byte[] input = "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (Base45OutputStream out = new Base45OutputStream(baos)) { + // Write in 3-byte chunks, which is not aligned to the 2-byte Base45 block size + for (int i = 0; i < input.length; i += 3) { + final int len = Math.min(3, input.length - i); + out.write(input, i, len); + } + } + compare(input, baos); + } + + /** + * Tests streaming encode/decode with 1-byte writes to verify the encoder accumulator is correctly reset between blocks. + */ + @Test + void testStreamingEncodeDecodeOneByteChunks() throws IOException { + final byte[] input = "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (Base45OutputStream out = new Base45OutputStream(baos)) { + for (final byte element : input) { + out.write(element); + } + } + compare(input, baos); + } + + /** + * Tests the encoding/decoding of two-byte pairs that produce all three encoded characters at the extremes of the Base45 alphabet (e.g., '0' and ':'). + */ + @Test + void testTwoByteExtremeValues() { + final Base45 codec = new Base45(); + // Value 0: n=0 -> "000" + assertArrayEquals(new byte[] { 0, 0 }, codec.decode("000")); + assertEquals("000", codec.encodeToString(new byte[] { 0, 0 })); + // Value 65535: n=65535 -> "FGW" (verified: 15 + 16*45 + 32*2025 = 65535) + assertArrayEquals(new byte[] { (byte) 0xFF, (byte) 0xFF }, codec.decode("FGW")); + assertEquals("FGW", codec.encodeToString(new byte[] { (byte) 0xFF, (byte) 0xFF })); + } +}