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.assertNull;
24  import static org.junit.jupiter.api.Assertions.assertThrows;
25  import static org.junit.jupiter.api.Assertions.assertTrue;
26  
27  import java.io.IOException;
28  import java.nio.ByteBuffer;
29  import java.nio.charset.Charset;
30  import java.nio.charset.StandardCharsets;
31  import java.util.Arrays;
32  import java.util.Random;
33  import java.util.concurrent.atomic.AtomicInteger;
34  
35  import org.apache.commons.codec.DecoderException;
36  import org.apache.commons.codec.EncoderException;
37  import org.apache.commons.lang3.ArrayFill;
38  import org.junit.jupiter.api.Test;
39  import org.junit.jupiter.params.ParameterizedTest;
40  import org.junit.jupiter.params.provider.CsvSource;
41  import org.junit.jupiter.params.provider.ValueSource;
42  
43  /**
44   * Tests {@link Base58}.
45   */
46  public class Base58Test {
47  
48      private static final int BOUND = 10_000;
49  
50      private static final String DEFAULT_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
51  
52      private static final Charset CHARSET_UTF8 = StandardCharsets.UTF_8;
53  
54      private static void assertArrayEqualsAt(final byte[] data, final byte[] dec, final int i) {
55          final AtomicInteger counter = new AtomicInteger(i);
56          assertArrayEquals(data, dec, () -> String.format("Failed for length %,d: %s", counter.get(), Arrays.toString(data)));
57      }
58  
59      private static byte[] fromHex(final String hex) {
60          try {
61              return Hex.decodeHex(hex);
62          } catch (final DecoderException e) {
63              throw new AssertionError("Invalid test-vector hex: " + hex, e);
64          }
65      }
66  
67      private static byte[] newEncodeTable() {
68          return DEFAULT_ALPHABET.getBytes(StandardCharsets.US_ASCII);
69      }
70  
71      private static byte[] newSwappedEncodeTable() {
72          final byte[] encodeTable = newEncodeTable();
73          final byte tmp = encodeTable[0];
74          encodeTable[0] = encodeTable[1];
75          encodeTable[1] = tmp;
76          return encodeTable;
77      }
78  
79      private final Random random = new Random();
80  
81      @Test
82      void testBase58() {
83          final String content = "Hello World";
84          final byte[] encodedBytes = new Base58().encode(StringUtils.getBytesUtf8(content));
85          final String encodedContent = StringUtils.newStringUtf8(encodedBytes);
86          assertEquals("JxF12TrwUP45BMd", encodedContent, "encoding hello world");
87          final byte[] decodedBytes = new Base58().decode(encodedBytes);
88          final String decodedContent = StringUtils.newStringUtf8(decodedBytes);
89          assertEquals(content, decodedContent, "decoding hello world");
90      }
91  
92      @Test
93      void testBuilderCustomEncodeTableAffectsEncodeAndDecode() {
94          final Base58 base58 = Base58.builder().setEncodeTable(newSwappedEncodeTable()).get();
95          assertEquals("1", new String(base58.encode(new byte[] { 1 }), StandardCharsets.US_ASCII));
96          assertArrayEquals(new byte[] { 1 }, base58.decode("1".getBytes(StandardCharsets.US_ASCII)));
97      }
98  
99      @Test
100     void testBuilderCustomEncodeTableAffectsIsInAlphabet() {
101         final byte[] encodeTable = newEncodeTable();
102         encodeTable[0] = '0';
103         final Base58 base58 = Base58.builder().setEncodeTable(encodeTable).get();
104         assertTrue(base58.isInAlphabet((byte) '0'));
105         assertFalse(base58.isInAlphabet((byte) '1'));
106         assertEquals("0", new String(base58.encode(new byte[] { 0 }), StandardCharsets.US_ASCII));
107         assertArrayEquals(new byte[] { 0 }, base58.decode("0".getBytes(StandardCharsets.US_ASCII)));
108     }
109 
110     @Test
111     void testBuilderCustomEncodeTableAffectsLeadingZeros() {
112         final Base58 base58 = Base58.builder().setEncodeTable(newSwappedEncodeTable()).get();
113         final byte[] data = { 0, 0, 1 };
114         final byte[] encoded = base58.encode(data);
115         assertEquals("221", new String(encoded, StandardCharsets.US_ASCII));
116         assertArrayEquals(data, base58.decode(encoded));
117     }
118 
119     @Test
120     void testBuilderCustomEncodeTableRejectsDuplicateEntries() {
121         final byte[] encodeTable = newEncodeTable();
122         encodeTable[1] = encodeTable[0];
123         assertThrows(IllegalArgumentException.class, () -> Base58.builder().setEncodeTable(encodeTable));
124     }
125 
126     @Test
127     void testBuilderCustomEncodeTableRejectsInvalidLength() {
128         assertThrows(IllegalArgumentException.class, () -> Base58.builder().setEncodeTable(Arrays.copyOf(newEncodeTable(), DEFAULT_ALPHABET.length() - 1)));
129     }
130 
131     @ParameterizedTest
132     @ValueSource(ints = { 20_000, 40_000, 80_000, 160_000, 320_000 })
133     void testDecodeLargeInput(final int n) {
134         // any valid non-'1' Base58 char
135         Base58.builder().setMaxDecodeLength(n).get().decode(ArrayFill.fill(new byte[n], (byte) 'z'));
136     }
137 
138     /**
139      * Verifies that characters not in the Base58 alphabet (whitespace, punctuation, excluded
140      * letters) are rejected during decoding.
141      */
142     @ParameterizedTest
143     @ValueSource(strings = { "0", "O", "I", "l", "+", "/", " ", "=", "~" })
144     void testDecodeRejectsNonAlphabetCharacters(final String badChar) {
145         // Wrap in a valid prefix/suffix so only the bad char triggers the error.
146         final byte[] input = ("1" + badChar + "1").getBytes(StandardCharsets.US_ASCII);
147         assertThrows(IllegalArgumentException.class, () -> new Base58().decode(input),
148             "expected rejection of character: " + badChar);
149     }
150 
151     @Test
152     void testEmptyBase58() {
153         byte[] empty = {};
154         byte[] result = new Base58().encode(empty);
155         assertEquals(0, result.length, "empty Base58 encode");
156         assertNull(new Base58().encode(null), "empty Base58 encode");
157         empty = new byte[0];
158         result = new Base58().decode(empty);
159         assertEquals(0, result.length, "empty Base58 decode");
160         assertNull(new Base58().decode((byte[]) null), "empty Base58 decode");
161     }
162 
163     @Test
164     void testEncodeDecode() {
165         for (int i = 1; i < 5; i++) {
166             final byte[] data = new byte[random.nextInt(BOUND) + 1];
167             Arrays.fill(data, (byte) i);
168             final byte[] enc = Base58.builder().setMaxEncodeLength(BOUND).get().encode(data);
169             final byte[] dec = Base58.builder().setMaxDecodeLength(BOUND * 2).get().decode(enc);
170             assertArrayEqualsAt(data, dec, i);
171         }
172     }
173 
174     @Test
175     void testEncodeDecodeRandom() {
176         for (int i = 1; i < 5; i++) {
177             final byte[] data = new byte[random.nextInt(BOUND) + 1];
178             random.nextBytes(data);
179             final byte[] enc = Base58.builder().setMaxEncodeLength(BOUND).get().encode(data);
180             final byte[] dec = Base58.builder().setMaxDecodeLength(BOUND * 2).get().decode(enc);
181             assertArrayEqualsAt(data, dec, i);
182         }
183     }
184 
185     @Test
186     void testEncodeDecodeSmall() {
187         for (int i = 0; i < 12; i++) {
188             final byte[] data = new byte[i];
189             Arrays.fill(data, (byte) i);
190             final byte[] enc = new Base58().encode(data);
191             final byte[] dec = new Base58().decode(enc);
192             assertArrayEqualsAt(data, dec, i);
193         }
194     }
195 
196     @Test
197     void testEncodeDecodeSmallRandom() {
198         for (int i = 0; i < 12; i++) {
199             final byte[] data = new byte[i];
200             random.nextBytes(data);
201             final byte[] enc = new Base58().encode(data);
202             final byte[] dec = new Base58().decode(enc);
203             assertArrayEqualsAt(data, dec, i);
204         }
205     }
206 
207     @Test
208     void testEncodedLength() {
209         final Base58 codec = new Base58();
210         assertEquals(0, codec.getEncodedLength(new byte[0]));
211         assertEquals(1, codec.getEncodedLength(new byte[] { 0 }));
212         assertEquals(3, codec.getEncodedLength(new byte[] { 0, 0, 0 }));
213         assertEquals(1, codec.getEncodedLength(new byte[] { 57 }));
214         assertEquals(2, codec.getEncodedLength(new byte[] { 58 }));
215         assertEquals(3, codec.getEncodedLength(new byte[] { 0, 58 }));
216         final Random random = new Random(58);
217         for (int length = 1; length <= 256; length++) {
218             final byte[] input = new byte[length];
219             random.nextBytes(input);
220             assertEquals(codec.encode(input).length, codec.getEncodedLength(input));
221         }
222     }
223 
224     @Test
225     void testEncodedLengthLimit() {
226         final Base58 codec = Base58.builder().setMaxEncodeLength(10).get();
227         assertEquals(10, codec.getEncodedLength(new byte[10]));
228         assertThrows(IllegalArgumentException.class, () -> codec.getEncodedLength(new byte[11]));
229         assertEquals(11, Base58.builder().setMaxEncodeLength(11).get().getEncodedLength(new byte[11]));
230     }
231 
232     @Test
233     void testHexEncoding() {
234         final String hexString = "48656c6c6f20576f726c6421";
235         final byte[] encoded = new Base58().encode(StringUtils.getBytesUtf8(hexString));
236         final byte[] decoded = new Base58().decode(StringUtils.newStringUtf8(encoded));
237         assertEquals("5m7UdtXCfQxGvX2K9dLrkNs7AFMS98qn8", StringUtils.newStringUtf8(encoded), "Hex encoding failed");
238         assertEquals(hexString, StringUtils.newStringUtf8(decoded), "Hex decoding failed");
239     }
240 
241     /**
242      * Tests encode and decode against every test vector in the IETF Base58 encoding draft
243      * (draft-msporny-base58-03, Appendix A). The hex column is the raw binary input; the second
244      * column is the expected Base58 output.
245      *
246      * @see <a href="https://datatracker.ietf.org/doc/html/draft-msporny-base58-03#appendix-A">
247      *      draft-msporny-base58-03 Appendix A</a>
248      */
249     @ParameterizedTest(name = "[{index}] hex={0}")
250     @CsvSource({
251         // single byte 'a' (0x61)
252         "61,                                                                                     2g",
253         // "bbb"
254         "626262,                                                                                 a3gV",
255         // "ccc"
256         "636363,                                                                                 aPEr",
257         // "simply a long string"
258         "73696d706c792061206c6f6e6720737472696e67,                                               2cFupjhnEsSn59qHXstmK2ffpLv2",
259         // leading zero byte + random payload (produces leading '1')
260         // 25-byte Bitcoin address payload (version + RIPEMD160 hash + checksum) from Bitcoin wiki
261         "00010966776006953d5567439e5e39f86a0d273beed61967f6,                                     16UwLL9Risc3QfPqBUvKofHmBQ7wMtjvM",
262         "516b6fcd0f,                                                                             ABnLTmg",
263         "bf4f89001e670274dd,                                                                     3SEo3LWLoPntC",
264         "572e4794,                                                                               3EFU7m",
265         "ecac89cad93923c02321,                                                                   EJDM8drfXA6uyA",
266         "10c8511e,                                                                               Rt5zm",
267         // ten zero bytes -> ten '1' characters
268         "00000000000000000000,                                                                   1111111111",
269         // 43-byte payload whose Base58 encoding is exactly the full alphabet in order
270         "000111d38e5fc9071ffcd20b4a763cc9ae4f252bb4e48fd66a835e252ada93ff480d6dd43dc62a641155a5, 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
271     })
272     void testIetfDraftBase58Vectors(final String hex, final String expectedBase58) {
273         final byte[] binary = fromHex(hex.trim());
274         final String base58 = expectedBase58.trim();
275         final byte[] base58Bytes = base58.getBytes(StandardCharsets.US_ASCII);
276         assertArrayEquals(base58Bytes, new Base58().encode(binary), "encode failed for hex=" + hex.trim());
277         assertArrayEquals(binary, new Base58().decode(base58Bytes), "decode failed for base58=" + base58);
278     }
279 
280     @Test
281     void testInvalidCharacters() {
282         // Test decoding with invalid characters (those not in Base58 alphabet)
283         final byte[] invalidChars = "0OIl".getBytes(CHARSET_UTF8); // All excluded from Base58
284         assertThrows(IllegalArgumentException.class, () -> new Base58().decode(invalidChars));
285     }
286 
287     @Test
288     void testIsInAlphabet() {
289         final Base58 base58 = new Base58();
290         // Valid characters
291         for (char c = '1'; c <= '9'; c++) {
292             assertTrue(base58.isInAlphabet((byte) c), "char " + c);
293         }
294         for (char c = 'A'; c <= 'H'; c++) {
295             assertTrue(base58.isInAlphabet((byte) c), "char " + c);
296         }
297         for (char c = 'J'; c <= 'N'; c++) {
298             assertTrue(base58.isInAlphabet((byte) c), "char " + c);
299         }
300         for (char c = 'P'; c <= 'Z'; c++) {
301             assertTrue(base58.isInAlphabet((byte) c), "char " + c);
302         }
303         for (char c = 'a'; c <= 'k'; c++) {
304             assertTrue(base58.isInAlphabet((byte) c), "char " + c);
305         }
306         for (char c = 'm'; c <= 'z'; c++) {
307             assertTrue(base58.isInAlphabet((byte) c), "char " + c);
308         }
309         // Invalid characters - excluded from Base58
310         assertFalse(base58.isInAlphabet((byte) '0'), "char 0");
311         assertFalse(base58.isInAlphabet((byte) 'O'), "char O");
312         assertFalse(base58.isInAlphabet((byte) 'I'), "char I");
313         assertFalse(base58.isInAlphabet((byte) 'l'), "char l");
314         // Out of bounds
315         assertFalse(base58.isInAlphabet((byte) -1));
316         assertFalse(base58.isInAlphabet((byte) 0));
317         assertFalse(base58.isInAlphabet((byte) 128));
318         assertFalse(base58.isInAlphabet((byte) 255));
319     }
320 
321     /**
322      * Verifies that the number of leading {@code '1'} characters in the encoded output exactly
323      * equals the number of leading zero bytes in the input, for a range of counts.
324      */
325     @ParameterizedTest
326     @ValueSource(ints = { 0, 1, 2, 3, 5, 10 })
327     void testLeadingZeroByteCountPreserved(final int zeros) {
328         // Append a non-zero tail so the total value is non-trivial.
329         final byte[] data = new byte[zeros + 3];
330         data[zeros]     = 0x01;
331         data[zeros + 1] = 0x02;
332         data[zeros + 2] = 0x03;
333         final byte[] encoded = new Base58().encode(data);
334         int leadingOnes = 0;
335         for (final byte b : encoded) {
336             if (b != '1') {
337                 break;
338             }
339             leadingOnes++;
340         }
341         assertEquals(zeros, leadingOnes, "leading '1' count must equal leading zero-byte count");
342         assertArrayEquals(data, new Base58().decode(encoded), "round-trip must preserve leading zeros");
343     }
344 
345     @Test
346     void testLeadingZeros() {
347         // Test that leading zero bytes are encoded as '1' characters
348         final byte[] input = { 0, 0, 1, 2, 3 };
349         final byte[] encoded = new Base58().encode(input);
350         final String encodedStr = new String(encoded);
351         // Should start with "11" (two leading ones for two leading zeros)
352         assertTrue(encodedStr.startsWith("11"), "Leading zeros should encode as '1' characters");
353         // Decode should restore the leading zeros
354         final byte[] decoded = new Base58().decode(encoded);
355         assertArrayEquals(input, decoded, "Decoded should match original including leading zeros");
356     }
357 
358     @Test
359     void testLineLength() {
360         assertThrows(IllegalArgumentException.class, () -> Base58.builder().setLineLength(76));
361         assertThrows(IllegalArgumentException.class, () -> Base58.builder().setLineSeparator(new byte[0]).setLineLength(1));
362         for (final int length : new int[] { 0, -1 }) {
363             final Base58 codec = Base58.builder().setLineLength(length).get();
364             assertArrayEquals(new byte[] { '2', '1' }, codec.encode(new byte[] { 58 }));
365             assertEquals(2, codec.getEncodedLength(new byte[] { 58 }));
366         }
367     }
368 
369     @Test
370     void testObjectDecodeWithInvalidParameter() {
371         assertThrows(DecoderException.class, () -> new Base58().decode(Integer.valueOf(5)));
372     }
373 
374     @Test
375     void testObjectDecodeWithValidParameter() throws Exception {
376         final String original = "Hello World!";
377         final Object o = new Base58().encode(original.getBytes(CHARSET_UTF8));
378         final Base58 base58 = new Base58();
379         final Object oDecoded = base58.decode(o);
380         final byte[] baDecoded = (byte[]) oDecoded;
381         final String dest = new String(baDecoded);
382         assertEquals(original, dest, "dest string does not equal original");
383     }
384 
385     @Test
386     void testObjectEncodeWithInvalidParameter() {
387         assertThrows(EncoderException.class, () -> new Base58().encode("Yadayadayada"));
388     }
389 
390     @Test
391     void testObjectEncodeWithValidParameter() throws Exception {
392         final String original = "Hello World!";
393         final Object origObj = original.getBytes(CHARSET_UTF8);
394         final Object oEncoded = new Base58().encode(origObj);
395         final byte[] bArray = new Base58().decode((byte[]) oEncoded);
396         final String dest = new String(bArray);
397         assertEquals(original, dest, "dest string does not equal original");
398     }
399 
400     @Test
401     void testRoundTrip() {
402         final String[] testStrings = { "", "a", "ab", "abc", "abcd", "abcde", "abcdef", "Hello World", "The quick brown fox jumps over the lazy dog",
403                 "1234567890", "!@#$%^&*()" };
404         for (final String test : testStrings) {
405             final byte[] input = test.getBytes(CHARSET_UTF8);
406             final byte[] encoded = new Base58().encode(input);
407             final byte[] decoded = new Base58().decode(encoded);
408             assertArrayEquals(input, decoded, "Round trip failed for: " + test);
409         }
410     }
411 
412     /**
413      * Round-trips arrays of all-{@code 0xFF} bytes (maximum unsigned byte value) to confirm
414      * that bytes with the high bit set are handled correctly.
415      */
416     @ParameterizedTest
417     @ValueSource(ints = { 1, 2, 3, 4, 8, 16 })
418     void testRoundTripAllMaxBytes(final int len) {
419         final byte[] data = new byte[len];
420         Arrays.fill(data, (byte) 0xFF);
421         assertArrayEquals(data, new Base58().decode(new Base58().encode(data)),
422             "round-trip failed for " + len + " x 0xFF bytes");
423     }
424 
425     @ParameterizedTest
426     @ValueSource(ints = { 0, 1, 2, 3, 4 })
427     void testRoundtripByte0(final int len) throws IOException {
428         // Sanity check, each step from scratch:
429         final byte[] zeros = new byte[len];
430         final byte[] encoded0s = ArrayFill.fill(zeros.clone(), (byte) '1');
431         assertArrayEquals(encoded0s, Base58.builder().get().encode(zeros));
432         final byte[] decoded = Base58.builder().get().decode(encoded0s);
433         assertArrayEquals(zeros, decoded, () -> String.format("zeros=%s, decoded=%s", Arrays.toString(zeros), Arrays.toString(decoded)));
434     }
435 
436     @Test
437     void testSingleBytes() {
438         // Test encoding of single bytes
439         for (int i = 1; i <= 255; i++) {
440             final byte[] data = { (byte) i };
441             final byte[] enc = new Base58().encode(data);
442             final byte[] dec = new Base58().decode(enc);
443             assertArrayEquals(data, dec, "Failed for byte value: " + i);
444         }
445     }
446 
447     /**
448      * Encodes a single zero byte; it must produce the single character {@code '1'}, and the
449      * round-trip must restore {@code [0x00]}.  This byte value is excluded from {@link #testSingleBytes()}
450      * which starts at {@code 1}.
451      */
452     @Test
453     void testSingleByteZero() {
454         final byte[] data = { 0 };
455         final byte[] encoded = new Base58().encode(data);
456         assertArrayEquals(new byte[] { '1' }, encoded, "single zero byte must encode as '1'");
457         assertArrayEquals(data, new Base58().decode(encoded), "round-trip of single zero byte");
458     }
459 
460     @Test
461     void testTestVectors() {
462         final String content = "Hello World!";
463         final String content1 = "The quick brown fox jumps over the lazy dog.";
464         final long content2 = 0x0000287fb4cdL; // Use long to preserve the full 48-bit value
465         final byte[] encodedBytes = new Base58().encode(StringUtils.getBytesUtf8(content));
466         final byte[] encodedBytes1 = new Base58().encode(StringUtils.getBytesUtf8(content1));
467         final byte[] content2Bytes = ByteBuffer.allocate(8).putLong(content2).array();
468         final byte[] content2Trimmed = new byte[6];
469         System.arraycopy(content2Bytes, 2, content2Trimmed, 0, 6);
470         final byte[] encodedBytes2 = new Base58().encode(content2Trimmed);
471         final String encodedContent = StringUtils.newStringUtf8(encodedBytes);
472         final String encodedContent1 = StringUtils.newStringUtf8(encodedBytes1);
473         final String encodedContent2 = StringUtils.newStringUtf8(encodedBytes2);
474         assertEquals("2NEpo7TZRRrLZSi2U", encodedContent, "encoding hello world");
475         assertEquals("USm3fpXnKG5EUBx2ndxBDMPVciP5hGey2Jh4NDv6gmeo1LkMeiKrLJUUBk6Z", encodedContent1);
476         assertEquals("11233QC4", encodedContent2, "encoding 0x0000287fb4cd");
477         final byte[] decodedBytes = new Base58().decode(encodedBytes);
478         final byte[] decodedBytes1 = new Base58().decode(encodedBytes1);
479         final byte[] decodedBytes2 = new Base58().decode(encodedBytes2);
480         final String decodedContent = StringUtils.newStringUtf8(decodedBytes);
481         final String decodedContent1 = StringUtils.newStringUtf8(decodedBytes1);
482         assertEquals(content, decodedContent, "decoding hello world");
483         assertEquals(content1, decodedContent1);
484         assertArrayEquals(content2Trimmed, decodedBytes2, "decoding 0x0000287fb4cd");
485     }
486 }