Skip to content

Commit cb4eb32

Browse files
authored
fix: constrain identity bytes to single characters (#338)
Use the char code API to interpret bytes as single characters to instead of using lossy utf-8 conversion. Fixes: #122
1 parent 1fa7547 commit cb4eb32

3 files changed

Lines changed: 94 additions & 17 deletions

File tree

src/bases/identity.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { fromString, toString } from '../bytes.ts'
1+
import { coerce, fromString, toString } from '../bytes.ts'
22
import { from } from './base.ts'
33

44
export const identity = from({
55
prefix: '\x00',
66
name: 'identity',
7-
encode: (buf) => toString(buf),
8-
decode: (str) => fromString(str)
7+
encode: (buf) => toString(coerce(buf)),
8+
decode: (str) => coerce(fromString(str))
99
})

src/bytes.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,16 @@ export function equals (aa: Uint8Array, bb: Uint8Array): boolean {
2424
return true
2525
}
2626

27-
/**
28-
* Normalize binary input to a plain `Uint8Array` backed by an `ArrayBuffer`.
29-
*
30-
* Returns the input itself when it is already a plain `Uint8Array` over an
31-
* `ArrayBuffer`, otherwise a fresh view (or, for `SharedArrayBuffer`-backed
32-
* input, a copy) over the same bytes.
33-
*
34-
* Throws if input is not a recognised binary type.
35-
*/
36-
export function coerce (o: ArrayBufferView | ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
27+
/**
28+
* Normalize binary input to a plain `Uint8Array` backed by an `ArrayBuffer`.
29+
*
30+
* Returns the input itself when it is already a plain `Uint8Array` over an
31+
* `ArrayBuffer`, otherwise a fresh view (or, for `SharedArrayBuffer`-backed
32+
* input, a copy) over the same bytes.
33+
*
34+
* Throws if input is not a recognised binary type.
35+
*/
36+
export function coerce (o: ArrayBufferView | ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
3737
if (o instanceof Uint8Array && o.constructor.name === 'Uint8Array') {
3838
return toArrayBufferBackedArray(o)
3939
}
@@ -50,12 +50,48 @@ export function isBinary (o: unknown): o is ArrayBuffer | ArrayBufferView {
5050
return o instanceof ArrayBuffer || ArrayBuffer.isView(o)
5151
}
5252

53+
/**
54+
* Convert the passed string into a byte array, constraining each character
55+
* value to a single byte
56+
*/
5357
export function fromString (str: string): Uint8Array<ArrayBuffer> {
54-
return new TextEncoder().encode(str)
58+
const output = new Uint8Array(str.length)
59+
60+
for (let i = 0; i < str.length; i++) {
61+
output[i] = str.charCodeAt(i)
62+
}
63+
64+
return output
5565
}
5666

67+
// Based on http://stackoverflow.com/a/22747272/680742, the browser with
68+
// the lowest limit is Chrome, with 0x10000 args.
69+
// We go 1 magnitude less, for safety
70+
const MAX_ARGUMENTS_LENGTH = 0x1000
71+
72+
/**
73+
* Convert the passed byte array to a string, interpreting each byte as a single
74+
* character
75+
*/
5776
export function toString (b: Uint8Array): string {
58-
return new TextDecoder().decode(b)
77+
const len = b.length
78+
79+
if (len <= MAX_ARGUMENTS_LENGTH) {
80+
// @ts-expect-error cannot ordinarily apply a Uint8Array
81+
return String.fromCharCode.apply(String, b) // avoid extra subarray()
82+
}
83+
84+
// Decode in chunks to avoid "call stack size exceeded".
85+
let res = ''
86+
let i = 0
87+
while (i < len) {
88+
res += String.fromCharCode.apply(
89+
String,
90+
// @ts-expect-error cannot ordinarily apply a Uint8Array
91+
b.subarray(i, i += MAX_ARGUMENTS_LENGTH)
92+
)
93+
}
94+
return res
5995
}
6096

6197
function isByteArrayWithArrayBuffer (b?: Uint8Array): b is Uint8Array<ArrayBuffer> {

test/test-multibase.spec.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as b36 from '../src/bases/base36.ts'
99
import * as b58 from '../src/bases/base58.ts'
1010
import * as b64 from '../src/bases/base64.ts'
1111
import * as b8 from '../src/bases/base8.ts'
12+
import * as id from '../src/bases/identity.ts'
1213
import * as bytes from '../src/bytes.ts'
1314

1415
const { base16, base32, base58btc, base64 } = { ...b16, ...b32, ...b58, ...b64 }
@@ -64,7 +65,7 @@ describe('multibase', () => {
6465
const buff = bytes.fromString('test')
6566
const nonPrintableBuff = Uint8Array.from([239, 250, 254])
6667

67-
const baseTest = (bases: typeof b2 | typeof b8 | typeof b10 | typeof b16 | typeof b32 | typeof b36 | typeof b58 | typeof b64): void => {
68+
const baseTest = (bases: typeof b2 | typeof b8 | typeof b10 | typeof b16 | typeof b32 | typeof b36 | typeof b58 | typeof b64 | typeof id): void => {
6869
for (const base of Object.values(bases)) {
6970
if (((base as { name: string })?.name) !== '') {
7071
it(`encode/decode ${base.name}`, () => {
@@ -118,6 +119,46 @@ describe('multibase', () => {
118119
baseTest(b64)
119120
})
120121

122+
describe('identity', () => {
123+
baseTest(id)
124+
125+
it('should round-trip unprintable characters', () => {
126+
const u = new Uint8Array([
127+
6, 22, 184, 240, 237, 178,
128+
112, 0, 150, 137, 182, 54,
129+
220, 1, 217, 221
130+
])
131+
132+
const s = id.identity.encode(u)
133+
const b = id.identity.decode(s)
134+
135+
assert.equalBytes(b, u)
136+
})
137+
138+
it('should round-trip emojis', () => {
139+
const input = '😵‍💫🎉'
140+
const u = new TextEncoder().encode(input)
141+
const s = id.identity.encode(u)
142+
const b = id.identity.decode(s)
143+
const output = new TextDecoder().decode(b)
144+
145+
assert.equalBytes(b, u)
146+
assert.equal(output, input)
147+
})
148+
149+
it('should round-trip multi-byte characters', () => {
150+
// https://www.kanshudo.com/kanji/%F0%A0%AE%B7
151+
const input = '𠮷'
152+
const u = new TextEncoder().encode(input)
153+
const s = id.identity.encode(u)
154+
const b = id.identity.decode(s)
155+
const output = new TextDecoder().decode(b)
156+
157+
assert.equalBytes(b, u)
158+
assert.equal(output, input)
159+
})
160+
})
161+
121162
it('multibase mismatch', () => {
122163
const b64 = base64.encode(bytes.fromString('test'))
123164
const msg = `Unable to decode multibase string "${b64}", base32 decoder only supports inputs prefixed with ${base32.prefix}`
@@ -158,7 +199,7 @@ describe('multibase', () => {
158199
assert.throws(() => base64.decode(b64.substring(0, b64.length - 1)), 'Unexpected end of data')
159200
})
160201

161-
it('infers prefix and name corretly', () => {
202+
it('infers prefix and name correctly', () => {
162203
const name = base32.name
163204

164205
// @ts-expect-error - TS catches mismatch

0 commit comments

Comments
 (0)