Skip to content

Commit

Permalink
Added base62p for leading zero byte preservation
Browse files Browse the repository at this point in the history
- node.js.yml: Dropped testing support for v14 and v16, added v18 and v20
- base62p.js: Added
- test/base58.js: Added test proving leading zeros are preserved
- test/base62.js: Added test showing that leading zeros are not preserved
- base62p.js: Added, with tests showing that leading zeros are preserved
- index.js: Export base62p
- package.json: Bump to v3.3.0
- README.md: Updated docs for base62p

Closes #16 - Thanks @Ronan-H !
  • Loading branch information
kfitzgerald committed Jan 19, 2024
1 parent e49520d commit c04ac8f
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [14.x, 16.x]
node-version: [18.x, 20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
# Cannot test on 10.x because the mongo driver only supports v12 and higher

Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ npm install base-id

```js

const { base58, base62 } = require('base-id');
const { base58, base62, base62p } = require('base-id');

// Generate a new crypto-random id with an arbitrary prefix
base58.generateToken(24, 'account_'); // account_ifq8PeVV9J3weEtz5V14cr9H7AuKhndD

// Generate a new crypto-random id with an arbitrary prefix
base62.generateToken(24, 'product_'); // product_8egyAcmiJhK0pFThcYHYIojG9GIKK7A4
base62p.generateToken(24, 'product_'); // product_mKO7RdTgKjHQtkrRMhm6uQAWmJ0hCRaG



Expand All @@ -34,6 +35,7 @@ let res = base58.encode(hex); // 2i6ye84HA6z;

// Encode a hex-string to base62
res = base62.encode(hex); // SnmsvJ1ziv;
res = base62p.encode(hex); // mR3E2wPbQQL;



Expand All @@ -46,7 +48,7 @@ new ObjectId(base58.decodeWithPrefix('charge_2d2yysrPLNBLYpWfK', 'charge_')); //

```

## `base58` and `base62`
## `base58`, `base62`, and `base62p`

The module exports both base58 and base62 instances with the following members.

Expand All @@ -64,13 +66,38 @@ The module exports both base58 and base62 instances with the following members.
* `encodeNumericToHex(dec)` – Encode a number to hex string
* `getHexFromObject(mixed)` Gets the hex string from the given thing. Accepts a hex string, number, BigInt, byte array, or MongoDB `ObjectId` instance.
* `getUUIDFromHex(hex, lowercase=true)` Gets the formatted UUID string from the given hex string. Defaults to lowercase.


## Base62 vs Base62p

There are two versions of base62 included, base62 and base62p, with the difference being that base62 **does not preserve**
leading zero bytes while **base62p does preserve** leading zero bytes by prepending a magic `0x01` byte before encoding.

> **Note: Base62 and Base62p are not interchangeable**. Please use the version that suits your application.
> In a future version, base62p may become the default pending community feedback.
It's also worth noting that base58 preserves leading zero bytes.

> **For new applications using base62, please consider using base62p.**
```js

const { base58, base62, base62p } = require('base-id');

// example 32 byte uuid with all zeros
base58.decode(base58.encode('00000000000000000000000000000000')); // 00000000000000000000000000000000
base62.decode(base62.encode('00000000000000000000000000000000')); // 00
base62p.decode(base62p.encode('00000000000000000000000000000000')); // 00000000000000000000000000000000

```


## Breaking Changes

> In a future major release version, base62 may be replaced with base62p so base62 and base58 by default preserve leading zero bytes.
### v3.0.0
* Removed BigNum dependency (uses JS2020's built-in BigInt)

### v2.0.0
* Removed `binaryToHex`
* Removed `hexToBinary`
* Removed `hexToBinary`
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const Base58 = require('./lib/base58');
const Base62 = require('./lib/base62');
const Base62p = require('./lib/base62p');

exports.base58 = new Base58();
exports.base62 = new Base62();
exports.base62p = new Base62p();
76 changes: 76 additions & 0 deletions lib/base62p.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use strict";

const Base62 = require('./base62');

/**
* Base62 but prepends a magic byte to preserve leading zero bytes
*/
class Base62p extends Base62 {

constructor() {
super();
}

/**
* Encode a hex string or array of bytes
* @param mixed – Hex string or array of bytes
* @returns {string|null} - Base encoded string
*/
encode(mixed) {
let hex = this.getHexFromObject(mixed);
if (!hex) return null;

hex = '01' + hex; // prepend magic byte
hex = this.decodeHexToNumeric(hex);
let output = "";
let rem;
while (hex > 0n) {
rem = hex % 62n;
hex = hex / 62n;
output = Base62p.BASE62_CHARS[rem] + output;
}

return output;
}


/**
* Decode a base-encoded string into a hex string
* @param {string} base62 - Base encoded string
* @returns {string|null} – hex encoded string
*/
decode(base62) {

// only valid chars allowed
if (base62.match(/[^0-9A-Za-z]/) != null) {
return null;
}

let output = BigInt(0);
let current;
for (let i = 0; i < base62.length; i++) {
current = BigInt(Base62p.BASE62_CHARS.indexOf(base62[i]));
output = output * 62n + current;
}

let outputHex = this.encodeNumericToHex(output);

/* istanbul ignore else: shouldn't happen unless garbage input given */
if (outputHex.length % 2 !== 0) {
outputHex = "0" + outputHex;
}

// magic byte required or nothing will return
if (outputHex.substring(0, 2) !== '01') {
return null;
}

// strip magic byte
outputHex = outputHex.substring(2);

//noinspection JSUnresolvedFunction
return outputHex.toUpperCase();
}
}

module.exports = Base62p;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "base-id",
"version": "3.2.0",
"version": "3.3.0",
"description": "Encode, decode, and generate base-58 and base-62 identifiers. Convertible MongoDB ObjectIds!",
"main": "index.js",
"scripts": {
Expand Down
7 changes: 7 additions & 0 deletions test/base58.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,11 @@ describe('Base58', () => {
res.should.be.instanceOf(Uint8Array);
});

it('should encode/decode a uuid with two leading zeroes', async () => {
should(Base.base58.decode(Base.base58.encode('0039d4cd-3923-44ed-a2d5-450776bdfce9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9');
should(Base.base58.decode(Base.base58.encode('0039D4CD392344EDA2D5450776BDFCE9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9');
should(Base.base58.decode(Base.base58.encode('00000000000000000000000000000000'))).be.exactly('00000000000000000000000000000000');
should(Base.base58.decode(Base.base58.encode('00000000000000000000000000000000'))).be.exactly('00000000000000000000000000000000');
});

});
7 changes: 7 additions & 0 deletions test/base62.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,5 +153,12 @@ describe('Base62', () => {
res.should.be.instanceOf(Uint8Array);
});

it('leading zeros are not preserved in base62', async () => {
should(Base.base62.decode(Base.base62.encode('00'))).be.exactly('00');
should(Base.base62.decode(Base.base62.encode('0039d4cd-3923-44ed-a2d5-450776bdfce9'))).be.exactly('39D4CD392344EDA2D5450776BDFCE9');
should(Base.base62.decode(Base.base62.encode('0039D4CD392344EDA2D5450776BDFCE9'))).be.exactly('39D4CD392344EDA2D5450776BDFCE9');
should(Base.base62.decode(Base.base62.encode('00000000000000000000000000000000'))).be.exactly('00');
});


});
166 changes: 166 additions & 0 deletions test/base62p.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"use strict";

const Base = require('../index');
const should = require('should');
const Crypto = require("crypto");
const ObjectId = require('mongodb').ObjectId;

describe('Base62', () => {

it('should return null when crap is given', () => {

should(Base.base62p.encode()).not.be.ok();
should(Base.base62p.encode(null)).not.be.ok();
should(Base.base62p.encode(undefined)).not.be.ok();
should(Base.base62p.encode(() => {})).not.be.ok();
should(Base.base62p.encode({})).not.be.ok();
should(Base.base62p.encode([])).not.be.ok();
});

it('should return null when crap is given with prefix', () => {
should(Base.base62p.encodeWithPrefix()).not.be.ok();
should(Base.base62p.encodeWithPrefix(null, 'prefix_')).not.be.ok();
should(Base.base62p.encodeWithPrefix(undefined, 'prefix_')).not.be.ok();
should(Base.base62p.encodeWithPrefix(() => {}, 'prefix_')).not.be.ok();
should(Base.base62p.encodeWithPrefix({}, 'prefix_')).not.be.ok();
should(Base.base62p.encodeWithPrefix([], 'prefix_')).not.be.ok();

should(Base.base62p.encodeWithPrefix(0, 'prefix_')).equal('prefix_48');
});

it('should return null when decoding garbage', () => {
should(Base.base62p.decode('®')).be.exactly(null);
should(Base.base62p.decode('SnmsvJ1ziv')).be.exactly(null); // base62 not base62p
});

it('should encode number', () => {
should(Base.base62p.encode(0)).equal('48');
should(Base.base62p.encode(1)).equal('49');
should(Base.base62p.encode(10)).equal('4i');
should(Base.base62p.encode(1000000)).equal('1cAFi');
});

it('should decode number', () => {
Base.base62p.decodeHexToNumeric(Base.base62p.decode('48')).should.equal(0n);
Base.base62p.decodeHexToNumeric(Base.base62p.decode('49')).should.equal(1n);
Base.base62p.decodeHexToNumeric(Base.base62p.decode('57')).should.equal(61n);
Base.base62p.decodeHexToNumeric(Base.base62p.decode('4i')).should.equal(10n);
Base.base62p.decodeHexToNumeric(Base.base62p.decode('71ZRN5')).should.equal(BigInt(2147483647));
Base.base62p.decodeHexToNumeric(Base.base62p.decode(Base.base62p.encode('73696d706c792061206c6f6e6720737472696e67'))).toString().should.equal((BigInt('0x73696d706c792061206c6f6e6720737472696e67')).toString());
});

it('should encode big numbers', () => {
const x = BigInt("340282366920938463463374607431768211455");
should(Base.base62p.encode(x)).equal('fA84qwIaXlxujAhzMevpef');
});

it('should encode big numbers', () => {
const x = BigInt("0");
should(Base.base62p.encode(x)).equal('48');
});

it('should encode hex string', () => {
const x = "0a372a50deadbeef";
should(Base.base62p.encode(x)).equal('mR3E2wPbQQL');
});

it('should encode byte array', () => {
let x = [0x0A, 0x37, 0x2A, 0x50, 0xDE, 0xAD, 0xBE, 0xEF];
should(Base.base62p.encode(x)).equal('mR3E2wPbQQL');

x = [0x00, 0x0A, 0x37, 0x2A, 0x50, 0xDE, 0xAD, 0xBE, 0xEF];
should(Base.base62p.encode(x)).equal('1sLqXXb3bu2Kz');
});

it('should encode/decode a uuid', async () => {
// Here is a UUID
const uuid = 'c939d4cd-3923-44ed-a2d5-450776bdfce9';

// Encoding the UUID should return work
should(Base.base62p.encode(uuid)).be.exactly('dULVbFAZK1ptGvuoRTAPTr');

// Decoding the value should return the uppercase hex value
should(Base.base62p.decode('dULVbFAZK1ptGvuoRTAPTr')).be.exactly('C939D4CD392344EDA2D5450776BDFCE9');

// Formatting the hex value as a UUID should match the original string
should(Base.base62p.getUUIDFromHex('C939D4CD392344EDA2D5450776BDFCE9')).be.exactly(uuid);
should(Base.base62p.getUUIDFromHex('C939D4CD392344EDA2D5450776BDFCE9', false)).be.exactly(uuid.toUpperCase());

// Should handle a random value
const random = Crypto.randomUUID();
should(Base.base62p.getUUIDFromHex(Base.base62p.decode(Base.base62p.encode(random))));
});

it('can encode and decode ObjectId', () => {

// Create a mongo object id instance
const objId = new ObjectId();
objId.should.be.instanceOf(ObjectId);

// Encode it to base62p
const id = Base.base62p.encode(objId);
id.should.be.ok().and.be.a.String();

// Decode it
Base.base62p.decode(id).toLowerCase().should.equal(objId.toString());
ObjectId.isValid(Base.base62p.decode(id)).should.be.equal(true);

const decodeObjId = new ObjectId(Base.base62p.decode(id).toLowerCase());
decodeObjId.toString().should.equal(objId.toString());
decodeObjId.equals(objId).should.be.equal(true);

});

it('can encode and decode ObjectId with prefix', () => {

// Create a mongo object id instance
const objId = new ObjectId();
objId.should.be.instanceOf(ObjectId);

// Encode it to base62p
const id = Base.base62p.encodeWithPrefix(objId, 'ac_');
id.should.be.ok().and.be.a.String();
id.should.startWith('ac_');
id.should.not.endWith('ac_');

// Decode it
Base.base62p.decodeWithPrefix(id, 'ac_').toLowerCase().should.equal(objId.toString());
ObjectId.isValid(Base.base62p.decodeWithPrefix(id, 'ac_')).should.be.equal(true);

const decodeObjId = new ObjectId(Base.base62p.decodeWithPrefix(id, 'ac_').toLowerCase());
decodeObjId.toString().should.equal(objId.toString());
decodeObjId.equals(objId).should.be.equal(true);

});

it('should generate tokens', () => {
const res = Base.base62p.generateToken(12, 'prefix_');
res.should.startWith('prefix_');
res.length.should.be.greaterThan(20);
});

it('should generate tokens with no prefix', () => {
const res = Base.base62p.generateToken(12);
res.length.should.be.greaterThan(13);
});

it('should generate tokens with no prefix or specific length', () => {
const res = Base.base62p.generateToken();
res.length.should.be.greaterThan(8);
});

it('should generate bytes as a typed array', () => {
const res = Base.base62p.generateBytes(10, { array: true });
res.length.should.be.exactly(10);
res.should.be.instanceOf(Uint8Array);
});

it('base62p should preserve leading zeros', async () => {
should(Base.base62p.decode(Base.base62p.encode('00'), 0)).be.exactly('00');
should(Base.base62p.decode(Base.base62p.encode('0039d4cd-3923-44ed-a2d5-450776bdfce9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9');
should(Base.base62p.decode(Base.base62p.encode('0039D4CD392344EDA2D5450776BDFCE9'))).be.exactly('0039D4CD392344EDA2D5450776BDFCE9');
should(Base.base62p.decode(Base.base62p.encode('00000000000000000000000000000000'))).be.exactly('00000000000000000000000000000000');
});


});

0 comments on commit c04ac8f

Please sign in to comment.