View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.codec.binary;
19  
20  import static org.junit.jupiter.api.Assertions.assertArrayEquals;
21  import static org.junit.jupiter.api.Assertions.assertEquals;
22  import static org.junit.jupiter.api.Assertions.assertFalse;
23  import static org.junit.jupiter.api.Assertions.assertNotNull;
24  import static org.junit.jupiter.api.Assertions.assertThrows;
25  import static org.junit.jupiter.api.Assertions.assertTrue;
26  
27  import java.io.ByteArrayInputStream;
28  import java.io.ByteArrayOutputStream;
29  import java.io.IOException;
30  import java.nio.charset.StandardCharsets;
31  import java.util.Arrays;
32  import java.util.Random;
33  
34  import org.apache.commons.codec.CodecPolicy;
35  import org.apache.commons.codec.DecoderException;
36  import org.apache.commons.codec.EncoderException;
37  import org.apache.commons.io.IOUtils;
38  import org.junit.jupiter.api.Test;
39  import org.junit.jupiter.params.ParameterizedTest;
40  import org.junit.jupiter.params.provider.ValueSource;
41  
42  /**
43   * Tests {@link Base45} as defined by <a href="https://www.rfc-editor.org/rfc/rfc9285">RFC 9285</a>.
44   * <p>
45   * Test vectors are taken from RFC 9285 Section 4.4 and independently verified.
46   * </p>
47   */
48  class Base45Test {
49  
50      /**
51       * RFC 9285 Section 4.4 official test vectors: {plain-text, base45-encoded}. Verified against the RFC 9285 specification.
52       */
53      // @formatter:off
54      private static final String[][] RFC9285_TEST_VECTORS = {
55          // RFC 9285 Section 4.4 Test Vectors
56          { "",        ""            },  // empty
57          { "ietf!",   "QED8WEX0"    },  // RFC 9285 Test Vector 1
58          { "base-45", "UJCLQE7W581" },  // RFC 9285 Test Vector 2
59          // Independently verified test vectors
60          { "AB",      "BB8"         },  // classic two-byte example
61      };
62      // @formatter:on
63  
64      private void compare(final byte[] input, final ByteArrayOutputStream baos) throws IOException {
65          final byte[] encoded = baos.toByteArray();
66          final ByteArrayInputStream bais = new ByteArrayInputStream(encoded);
67          final ByteArrayOutputStream decodedBaos = new ByteArrayOutputStream();
68          try (Base45InputStream in = new Base45InputStream(bais)) {
69              IOUtils.copy(in, decodedBaos);
70          }
71          assertArrayEquals(input, decodedBaos.toByteArray());
72      }
73  
74      /**
75       * Tests that the builder creates a functional Base45 codec.
76       */
77      @Test
78      void testBuilder() {
79          final Base45 codec = Base45.builder().get();
80          assertNotNull(codec);
81          assertEquals("QED8WEX0", codec.encodeToString("ietf!".getBytes(StandardCharsets.US_ASCII)));
82      }
83  
84      @Test
85      void testBuilderLenientDecodingPolicyThrows() {
86          assertThrows(IllegalArgumentException.class, () -> Base45.builder().setDecodingPolicy(CodecPolicy.LENIENT));
87          assertEquals(CodecPolicy.STRICT, Base45.builder().setDecodingPolicy(null).get().getCodecPolicy());
88      }
89  
90      /**
91       * Tests that {@code setEncodeTable} derives a matching decode table, so a codec with a custom alphabet can always decode its own output.
92       */
93      @Test
94      void testBuilderSetEncodeTableDerivesDecodeTable() {
95          final byte[] custom = new byte[45];
96          int k = 0;
97          for (char c = 'a'; c <= 'z'; c++) {
98              custom[k++] = (byte) c;
99          }
100         for (char c = 'A'; c <= 'I'; c++) {
101             custom[k++] = (byte) c;
102         }
103         for (char c = '0'; c <= '9'; c++) {
104             custom[k++] = (byte) c;
105         }
106         final Base45 codec = Base45.builder().setEncodeTable(custom).get();
107         final byte[] input = "hello world".getBytes(StandardCharsets.US_ASCII);
108         final byte[] encoded = codec.encode(input);
109         for (final byte b : encoded) {
110             assertTrue(codec.isInAlphabet(b), "Encoded byte '" + (char) b + "' must be in the custom alphabet");
111         }
112         assertArrayEquals(input, codec.decode(encoded), "A custom-alphabet codec must be able to decode its own output");
113         // null resets to the default table.
114         assertArrayEquals("QED8WEX0".getBytes(StandardCharsets.US_ASCII),
115                 Base45.builder().setEncodeTable((byte[]) null).get().encode("ietf!".getBytes(StandardCharsets.US_ASCII)));
116     }
117 
118     /**
119      * Tests that {@code setEncodeTable} rejects tables that are not exactly 45 unique entries.
120      */
121     @Test
122     void testBuilderSetEncodeTableRejectsInvalidTable() {
123         assertThrows(IllegalArgumentException.class, () -> Base45.builder().setEncodeTable(new byte[] { 'a', 'b', 'c' }), "wrong size");
124         assertThrows(IllegalArgumentException.class, () -> Base45.builder().setEncodeTable(), "zero entries");
125         final byte[] duplicates = new byte[45];
126         Arrays.fill(duplicates, (byte) 'a');
127         assertThrows(IllegalArgumentException.class, () -> Base45.builder().setEncodeTable(duplicates), "duplicates");
128     }
129 
130     @Test
131     void testBuilderSetLineLength() {
132         assertThrows(UnsupportedOperationException.class, () -> Base45.builder().setLineLength(0));
133     }
134 
135     @Test
136     void testBuilderSetLineSeparator() {
137         assertThrows(UnsupportedOperationException.class, () -> Base45.builder().setLineSeparator((byte) 0));
138     }
139 
140     @Test
141     void testBuilderSetPadding() {
142         assertThrows(UnsupportedOperationException.class, () -> Base45.builder().setPadding((byte) 0));
143     }
144 
145     /**
146      * Tests the builder with strict decoding policy.
147      */
148     @Test
149     void testBuilderStrictDecoding() {
150         final Base45 strict = Base45.builder().setDecodingPolicy(CodecPolicy.STRICT).get();
151         assertTrue(strict.isStrictDecoding());
152         assertTrue(new Base45().isStrictDecoding());
153         // Valid data should still decode successfully
154         assertArrayEquals("ietf!".getBytes(StandardCharsets.US_ASCII), strict.decode("QED8WEX0"));
155     }
156 
157     /**
158      * Tests that strict decoding rejects trailing characters that represent invalid values.
159      */
160     @Test
161     void testBuilderStrictDecodingRejectsHighPair() {
162         final Base45 strict = Base45.builder().setDecodingPolicy(CodecPolicy.STRICT).get();
163         // ':' ':' = 2024 > 255; this should be rejected in both lenient and strict modes
164         assertThrows(IllegalArgumentException.class, () -> strict.decode("::"), "Strict mode: ':' ':' decodes to 2024 > 255, should be rejected");
165     }
166 
167     /**
168      * Tests the codec type constants match RFC 9285 requirements.
169      */
170     @Test
171     void testCodecConstants() {
172         assertEquals(3, Base45.BYTES_PER_ENCODED_BLOCK, "BYTES_PER_ENCODED_BLOCK should be 3 (3 Base45 chars per 2 bytes)");
173         assertEquals(2, Base45.BYTES_PER_UNENCODED_BLOCK, "BYTES_PER_UNENCODED_BLOCK should be 2 (2 bytes per 3 Base45 chars)");
174     }
175 
176     /**
177      * Tests that an empty string decodes to an empty byte array.
178      */
179     @Test
180     void testDecodeEmpty() {
181         final Base45 codec = new Base45();
182         assertArrayEquals(new byte[0], codec.decode(""));
183         assertArrayEquals(new byte[0], codec.decode(new byte[0]));
184     }
185 
186     /**
187      * Tests that invalid characters cause an exception. RFC 9285: "Receivers MUST reject any input string that is not valid Base45 encoding."
188      */
189     @ParameterizedTest
190     @ValueSource(chars = { '!', '"', '#', '&', '\'', '(', ')', ',', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', 'a', 'z' })
191     void testDecodeInvalidCharacters(final char c) {
192         final Base45 codec = new Base45();
193         // Characters not in the Base45 alphabet
194         final String input = "Q" + c + "D";
195         assertThrows(IllegalArgumentException.class, () -> codec.decode(input), () -> "Should reject character '" + c + "' (ASCII " + (int) c + ")");
196     }
197 
198     /**
199      * 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."
200      */
201     @Test
202     void testDecodeInvalidLengthMod3Equals1() {
203         final Base45 codec = new Base45();
204         assertThrows(IllegalArgumentException.class, () -> codec.decode("Q"), "Single character input should be rejected");
205         assertThrows(IllegalArgumentException.class, () -> codec.decode("QEDB"), "4-character input (length % 3 == 1) should be rejected");
206         assertThrows(IllegalArgumentException.class, () -> codec.decode("QEDBWEC"), "7-character input (length % 3 == 1) should be rejected");
207     }
208 
209     /**
210      * Tests decoding of "FGW" -> [0xFF, 0xFF].
211      */
212     @Test
213     void testDecodeMaxMax() {
214         assertArrayEquals(new byte[] { (byte) 0xFF, (byte) 0xFF }, new Base45().decode("FGW"));
215     }
216 
217     /**
218      * Tests that {@link Base45} implements BinaryDecoder correctly via the {@code decode(Object)} method.
219      */
220     @Test
221     void testDecodeObject() throws DecoderException {
222         final Base45 codec = new Base45();
223         final byte[] encoded = "QED8WEX0".getBytes(StandardCharsets.US_ASCII);
224         Object result = codec.decode((Object) encoded);
225         assertArrayEquals("ietf!".getBytes(StandardCharsets.US_ASCII), (byte[]) result);
226         // Also test with String input
227         result = codec.decode((Object) "QED8WEX0");
228         assertArrayEquals("ietf!".getBytes(StandardCharsets.US_ASCII), (byte[]) result);
229     }
230 
231     /**
232      * Tests that decoding an unsupported Object type throws DecoderException.
233      */
234     @Test
235     void testDecodeObjectThrowsForUnsupportedType() {
236         final Base45 codec = new Base45();
237         assertThrows(DecoderException.class, () -> codec.decode(Integer.valueOf(42)));
238     }
239 
240     /**
241      * 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,
242      * some 2-char combos decode to values 256-2024.
243      */
244     @Test
245     void testDecodePairExceedingMaxByte() {
246         final Base45 codec = new Base45();
247         // ':' ':' = 44 + 44*45 = 44 + 1980 = 2024 > 255 -> invalid
248         assertThrows(IllegalArgumentException.class, () -> codec.decode("::"), "Pair ':' ':' decodes to 2024 which exceeds 255 and should be rejected");
249         // ':' '6' = 44 + 6*45 = 44 + 270 = 314 > 255 -> invalid
250         assertThrows(IllegalArgumentException.class, () -> codec.decode(":6"), "Pair ':' '6' decodes to 314 which exceeds 255 and should be rejected");
251     }
252 
253     /**
254      * Tests that non-alphabet whitespace is rejected, including within groups and at the start and end of the input.
255      */
256     @ParameterizedTest
257     @ValueSource(strings = { "\r", "\n", "\t", "\r\n", "\u000B", "\f", "\u001C", "\u001D", "\u001E", "\u001F" })
258     void testDecodeRejectsNonAlphabetWhitespace(final String whitespace) {
259         final Base45 codec = new Base45();
260         assertThrows(IllegalArgumentException.class, () -> codec.decode(whitespace));
261         final String encoded = "QED8WEX0";
262         for (int i = 0; i <= encoded.length(); i++) {
263             final String input = encoded.substring(0, i) + whitespace + encoded.substring(i);
264             assertThrows(IllegalArgumentException.class, () -> codec.decode(input));
265             assertThrows(IllegalArgumentException.class, () -> codec.decode(input.getBytes(StandardCharsets.US_ASCII)));
266         }
267     }
268 
269     /**
270      * Tests the RFC 9285 Section 4.4 decoding test vectors.
271      */
272     @Test
273     void testDecodeRfc9285TestVectors() {
274         final Base45 codec = new Base45();
275         for (final String[] testCase : RFC9285_TEST_VECTORS) {
276             final String plainText = testCase[0];
277             final String encoded = testCase[1];
278             final byte[] expected = plainText.getBytes(StandardCharsets.ISO_8859_1);
279             final byte[] actual = codec.decode(encoded.getBytes(StandardCharsets.US_ASCII));
280             assertArrayEquals(expected, actual, "RFC 9285 decode failed for: '" + encoded + "'");
281         }
282     }
283 
284     /**
285      * Tests decoding of "U5" -> [0xFF] (single byte 255).
286      */
287     @Test
288     void testDecodeSingleMax() {
289         assertArrayEquals(new byte[] { (byte) 0xFF }, new Base45().decode("U5"));
290     }
291 
292     /**
293      * Tests decoding of "00" -> [0x00] (single byte 0).
294      */
295     @Test
296     void testDecodeSingleZero() {
297         assertArrayEquals(new byte[] { 0 }, new Base45().decode("00"));
298     }
299 
300     /**
301      * Tests that spaces (ASCII 32) ARE in the Base45 alphabet and are NOT skipped during decoding. Space has alphabet value 36.
302      */
303     @Test
304     void testDecodeSpaceIsInAlphabet() {
305         final Base45 codec = new Base45();
306         // ' ' has value 36 in Base45 alphabet
307         // Verify space is in alphabet
308         assertTrue(codec.isInAlphabet((byte) ' '), "Space should be in Base45 alphabet");
309         assertArrayEquals("Hello!!".getBytes(StandardCharsets.US_ASCII), codec.decode("%69 VD92EX0"));
310         // Round-trip test for data that encodes to/contains a space
311         final byte[] input = { (byte) 0xF0, (byte) 0xF0 }; // Some value that produces a space in output
312         final byte[] encoded = codec.encode(input);
313         final byte[] decoded = codec.decode(encoded);
314         assertArrayEquals(input, decoded, "Round-trip with space in encoding failed");
315     }
316 
317     /**
318      * Tests decoding via the String overload of {@code decode}.
319      */
320     @Test
321     void testDecodeStringRfc9285TestVectors() {
322         final Base45 codec = new Base45();
323         for (final String[] testCase : RFC9285_TEST_VECTORS) {
324             final String plainText = testCase[0];
325             final String encoded = testCase[1];
326             final byte[] expected = plainText.getBytes(StandardCharsets.ISO_8859_1);
327             final byte[] actual = codec.decode(encoded);
328             assertArrayEquals(expected, actual, () -> "RFC 9285 decode(String) failed for: '" + encoded + "'");
329         }
330     }
331 
332     /**
333      * Tests that the DECODE_TABLE has exactly 128 entries (covering the full ASCII range).
334      */
335     @Test
336     void testDecodeTableLength() {
337         assertEquals(128, Base45.DECODE_TABLE.length);
338     }
339 
340     /**
341      * 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
342      * = 91124, which exceeds 65535.
343      */
344     @Test
345     void testDecodeTripleExceedingMaxValue() {
346         final Base45 codec = new Base45();
347         // ':' ':' ':' = value 44, 44*45, 44*2025 = 44 + 1980 + 89100 = 91124 > 65535
348         // In the alphabet ':' = 44 (last entry)
349         assertThrows(IllegalArgumentException.class, () -> codec.decode(":::"),
350                 "Triple ':' ':' ':' decodes to 91124 which exceeds 65535 and should be rejected");
351     }
352 
353     /**
354      * Tests decoding of "000" -> [0x00, 0x00].
355      */
356     @Test
357     void testDecodeZeroZero() {
358         assertArrayEquals(new byte[] { 0, 0 }, new Base45().decode("000"));
359     }
360 
361     /**
362      * Tests encoding of "AB" -> "BB8". A=65, B=66: n = 65*256+66 = 16706; c=11('B'), d=11('B'), e=8('8') -> "BB8"
363      */
364     @Test
365     void testEncodeAB() {
366         final byte[] input = "AB".getBytes(StandardCharsets.US_ASCII);
367         assertEquals("BB8", new Base45().encodeToString(input));
368         assertArrayEquals(input, new Base45().decode("BB8"));
369     }
370 
371     /**
372      * Tests the {@link Base45#encodeAsString(byte[])} convenience method.
373      */
374     @Test
375     void testEncodeAsString() {
376         final Base45 codec = new Base45();
377         final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII);
378         assertEquals("QED8WEX0", codec.encodeAsString(input));
379         assertEquals(codec.encodeToString(input), codec.encodeAsString(input));
380     }
381 
382     /**
383      * Tests encoding of all possible single-byte values for round-trip correctness.
384      */
385     @Test
386     void testEncodeDecodeSingleByteRoundTrip() {
387         final Base45 codec = new Base45();
388         for (int i = 0; i <= 255; i++) {
389             final byte[] input = { (byte) i };
390             final byte[] encoded = codec.encode(input);
391             assertEquals(2, encoded.length, "Single byte should encode to 2 chars, byte value: " + i);
392             final byte[] decoded = codec.decode(encoded);
393             assertArrayEquals(input, decoded, "Round-trip failed for byte value: " + i);
394         }
395     }
396 
397     /**
398      * Verifies that all 45 ENCODE_TABLE entries are in the DECODE_TABLE with the correct index.
399      */
400     @Test
401     void testEncodeDecodeTableConsistency() {
402         final byte[] encodeTable = Base45.ENCODE_TABLE;
403         final byte[] decodeTable = Base45.DECODE_TABLE;
404         for (int i = 0; i < encodeTable.length; i++) {
405             final int encoded = encodeTable[i] & 0xFF;
406             assertTrue(encoded < decodeTable.length, "Encode table char " + (char) encoded + " at index " + i + " exceeds decode table length");
407             assertEquals(i, decodeTable[encoded], "Decode table mismatch for char '" + (char) encoded + "' at encode index " + i);
408         }
409     }
410 
411     /**
412      * Tests encoding of all possible two-byte values for round-trip correctness. Checks a sample to avoid exhaustive O(65536) iterations being slow.
413      */
414     @Test
415     void testEncodeDecodeTwoByteRoundTrip() {
416         final Base45 codec = new Base45();
417         // Test specific important values
418         final int[] interestingValues = { 0, 1, 44, 45, 254, 255, 256, 2024, 2025, 65534, 65535 };
419         for (final int n : interestingValues) {
420             final byte[] input = { (byte) (n >> 8), (byte) (n & 0xFF) };
421             final byte[] encoded = codec.encode(input);
422             assertEquals(3, encoded.length, "Two bytes should encode to 3 chars, n=" + n);
423             final byte[] decoded = codec.decode(encoded);
424             assertArrayEquals(input, decoded, "Round-trip failed for two-byte value n=" + n);
425         }
426     }
427 
428     /**
429      * Tests that the codec correctly handles inputs where encoded output contains spaces (space = Base45 value 36), ensuring they are preserved through the
430      * encode-decode cycle.
431      */
432     @Test
433     void testEncodeDecodeWithSpaceInOutput() {
434         final Base45 codec = new Base45();
435         // Find byte pairs that encode to include a space (' ' = value 36):
436         // We need n such that n % 45 == 36, or (n/45) % 45 == 36, or n/2025 == 36.
437         // For n % 45 == 36: e.g., n = 36 -> b0=0, b1=36
438         final byte[] input = { 0, 36 }; // n = 36, first char = ' '
439         final byte[] encoded = codec.encode(input);
440         assertTrue(codec.isInAlphabet(encoded[0]), "First encoded char should be in alphabet");
441         assertEquals((byte) ' ', encoded[0], "First encoded char should be space (value 36)");
442         assertArrayEquals(input, codec.decode(encoded));
443     }
444 
445     /**
446      * Tests the relationship between input length and encoded length. Per RFC 9285: encoded_length = (n / 2) * 3 + (n % 2 != 0 ? 2 : 0).
447      */
448     @Test
449     void testEncodedLength() {
450         final Base45 codec = new Base45();
451         final int[] inputLengths = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000 };
452         for (final int n : inputLengths) {
453             final byte[] input = new byte[n];
454             final long expected = (long) n / 2 * 3 + (n % 2 != 0 ? 2 : 0);
455             assertEquals(expected, codec.getEncodedLength(input), "getEncodedLength incorrect for input length " + n);
456         }
457     }
458 
459     /**
460      * Tests that encoded output contains only valid Base45 characters.
461      */
462     @Test
463     void testEncodedOutputIsInAlphabet() {
464         final Base45 codec = new Base45();
465         final Random rng = new Random(42L);
466         for (int len = 0; len <= 100; len++) {
467             final byte[] input = new byte[len];
468             rng.nextBytes(input);
469             final byte[] encoded = codec.encode(input);
470             for (final byte b : encoded) {
471                 assertTrue(codec.isInAlphabet(b), "Encoded byte " + b + " ('" + (char) b + "') is not in Base45 alphabet");
472             }
473         }
474     }
475 
476     /**
477      * Tests that an empty byte array encodes to an empty byte array.
478      */
479     @Test
480     void testEncodeEmpty() {
481         final Base45 codec = new Base45();
482         assertArrayEquals(new byte[0], codec.encode(new byte[0]));
483         assertEquals("", codec.encodeToString(new byte[0]));
484     }
485 
486     /**
487      * Tests that [0xFF, 0xFF] encodes to "FGW". n=65535: c=15('F'), d=16('G'), e=32('W') -> "FGW"
488      */
489     @Test
490     void testEncodeMaxMax() {
491         final byte[] input = { (byte) 0xFF, (byte) 0xFF };
492         assertEquals("FGW", new Base45().encodeToString(input));
493     }
494 
495     /**
496      * Tests that {@link Base45} implements BinaryEncoder correctly via the {@code encode(Object)} method.
497      */
498     @Test
499     void testEncodeObject() throws EncoderException {
500         final Base45 codec = new Base45();
501         final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII);
502         final Object result = codec.encode((Object) input);
503         assertArrayEquals("QED8WEX0".getBytes(StandardCharsets.US_ASCII), (byte[]) result);
504     }
505 
506     /**
507      * Tests that encoding a non-byte-array Object throws EncoderException.
508      */
509     @Test
510     void testEncodeObjectThrowsForNonByteArray() {
511         final Base45 codec = new Base45();
512         assertThrows(EncoderException.class, () -> codec.encode("not a byte array"));
513     }
514 
515     /**
516      * Pins the {@code encode} postcondition that {@code Context#ibitWorkArea} is reset after each complete 2-byte group is emitted.
517      * <p>
518      * Regression guard: without the reset, the work area would hold stale high bits from previous groups and output correctness would depend solely on the
519      * {@code & 0xFFFF}/{@code & 0xFF} output masks.
520      * </p>
521      */
522     @Test
523     void testEncodeResetsWorkAreaAfterCompleteGroup() {
524         final Base45 codec = new Base45();
525         final BaseNCodec.Context context = new BaseNCodec.Context();
526         final byte[] data = { 1, 2, 3, 4, 5, 6 }; // three complete 2-byte groups
527         codec.encode(data, 0, data.length, context);
528         assertEquals(0, context.modulus, "modulus must be 0 after an even number of encoded bytes");
529         assertEquals(0, context.ibitWorkArea, "encode() must reset ibitWorkArea after emitting each complete group, not leave stale accumulator data");
530     }
531 
532     /**
533      * Tests the RFC 9285 Section 4.4 encoding test vectors.
534      */
535     @Test
536     void testEncodeRfc9285TestVectors() {
537         final Base45 codec = new Base45();
538         for (final String[] testCase : RFC9285_TEST_VECTORS) {
539             final String plainText = testCase[0];
540             final String expected = testCase[1];
541             final byte[] input = plainText.getBytes(StandardCharsets.ISO_8859_1);
542             final String actual = codec.encodeToString(input);
543             assertEquals(expected, actual, "RFC 9285 encode failed for: '" + plainText + "'");
544         }
545     }
546 
547     /**
548      * Tests encoding of a single byte with value 65 ('A'). n=65: 65%45=20 ('K'), 65/45=1 ('1') -> "K1"
549      */
550     @Test
551     void testEncodeSingleByteLetterA() {
552         final byte[] input = "A".getBytes(StandardCharsets.US_ASCII); // 'A' = 65
553         assertEquals("K1", new Base45().encodeToString(input));
554         assertArrayEquals(input, new Base45().decode("K1"));
555     }
556 
557     /**
558      * Tests encoding of a single byte with value 1. n=1: 1%45=1 ('1'), 1/45=0 ('0') -> "10"
559      */
560     @Test
561     void testEncodeSingleByteOne() {
562         final byte[] input = { 1 };
563         assertEquals("10", new Base45().encodeToString(input));
564         assertArrayEquals(input, new Base45().decode("10"));
565     }
566 
567     /**
568      * Tests encoding of a single max-value byte (0xFF = 255). n=255: 255%45=30('U'), 255/45=5('5') -> "U5"
569      */
570     @Test
571     void testEncodeSingleMaxByte() {
572         final byte[] input = { (byte) 0xFF };
573         assertEquals("U5", new Base45().encodeToString(input));
574     }
575 
576     /**
577      * Tests encoding of a single zero byte. n=0: c=0('0'), d=0('0') -> "00"
578      */
579     @Test
580     void testEncodeSingleZeroByte() {
581         final byte[] input = { 0 };
582         assertEquals("00", new Base45().encodeToString(input));
583     }
584 
585     /**
586      * Verifies the first 10 entries (digits 0-9) of the ENCODE_TABLE.
587      */
588     @Test
589     void testEncodeTableDigits() {
590         for (int i = 0; i <= 9; i++) {
591             assertEquals((byte) ('0' + i), Base45.ENCODE_TABLE[i], "ENCODE_TABLE[" + i + "] should be digit '" + (char) ('0' + i) + "'");
592         }
593     }
594 
595     /**
596      * Verifies that the ENCODE_TABLE has exactly 45 entries.
597      */
598     @Test
599     void testEncodeTableHas45Entries() {
600         assertEquals(45, Base45.ENCODE_TABLE.length);
601     }
602 
603     /**
604      * Verifies the special-character entries 36-44 of the ENCODE_TABLE.
605      */
606     @Test
607     void testEncodeTableSpecialChars() {
608         assertEquals((byte) ' ', Base45.ENCODE_TABLE[36], "ENCODE_TABLE[36] should be space");
609         assertEquals((byte) '$', Base45.ENCODE_TABLE[37], "ENCODE_TABLE[37] should be '$'");
610         assertEquals((byte) '%', Base45.ENCODE_TABLE[38], "ENCODE_TABLE[38] should be '%'");
611         assertEquals((byte) '*', Base45.ENCODE_TABLE[39], "ENCODE_TABLE[39] should be '*'");
612         assertEquals((byte) '+', Base45.ENCODE_TABLE[40], "ENCODE_TABLE[40] should be '+'");
613         assertEquals((byte) '-', Base45.ENCODE_TABLE[41], "ENCODE_TABLE[41] should be '-'");
614         assertEquals((byte) '.', Base45.ENCODE_TABLE[42], "ENCODE_TABLE[42] should be '.'");
615         assertEquals((byte) '/', Base45.ENCODE_TABLE[43], "ENCODE_TABLE[43] should be '/'");
616         assertEquals((byte) ':', Base45.ENCODE_TABLE[44], "ENCODE_TABLE[44] should be ':'");
617     }
618 
619     /**
620      * Verifies entries 10-35 (uppercase A-Z) of the ENCODE_TABLE.
621      */
622     @Test
623     void testEncodeTableUppercase() {
624         for (int i = 0; i < 26; i++) {
625             assertEquals((byte) ('A' + i), Base45.ENCODE_TABLE[10 + i], "ENCODE_TABLE[" + (10 + i) + "] should be letter '" + (char) ('A' + i) + "'");
626         }
627     }
628 
629     /**
630      * Tests compatibility with the {@code encode(byte[], int, int)} overload.
631      */
632     @Test
633     void testEncodeWithOffsetAndLength() {
634         final Base45 codec = new Base45();
635         final byte[] buffer = new byte[10];
636         // Fill with "ietf!" bytes at offset 2
637         final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII);
638         System.arraycopy(input, 0, buffer, 2, input.length);
639         final byte[] encoded = codec.encode(buffer, 2, 5);
640         assertEquals("QED8WEX0", new String(encoded, StandardCharsets.US_ASCII));
641     }
642 
643     /**
644      * Companion to {@link #testEncodeResetsWorkAreaAfterCompleteGroup}: with a pending (odd) byte, the work area must hold exactly that pending byte, nothing
645      * more.
646      * <p>
647      * Regression guard for the {@code Base45#encode} work-area reset.
648      * </p>
649      */
650     @Test
651     void testEncodeWorkAreaHoldsOnlyPendingByte() {
652         final Base45 codec = new Base45();
653         final BaseNCodec.Context context = new BaseNCodec.Context();
654         final byte[] data = { 1, 2, 3, 4, 99 }; // two complete groups + one pending byte (99)
655         codec.encode(data, 0, data.length, context);
656         assertEquals(1, context.modulus, "one pending byte must leave modulus == 1");
657         assertEquals(99, context.ibitWorkArea, "after two complete groups, ibitWorkArea must hold only the pending byte, not stale high bits");
658     }
659 
660     /**
661      * Tests that two zero bytes encode to "000". n=0: c=0('0'), d=0('0'), e=0('0') -> "000"
662      */
663     @Test
664     void testEncodeZeroZero() {
665         final byte[] input = { 0, 0 };
666         assertEquals("000", new Base45().encodeToString(input));
667     }
668 
669     /**
670      * Tests {@link Base45#getEncodedLength(byte[])} for known input lengths.
671      */
672     @Test
673     void testGetEncodedLength() {
674         final Base45 codec = new Base45();
675         assertEquals(0L, codec.getEncodedLength(new byte[0])); // 0 bytes -> 0 chars
676         assertEquals(2L, codec.getEncodedLength(new byte[1])); // 1 byte -> 2 chars
677         assertEquals(3L, codec.getEncodedLength(new byte[2])); // 2 bytes -> 3 chars
678         assertEquals(5L, codec.getEncodedLength(new byte[3])); // 3 bytes -> 5 chars
679         assertEquals(6L, codec.getEncodedLength(new byte[4])); // 4 bytes -> 6 chars
680         assertEquals(8L, codec.getEncodedLength(new byte[5])); // 5 bytes -> 8 chars
681         assertEquals(9L, codec.getEncodedLength(new byte[6])); // 6 bytes -> 9 chars
682         assertEquals(11L, codec.getEncodedLength(new byte[7])); // 7 bytes -> 11 chars
683     }
684 
685     /**
686      * Tests that the actual encoded length matches the value returned by getEncodedLength.
687      */
688     @Test
689     void testGetEncodedLengthMatchesActual() {
690         final Base45 codec = new Base45();
691         for (int len = 0; len <= 30; len++) {
692             final byte[] input = new byte[len];
693             Arrays.fill(input, (byte) 0xAB);
694             final byte[] encoded = codec.encode(input);
695             assertEquals(codec.getEncodedLength(input), encoded.length, "getEncodedLength disagrees with actual length for input length " + len);
696         }
697     }
698 
699     /**
700      * Tests {@link Base45#isInAlphabet(byte)} for all 45 valid alphabet characters.
701      */
702     @Test
703     void testIsInAlphabet_allValidChars() {
704         final Base45 codec = new Base45();
705         final String alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
706         assertEquals(45, alphabet.length());
707         for (final char c : alphabet.toCharArray()) {
708             assertTrue(codec.isInAlphabet((byte) c), "Character '" + c + "' (ASCII " + (int) c + ") should be in Base45 alphabet");
709         }
710     }
711 
712     /**
713      * Tests {@link Base45#isInAlphabet(byte)} for characters NOT in the Base45 alphabet.
714      */
715     @Test
716     void testIsInAlphabet_invalidChars() {
717         final Base45 codec = new Base45();
718         // Control characters
719         for (int i = 0; i < 32; i++) {
720             if (i != ' ') { // space is at 32
721                 // None of 0-31 are in the alphabet
722                 assertFalse(codec.isInAlphabet((byte) i), "Control char " + i + " should not be in Base45 alphabet");
723             }
724         }
725         // Characters between valid ranges
726         assertFalse(codec.isInAlphabet((byte) '!'), "! should not be in Base45 alphabet");
727         assertFalse(codec.isInAlphabet((byte) '"'), "\" should not be in Base45 alphabet");
728         assertFalse(codec.isInAlphabet((byte) '#'), "# should not be in Base45 alphabet");
729         assertFalse(codec.isInAlphabet((byte) '&'), "& should not be in Base45 alphabet");
730         assertFalse(codec.isInAlphabet((byte) '\''), "' should not be in Base45 alphabet");
731         assertFalse(codec.isInAlphabet((byte) '('), "( should not be in Base45 alphabet");
732         assertFalse(codec.isInAlphabet((byte) ')'), ") should not be in Base45 alphabet");
733         assertFalse(codec.isInAlphabet((byte) ','), ", should not be in Base45 alphabet");
734         assertFalse(codec.isInAlphabet((byte) ';'), "; should not be in Base45 alphabet");
735         assertFalse(codec.isInAlphabet((byte) '<'), "< should not be in Base45 alphabet");
736         assertFalse(codec.isInAlphabet((byte) '='), "= should not be in Base45 alphabet");
737         assertFalse(codec.isInAlphabet((byte) '>'), "> should not be in Base45 alphabet");
738         assertFalse(codec.isInAlphabet((byte) '?'), "? should not be in Base45 alphabet");
739         assertFalse(codec.isInAlphabet((byte) '@'), "@ should not be in Base45 alphabet");
740         assertFalse(codec.isInAlphabet((byte) '['), "[ should not be in Base45 alphabet");
741         assertFalse(codec.isInAlphabet((byte) '\\'), "\\ should not be in Base45 alphabet");
742         assertFalse(codec.isInAlphabet((byte) ']'), "] should not be in Base45 alphabet");
743         assertFalse(codec.isInAlphabet((byte) '^'), "^ should not be in Base45 alphabet");
744         assertFalse(codec.isInAlphabet((byte) '_'), "_ should not be in Base45 alphabet");
745         assertFalse(codec.isInAlphabet((byte) '`'), "` should not be in Base45 alphabet");
746         // lowercase letters
747         for (char c = 'a'; c <= 'z'; c++) {
748             assertFalse(codec.isInAlphabet((byte) c), "Lowercase '" + c + "' should not be in Base45 alphabet");
749         }
750         // High bytes (> 127)
751         assertFalse(codec.isInAlphabet((byte) 0x80), "Byte 0x80 should not be in Base45 alphabet");
752         assertFalse(codec.isInAlphabet((byte) 0xFF), "Byte 0xFF should not be in Base45 alphabet");
753     }
754 
755     /**
756      * 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
757      * {@code allowWhitespacePad} flag. Decoding still rejects non-alphabet whitespace.
758      */
759     @Test
760     void testIsInAlphabetArrayDoesNotAllowPad() {
761         final Base45 codec = new Base45();
762         assertFalse(codec.isInAlphabet("QED="), "'=' (inherited pad) must not be treated as in-alphabet for padding-less Base45");
763         assertFalse(codec.isInAlphabet("QED=".getBytes(StandardCharsets.US_ASCII), true));
764         assertFalse(codec.isInAlphabet("QED=".getBytes(StandardCharsets.US_ASCII), false));
765         assertTrue(codec.isInAlphabet("QED\t8WE".getBytes(StandardCharsets.US_ASCII), true));
766         assertFalse(codec.isInAlphabet("QED\t8WE".getBytes(StandardCharsets.US_ASCII), false));
767         assertTrue(codec.isInAlphabet("QED8WEX0".getBytes(StandardCharsets.US_ASCII), true));
768     }
769 
770     /**
771      * Tests {@link Base45#isInAlphabet(byte[])} for valid and invalid arrays.
772      */
773     @Test
774     void testIsInAlphabetByteArray() {
775         final Base45 codec = new Base45();
776         assertTrue(codec.isInAlphabet("QED8WEX0".getBytes(StandardCharsets.US_ASCII), false));
777         assertTrue(codec.isInAlphabet(new byte[0], false));
778         assertFalse(codec.isInAlphabet("QED!WEX0".getBytes(StandardCharsets.US_ASCII), false));
779         assertFalse(codec.isInAlphabet("abc".getBytes(StandardCharsets.US_ASCII), false));
780     }
781 
782     /**
783      * Tests that long inputs (more than 2 encoding blocks) encode and decode correctly.
784      */
785     @Test
786     void testLongInputRoundTrip() {
787         final Base45 codec = new Base45();
788         final byte[] input = new byte[1000];
789         new Random(99999L).nextBytes(input);
790         final byte[] encoded = codec.encode(input);
791         assertEquals(codec.getEncodedLength(input), encoded.length);
792         assertArrayEquals(input, codec.decode(encoded));
793     }
794 
795     /**
796      * Verifies RFC 9285 Test Vector 1: "ietf!" encodes to "QED8WEX0".
797      * <p>
798      * Manually verified:
799      * <ul>
800      * <li>Group [i=105, e=101]: n=26981; c=26 (Q), d=14 (E), e=13 (D) -> "QED"</li>
801      * <li>Group [t=116, f=102]: n=29798; c=8 (8), d=32 (W), e=14 (E) -> "8WE"</li>
802      * <li>Tail [!=33]: n=33; c=33 (X), d=0 (0) -> "X0"</li>
803      * </ul>
804      */
805     @Test
806     void testRfc9285TestVector1_ietf() {
807         final byte[] input = "ietf!".getBytes(StandardCharsets.US_ASCII);
808         assertEquals("QED8WEX0", new Base45().encodeToString(input));
809         assertArrayEquals(input, new Base45().decode("QED8WEX0"));
810     }
811 
812     /**
813      * Verifies RFC 9285 Test Vector 2: "base-45" encodes to "UJCLQE7W581".
814      * <p>
815      * Manually verified:
816      * <ul>
817      * <li>Group [b=98, a=97]: n=25185; c=30 (U), d=19 (J), e=12 (C) -> "UJC"</li>
818      * <li>Group [s=115, e=101]: n=29541; c=21 (L), d=26 (Q), e=14 (E) -> "LQE"</li>
819      * <li>Group [-=45, 4=52]: n=11572; c=7 (7), d=32 (W), e=5 (5) -> "7W5"</li>
820      * <li>Tail [5=53]: n=53; c=8 (8), d=1 (1) -> "81"</li>
821      * </ul>
822      */
823     @Test
824     void testRfc9285TestVector2_base45() {
825         final byte[] input = "base-45".getBytes(StandardCharsets.US_ASCII);
826         assertEquals("UJCLQE7W581", new Base45().encodeToString(input));
827         assertArrayEquals(input, new Base45().decode("UJCLQE7W581"));
828     }
829 
830     /**
831      * Tests round-trip for binary data with all byte values.
832      */
833     @Test
834     void testRoundTripAllByteValues() {
835         final Base45 codec = new Base45();
836         final byte[] allBytes = new byte[256];
837         for (int i = 0; i < 256; i++) {
838             allBytes[i] = (byte) i;
839         }
840         final byte[] encoded = codec.encode(allBytes);
841         final byte[] decoded = codec.decode(encoded);
842         assertArrayEquals(allBytes, decoded, "Round-trip failed for all byte values");
843     }
844 
845     /**
846      * Tests round-trip encoding/decoding of all byte lengths from 0 to 50.
847      */
848     @Test
849     void testRoundTripAllLengths() {
850         final Base45 codec = new Base45();
851         final Random rng = new Random(12345L);
852         for (int len = 0; len <= 50; len++) {
853             final byte[] input = new byte[len];
854             rng.nextBytes(input);
855             final byte[] encoded = codec.encode(input);
856             final byte[] decoded = codec.decode(encoded);
857             assertArrayEquals(input, decoded, "Round-trip failed for length " + len);
858         }
859     }
860 
861     /**
862      * Tests round-trip for various well-known strings.
863      */
864     @Test
865     void testRoundTripStrings() {
866         final Base45 codec = new Base45();
867         // @formatter:off
868         final String[] inputs = {
869             "Hello, World!",
870             "The quick brown fox jumps over the lazy dog",
871             "Apache Commons Codec",
872             "0123456789",
873             "\u0000\u0001\u0002",
874             "Base45 (RFC 9285)",
875         };
876         // @formatter:on
877         for (final String input : inputs) {
878             final byte[] bytes = input.getBytes(StandardCharsets.UTF_8);
879             final byte[] decoded = codec.decode(codec.encode(bytes));
880             assertArrayEquals(bytes, decoded, () -> "Round-trip failed for: " + input);
881         }
882     }
883 
884     /**
885      * Tests that encoding a single byte and then decoding gives the correct value for boundary cases around multiples of 45.
886      */
887     @Test
888     void testSingleByteAroundBase45Multiples() {
889         final Base45 codec = new Base45();
890         // Test byte values at multiples of 45: 0, 45, 90, 135, 180, 225
891         for (int i = 0; i <= 255; i += 45) {
892             final byte[] input = { (byte) i };
893             final byte[] encoded = codec.encode(input);
894             assertEquals(2, encoded.length, "Encoded length should be 2 for single byte, value=" + i);
895             assertArrayEquals(input, codec.decode(encoded), "Round-trip failed for single byte value=" + i);
896         }
897     }
898 
899     /**
900      * Tests streaming encode/decode with non-aligned chunk sizes to verify the encoder accumulator is correctly reset between blocks.
901      */
902     @Test
903     void testStreamingEncodeDecodeIncremental() throws IOException {
904         final byte[] input = "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8);
905         final ByteArrayOutputStream baos = new ByteArrayOutputStream();
906         try (Base45OutputStream out = new Base45OutputStream(baos)) {
907             // Write in 3-byte chunks, which is not aligned to the 2-byte Base45 block size
908             for (int i = 0; i < input.length; i += 3) {
909                 final int len = Math.min(3, input.length - i);
910                 out.write(input, i, len);
911             }
912         }
913         compare(input, baos);
914     }
915 
916     /**
917      * Tests streaming encode/decode with 1-byte writes to verify the encoder accumulator is correctly reset between blocks.
918      */
919     @Test
920     void testStreamingEncodeDecodeOneByteChunks() throws IOException {
921         final byte[] input = "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8);
922         final ByteArrayOutputStream baos = new ByteArrayOutputStream();
923         try (Base45OutputStream out = new Base45OutputStream(baos)) {
924             for (final byte element : input) {
925                 out.write(element);
926             }
927         }
928         compare(input, baos);
929     }
930 
931     /**
932      * 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 ':').
933      */
934     @Test
935     void testTwoByteExtremeValues() {
936         final Base45 codec = new Base45();
937         // Value 0: n=0 -> "000"
938         assertArrayEquals(new byte[] { 0, 0 }, codec.decode("000"));
939         assertEquals("000", codec.encodeToString(new byte[] { 0, 0 }));
940         // Value 65535: n=65535 -> "FGW" (verified: 15 + 16*45 + 32*2025 = 65535)
941         assertArrayEquals(new byte[] { (byte) 0xFF, (byte) 0xFF }, codec.decode("FGW"));
942         assertEquals("FGW", codec.encodeToString(new byte[] { (byte) 0xFF, (byte) 0xFF }));
943     }
944 }