diff --git a/README.md b/README.md index 0aac54d..5a84b10 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ import the ones you need yourself. | `base64`, `base64pad`, `base64url`, `base64urlpad` | `multiformats/bases/base64` | [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) | | `base58btc`, `base58flick4` | `multiformats/bases/base58` | [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) | -Other (less useful) bases implemented in [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) include: `base2`, `base8`, `base10`, `base36` and `base256emoji`. +Other (less useful) bases implemented in [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) include: `base2`, `base8`, `base10`, `base36`, `base45` and `base256emoji`. ### Multihash hashers diff --git a/package.json b/package.json index c1543af..238f307 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,11 @@ "import": "./dist/src/bases/base36.js", "module-sync": "./dist/src/bases/base36.js" }, + "./bases/base45": { + "types": "./dist/src/bases/base45.d.ts", + "import": "./dist/src/bases/base45.js", + "module-sync": "./dist/src/bases/base45.js" + }, "./bases/base58": { "types": "./dist/src/bases/base58.d.ts", "import": "./dist/src/bases/base58.js", diff --git a/src/bases/base45.ts b/src/bases/base45.ts new file mode 100644 index 0000000..b1682c8 --- /dev/null +++ b/src/bases/base45.ts @@ -0,0 +1,61 @@ +import { from } from './base.ts' + +const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' +const INVALID = 0xff +const decodeTable = (() => { + const table = new Uint8Array(256).fill(INVALID) + for (let i = 0; i < alphabet.length; i++) { + table[alphabet.charCodeAt(i)] = i + } + return table +})() + +function decodeChar (input: string, i: number): number { + const v = decodeTable[input.charCodeAt(i)] + if (v === INVALID) { + throw new Error(`Non-base45 character: ${input[i]}`) + } + return v +} + +export const base45 = from({ + name: 'base45', + prefix: 'R', + encode: (input: Uint8Array): string => { + let ret = '' + for (let i = 0; i < input.length; i += 2) { + if (i + 1 === input.length) { + const v = input[i] + ret += alphabet[v % 45] + alphabet[(v / 45) | 0] + break + } + const v = (input[i] << 8) | input[i + 1] + ret += alphabet[v % 45] + alphabet[((v / 45) | 0) % 45] + alphabet[(v / 2025) | 0] + } + return ret + }, + decode: (input: string): Uint8Array => { + if ((input.length * 2) % 3 === 2) { + throw new Error('Unexpected end of data') + } + const out = new Uint8Array(((input.length * 2) / 3) | 0) + let o = 0 + for (let i = 0; i < input.length; i += 3) { + if (i + 2 === input.length) { + const v = decodeChar(input, i) + decodeChar(input, i + 1) * 45 + if (v > 0xff) { + throw new Error('Invalid base45 encoding: trailing chunk out of range') + } + out[o++] = v + break + } + const v = decodeChar(input, i) + decodeChar(input, i + 1) * 45 + decodeChar(input, i + 2) * 2025 + if (v > 0xffff) { + throw new Error('Invalid base45 encoding: chunk out of range') + } + out[o++] = v >> 8 + out[o++] = v & 0xff + } + return out + } +}) diff --git a/src/basics.ts b/src/basics.ts index d4c2a55..f63d4b9 100644 --- a/src/basics.ts +++ b/src/basics.ts @@ -4,6 +4,7 @@ import * as base2 from './bases/base2.ts' import * as base256emoji from './bases/base256emoji.ts' import * as base32 from './bases/base32.ts' import * as base36 from './bases/base36.ts' +import * as base45 from './bases/base45.ts' import * as base58 from './bases/base58.ts' import * as base64 from './bases/base64.ts' import * as base8 from './bases/base8.ts' @@ -14,7 +15,7 @@ import * as identity from './hashes/identity.ts' import * as sha2 from './hashes/sha2.ts' import { CID, hasher, digest, varint, bytes } from './index.ts' -export const bases = { ...identityBase, ...base2, ...base8, ...base10, ...base16, ...base32, ...base36, ...base58, ...base64, ...base256emoji } +export const bases = { ...identityBase, ...base2, ...base8, ...base10, ...base16, ...base32, ...base36, ...base45, ...base58, ...base64, ...base256emoji } export const hashes = { ...sha2, ...identity } export const codecs = { raw, json } diff --git a/src/index.ts b/src/index.ts index 494f6a1..71acb0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -176,7 +176,7 @@ * | `base64`, `base64pad`, `base64url`, `base64urlpad` | `multiformats/bases/base64` | [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) | * | `base58btc`, `base58flick4` | `multiformats/bases/base58` | [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) | * - * Other (less useful) bases implemented in [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) include: `base2`, `base8`, `base10`, `base36` and `base256emoji`. + * Other (less useful) bases implemented in [multiformats/js-multiformats](https://github.com/multiformats/js-multiformats/tree/master/bases) include: `base2`, `base8`, `base10`, `base36`, `base45` and `base256emoji`. * * ### Multihash hashers * diff --git a/test/test-multibase-spec.spec.ts b/test/test-multibase-spec.spec.ts index 63d5acf..047e0b3 100644 --- a/test/test-multibase-spec.spec.ts +++ b/test/test-multibase-spec.spec.ts @@ -23,6 +23,7 @@ const encoded = [ ['base32z', 'het1sg3mqqt3gn5djxj11y3msci3817depfzgqejb'], ['base36', 'k343ixo7d49hqj1ium15pgy1wzww5fxrid21td7l'], ['base36upper', 'K343IXO7D49HQJ1IUM15PGY1WZWW5FXRID21TD7L'], + ['base45', 'R4T8KPCG/DVKEXVDDLFD44O/EALEAWEZEDV1DX0'], ['base58flickr', 'Ztwe7gVTeK8wswS1gf8hrgAua9fcw9reboD'], ['base58btc', 'zUXE7GvtEk8XTXs1GF8HSGbVA9FCX9SEBPe'], ['base64', 'mRGVjZW50cmFsaXplIGV2ZXJ5dGhpbmchIQ'], @@ -52,6 +53,7 @@ const encoded = [ ['base32z', 'hxf1zgedpcfzg1ebb'], ['base36', 'k2lcpzo5yikidynfl'], ['base36upper', 'K2LCPZO5YIKIDYNFL'], + ['base45', 'RRFF.OEB$D5/DZ24'], ['base58flickr', 'Z7Pznk19XTTzBtx'], ['base58btc', 'z7paNL19xttacUY'], ['base64', 'meWVzIG1hbmkgIQ'], @@ -81,6 +83,7 @@ const encoded = [ ['base32z', 'hpb1sa5dxrb5s6hucco'], ['base36', 'kfuvrsivvnfrbjwajo'], ['base36upper', 'KFUVRSIVVNFRBJWAJO'], + ['base45', 'R+8D VD82EK4F.KEA2'], ['base58flickr', 'ZrTu1dk6cWsRYjYu'], ['base58btc', 'zStV1DL6CwTryKyV'], ['base64', 'maGVsbG8gd29ybGQ'], @@ -110,6 +113,7 @@ const encoded = [ ['base32z', 'hybhskh3ypiosh4jyrr'], ['base36', 'k02lcpzo5yikidynfl'], ['base36upper', 'K02LCPZO5YIKIDYNFL'], + ['base45', 'RV206$CL44CEC2DDX0'], ['base58flickr', 'Z17Pznk19XTTzBtx'], ['base58btc', 'z17paNL19xttacUY'], ['base64', 'mAHllcyBtYW5pICE'], @@ -139,6 +143,7 @@ const encoded = [ ['base32z', 'hyyy813murbssn5ujryoo'], ['base36', 'k002lcpzo5yikidynfl'], ['base36upper', 'K002LCPZO5YIKIDYNFL'], + ['base45', 'R000RFF.OEB$D5/DZ24'], ['base58flickr', 'Z117Pznk19XTTzBtx'], ['base58btc', 'z117paNL19xttacUY'], ['base64', 'mAAB5ZXMgbWFuaSAh'], @@ -147,7 +152,11 @@ const encoded = [ ['base64urlpad', 'UAAB5ZXMgbWFuaSAh'], ['base256emoji', '🚀🚀🚀🏃✋🌈😅🌷🤤😻🌟😅👏'] ] - } + }, + { input: 'AB', tests: [['base45', 'RBB8']] }, + { input: 'Hello!!', tests: [['base45', 'R%69 VD92EX0']] }, + { input: 'base-45', tests: [['base45', 'RUJCLQE7W581']] }, + { input: 'ietf!', tests: [['base45', 'RQED8WEX0']] } ] describe('spec test', () => { @@ -158,12 +167,12 @@ describe('spec test', () => { const base = bases[name as keyof typeof bases] describe(name, () => { - it('should encode buffer', () => { + it(`should encode from buffer [${input}]`, () => { const out = base.encode(fromString(input)) assert.deepStrictEqual(out, output) }) - it('should decode string', () => { + it(`should decode from string [${input}]`, () => { assert.deepStrictEqual(base.decode(output), fromString(input)) }) }) @@ -180,4 +189,25 @@ describe('spec test', () => { assert.throws(() => base.decode(base.prefix + '^!@$%!#$%@#y'), `Non-${base.name} character`) }) } + + it('base45 should fail with invalid input', () => { + // not enough input chars, should be multiple of 3 or multiple of 3 + 2 + assert.throws(() => bases.base45.decode('R%69 VD92EX'), 'Unexpected end of data') + }) + + // RFC9285 section 6: encoded chunks must lie within the byte ranges they + // represent; 3-char chunks decode to a 16-bit value, 2-char trailers to 8-bit. + for (const input of ['RGGW', 'R:::', 'R000V5', 'R000::']) { + it(`base45 should fail with out-of-range encoding [${input}]`, () => { + assert.throws(() => bases.base45.decode(input), 'Invalid base45 encoding') + }) + } + + // RFC9285 section 6 boundary: "FGW" is 65535 (0xFFFF), the largest valid + // 16-bit value; the contrasting "GGW" (above) is 65536 and must fail. + it('base45 should round-trip the 0xFFFF boundary [FGW]', () => { + const bytes = Uint8Array.from([0xff, 0xff]) + assert.deepStrictEqual(bases.base45.encode(bytes), 'RFGW') + assert.deepStrictEqual(bases.base45.decode('RFGW'), bytes) + }) }) diff --git a/test/test-multibase.spec.ts b/test/test-multibase.spec.ts index 0e00059..130131c 100644 --- a/test/test-multibase.spec.ts +++ b/test/test-multibase.spec.ts @@ -6,6 +6,7 @@ import * as b16 from '../src/bases/base16.ts' import * as b2 from '../src/bases/base2.ts' import * as b32 from '../src/bases/base32.ts' import * as b36 from '../src/bases/base36.ts' +import * as b45 from '../src/bases/base45.ts' import * as b58 from '../src/bases/base58.ts' import * as b64 from '../src/bases/base64.ts' import * as b8 from '../src/bases/base8.ts' @@ -64,7 +65,7 @@ describe('multibase', () => { const buff = bytes.fromString('test') const nonPrintableBuff = Uint8Array.from([239, 250, 254]) - const baseTest = (bases: typeof b2 | typeof b8 | typeof b10 | typeof b16 | typeof b32 | typeof b36 | typeof b58 | typeof b64): void => { + const baseTest = (bases: typeof b2 | typeof b8 | typeof b10 | typeof b16 | typeof b32 | typeof b36 | typeof b45 | typeof b58 | typeof b64): void => { for (const base of Object.values(bases)) { if (((base as { name: string })?.name) !== '') { it(`encode/decode ${base.name}`, () => { @@ -110,6 +111,10 @@ describe('multibase', () => { baseTest(b36) }) + describe('base45', () => { + baseTest(b45) + }) + describe('base58', () => { baseTest(b58) }) diff --git a/typedoc.json b/typedoc.json index 87e4675..688689d 100644 --- a/typedoc.json +++ b/typedoc.json @@ -7,6 +7,7 @@ "./src/bases/base256emoji.ts", "./src/bases/base32.ts", "./src/bases/base36.ts", + "./src/bases/base45.ts", "./src/bases/base58.ts", "./src/bases/base64.ts", "./src/bases/base8.ts",