This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-codec.git
The following commit(s) were added to refs/heads/master by this push:
new f288a1ce Add Base45 support (#442)
f288a1ce is described below
commit f288a1ce41c19570ca79362abdc2bfa1c446263e
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 12 20:10:06 2026 -0500
Add Base45 support (#442)
* Add Base45 support.
* Fix whitespace.
* Update note on closing the Base45OutputStream
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
* Fix spelling
---------
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../org/apache/commons/codec/binary/Base45.java | 476 +++++++++++
.../commons/codec/binary/Base45InputStream.java | 75 ++
.../commons/codec/binary/Base45OutputStream.java | 89 ++
.../codec/binary/Base45InputStreamTest.java | 341 ++++++++
.../codec/binary/Base45OutputStreamTest.java | 179 ++++
.../apache/commons/codec/binary/Base45Test.java | 944 +++++++++++++++++++++
6 files changed, 2104 insertions(+)
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 00000000..49fe6d44
--- /dev/null
+++ b/src/main/java/org/apache/commons/codec/binary/Base45.java
@@ -0,0 +1,476 @@
+/*
+ * 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 <a
href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285</a>.
+ * <p>
+ * 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.
+ * </p>
+ * <p>
+ * The Base45 alphabet consists of 45 characters:
+ * </p>
+ *
+ * <pre>
+ * 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
+ * </pre>
+ *
+ * <h2>Encoding</h2>
+ * <p>
+ * 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.
+ * </p>
+ * <ul>
+ * <li>For each 2-byte pair {@code (b0, b1)}: {@code n = b0 * 256 + b1};
output 3 characters {@code alphabet[n % 45]}, {@code alphabet[(n / 45) % 45]},
+ * {@code alphabet[n / 2025]}</li>
+ * <li>For a final single byte {@code b0}: {@code n = b0}; output 2 characters
{@code alphabet[n % 45]}, {@code alphabet[n / 45]}</li>
+ * </ul>
+ * <h2>Decoding</h2>
+ * <p>
+ * 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.
+ * </p>
+ * <p>
+ * This class is thread-safe.
+ * </p>
+ * <p>
+ * To create an instance, use the default constructor or the builder:
+ * </p>
+ *
+ * <pre>
+ * Base45 codec = new Base45();
+ *
+ * // Or, use the builder to customize the encode table:
+ * Base45 custom = Base45.builder().setEncodeTable(...).get();
+ * </pre>
+ *
+ * @see <a href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285 – The
Base45 Data Encoding</a>
+ * @since 1.23.0
+ */
+public class Base45 extends BaseNCodec {
+
+ /**
+ * Builds {@link Base45} instances.
+ * <p>
+ * To configure a new instance, use a {@link Builder}. For example:
+ * </p>
+ *
+ * <pre>
+ *
+ * Base45 base45 = Base45.builder().get();
+ * </pre>
+ *
+ * @since 1.23.0
+ */
+ public static class Builder extends AbstractBuilder<Base45, Builder> {
+
+ /**
+ * 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 Base45 values (0–44) to their ASCII character
equivalents.
+ * <p>
+ * As specified in <a
href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285</a>: {@code 0-9,
A-Z, Space, $, %, *, +, -, ., /, :}
+ * </p>
+ */
+ // @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.
+ * <p>
+ * 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.
+ * </p>
+ * <p>
+ * Input characters not in the Base45 alphabet, including CR, LF, and TAB,
cause an {@link IllegalArgumentException}. Space {@code ' '} is part of the
+ * Base45 alphabet and is decoded as data.
+ * </p>
+ *
+ * @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;
+ if (b >= decodeTable.length || decodeTable[b] < 0) {
+ 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.
+ * <p>
+ * 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.
+ * </p>
+ * <p>
+ * 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.
+ * </p>
+ *
+ * @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.
+ * <p>
+ * The formula is: {@code (n / 2) * 3 + (n % 2 != 0 ? 2 : 0)}, where
{@code n} is the number of unencoded bytes.
+ * </p>
+ *
+ * @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.
+ * <p>
+ * Unlike the {@link BaseNCodec} implementation, the pad character is
<em>not</em> considered valid, because Base45 (RFC 9285) has no padding.
+ * </p>
+ *
+ * @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 00000000..5b3c3990
--- /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 <a href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285
Base45</a> decoding in a streaming fashion (unlimited size).
+ *
+ * @see Base45
+ * @see <a href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285 – The
Base45 Data Encoding</a>
+ * @since 1.23.0
+ */
+public class Base45InputStream extends BaseNCodecInputStream<Base45,
Base45InputStream, Base45InputStream.Builder> {
+
+ /**
+ * Builds instances of Base45InputStream.
+ */
+ public static class Builder extends
BaseNCodecInputStream.AbstracBuilder<Base45InputStream, Base45, Builder> {
+
+ /**
+ * 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 00000000..638e9280
--- /dev/null
+++ b/src/main/java/org/apache/commons/codec/binary/Base45OutputStream.java
@@ -0,0 +1,89 @@
+/*
+ * 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 <a href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285
Base45</a> encoding in a streaming fashion (unlimited size).
+ * <p>
+ * <strong>Note:</strong> Call {@link #close()} or {@link #eof()} after the
last byte has been written; otherwise the final
+ * partial block will be omitted and the resulting data will be incomplete.
+ * </p>
+ *
+ * @see Base45
+ * @see <a href="https://datatracker.ietf.org/doc/html/rfc9285">RFC 9285 – The
Base45 Data Encoding</a>
+ * @since 1.23.0
+ */
+public class Base45OutputStream extends BaseNCodecOutputStream<Base45,
Base45OutputStream, Base45OutputStream.Builder> {
+
+ /**
+ * Builds instances of Base45OutputStream.
+ */
+ public static class Builder extends
BaseNCodecOutputStream.AbstractBuilder<Base45OutputStream, Base45, Builder> {
+
+ /**
+ * 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 00000000..da3b4a0c
--- /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 chunkSize) throws
Exception {
+ final byte[] emptyEncoded = {};
+ final byte[] emptyDecoded = {};
+ testByChunk(emptyEncoded, emptyDecoded, chunkSize, CRLF);
+ testByteByByte(emptyEncoded, emptyDecoded, chunkSize, CRLF);
+ }
+
+ /**
+ * Tests the Base45InputStream implementation against empty input.
+ *
+ * @throws Exception for some failure scenarios.
+ */
+ @Test
+ void testBase45EmptyInputStreamMimeChunkSize() throws Exception {
+ testBase45EmptyInputStream(BaseNCodec.MIME_CHUNK_SIZE);
+ }
+
+ /**
+ * Tests the Base45InputStream implementation against empty input.
+ *
+ * @throws Exception for some failure scenarios.
+ */
+ @Test
+ void testBase45EmptyInputStreamPemChunkSize() 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
+ * <p/>
+ * 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
+ * <p/>
+ * 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 00000000..97a7eb82
--- /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 00000000..9348c5b0
--- /dev/null
+++ b/src/test/java/org/apache/commons/codec/binary/Base45Test.java
@@ -0,0 +1,944 @@
+/*
+ * 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 <a
href="https://www.rfc-editor.org/rfc/rfc9285">RFC 9285</a>.
+ * <p>
+ * Test vectors are taken from RFC 9285 Section 4.4 and independently verified.
+ * </p>
+ */
+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
+ 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 non-alphabet whitespace is rejected, including within groups
and at the start and end of the input.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = { "\r", "\n", "\t", "\r\n", "\u000B", "\f",
"\u001C", "\u001D", "\u001E", "\u001F" })
+ void testDecodeRejectsNonAlphabetWhitespace(final String whitespace) {
+ final Base45 codec = new Base45();
+ assertThrows(IllegalArgumentException.class, () ->
codec.decode(whitespace));
+ final String encoded = "QED8WEX0";
+ for (int i = 0; i <= encoded.length(); i++) {
+ final String input = encoded.substring(0, i) + whitespace +
encoded.substring(i);
+ assertThrows(IllegalArgumentException.class, () ->
codec.decode(input));
+ assertThrows(IllegalArgumentException.class, () ->
codec.decode(input.getBytes(StandardCharsets.US_ASCII)));
+ }
+ }
+
+ /**
+ * 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");
+ assertArrayEquals("Hello!!".getBytes(StandardCharsets.US_ASCII),
codec.decode("%69 VD92EX0"));
+ // 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.
+ * <p>
+ * 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.
+ * </p>
+ */
+ @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.
+ * <p>
+ * Regression guard for the {@code Base45#encode} work-area reset.
+ * </p>
+ */
+ @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. Decoding still rejects non-alphabet
whitespace.
+ */
+ @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".
+ * <p>
+ * Manually verified:
+ * <ul>
+ * <li>Group [i=105, e=101]: n=26981; c=26 (Q), d=14 (E), e=13 (D) ->
"QED"</li>
+ * <li>Group [t=116, f=102]: n=29798; c=8 (8), d=32 (W), e=14 (E) ->
"8WE"</li>
+ * <li>Tail [!=33]: n=33; c=33 (X), d=0 (0) -> "X0"</li>
+ * </ul>
+ */
+ @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".
+ * <p>
+ * Manually verified:
+ * <ul>
+ * <li>Group [b=98, a=97]: n=25185; c=30 (U), d=19 (J), e=12 (C) ->
"UJC"</li>
+ * <li>Group [s=115, e=101]: n=29541; c=21 (L), d=26 (Q), e=14 (E) ->
"LQE"</li>
+ * <li>Group [-=45, 4=52]: n=11572; c=7 (7), d=32 (W), e=5 (5) ->
"7W5"</li>
+ * <li>Tail [5=53]: n=53; c=8 (8), d=1 (1) -> "81"</li>
+ * </ul>
+ */
+ @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 }));
+ }
+}