Skip to content

Commit ffb41a1

Browse files
committed
feat(NODE-5957): add parse to elements API
1 parent b64e912 commit ffb41a1

File tree

10 files changed

+540
-1
lines changed

10 files changed

+540
-1
lines changed

src/bson.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export { BSONValue } from './bson_value';
5454
export { BSONError, BSONVersionError, BSONRuntimeError } from './error';
5555
export { BSONType } from './constants';
5656
export { EJSON } from './extended_json';
57+
export { onDemand } from './parser/on_demand/index';
5758

5859
/** @public */
5960
export interface Document {

src/error.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,25 @@ export class BSONRuntimeError extends BSONError {
8181
super(message);
8282
}
8383
}
84+
85+
/**
86+
* @public
87+
* @category Error
88+
*
89+
* @experimental
90+
*
91+
* An error generated when BSON bytes are invalid.
92+
* Reports the offset the parser was able to reach before encountering the error.
93+
*/
94+
export class BSONOffsetError extends BSONError {
95+
public get name(): 'BSONOffsetError' {
96+
return 'BSONOffsetError';
97+
}
98+
99+
public offset: number;
100+
101+
constructor(message: string, offset: number) {
102+
super(`${message}. offset: ${offset}`);
103+
this.offset = offset;
104+
}
105+
}

src/parser/on_demand/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { type BSONError, BSONOffsetError } from '../../error';
2+
import { type BSONElement, parseToElements } from './parse_to_elements';
3+
/**
4+
* @experimental
5+
* @public
6+
*
7+
* A new set of BSON APIs that are currently experimental and not intended for production use.
8+
*/
9+
export type OnDemand = {
10+
BSONOffsetError: {
11+
new (message: string, offset: number): BSONOffsetError;
12+
isBSONError(value: unknown): value is BSONError;
13+
};
14+
parseToElements: (this: void, bytes: Uint8Array, startOffset?: number) => Iterable<BSONElement>;
15+
};
16+
17+
/**
18+
* @experimental
19+
* @public
20+
*/
21+
const onDemand: OnDemand = Object.create(null);
22+
23+
onDemand.parseToElements = parseToElements;
24+
onDemand.BSONOffsetError = BSONOffsetError;
25+
26+
Object.freeze(onDemand);
27+
28+
export { onDemand };
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { BSONOffsetError } from '../../error';
2+
import { NumberUtils } from '../../utils/number_utils';
3+
4+
/**
5+
* @public
6+
* @experimental
7+
*/
8+
export type BSONElement = [
9+
type: number,
10+
nameOffset: number,
11+
nameLength: number,
12+
offset: number,
13+
length: number
14+
];
15+
16+
/**
17+
* Searches for null terminator.
18+
* **Does not** bounds check since this should **ONLY** be used within parseToElements which has asserted that `bytes` ends with a `0x00`.
19+
* So this will at most iterate to the document's terminator and error if that is the offset reached.
20+
*/
21+
function findNull(bytes: Uint8Array, offset: number): number {
22+
let nullTerminatorOffset = offset;
23+
24+
for (; bytes[nullTerminatorOffset] !== 0x00; nullTerminatorOffset++);
25+
26+
if (nullTerminatorOffset === bytes.length - 1) {
27+
throw new BSONOffsetError('Null terminator not found', offset);
28+
}
29+
30+
return nullTerminatorOffset;
31+
}
32+
33+
/**
34+
* @public
35+
* @experimental
36+
*/
37+
export function parseToElements(bytes: Uint8Array, startOffset = 0): Iterable<BSONElement> {
38+
if (bytes.length < 5) {
39+
throw new BSONOffsetError(
40+
`Input must be at least 5 bytes, got ${bytes.length} bytes`,
41+
startOffset
42+
);
43+
}
44+
45+
const documentSize = NumberUtils.getSize(bytes, startOffset);
46+
47+
if (documentSize > bytes.length - startOffset) {
48+
throw new BSONOffsetError(
49+
`Parsed documentSize (${documentSize} bytes) does not match input length (${bytes.length} bytes)`,
50+
startOffset
51+
);
52+
}
53+
54+
if (bytes[startOffset + documentSize - 1] !== 0x00) {
55+
throw new BSONOffsetError('BSON documents must end in 0x00', startOffset + documentSize);
56+
}
57+
58+
const elements: BSONElement[] = [];
59+
let offset = startOffset + 4;
60+
61+
while (offset <= documentSize + startOffset) {
62+
const type = bytes[offset];
63+
offset += 1;
64+
65+
if (type === 0) {
66+
if (offset - startOffset !== documentSize) {
67+
throw new BSONOffsetError(`Invalid 0x00 type byte`, offset);
68+
}
69+
break;
70+
}
71+
72+
const nameOffset = offset;
73+
const nameLength = findNull(bytes, offset) - nameOffset;
74+
offset += nameLength + 1;
75+
76+
let length: number;
77+
78+
if (type === 1 || type === 18 || type === 9 || type === 17) {
79+
// double, long, date, timestamp
80+
length = 8;
81+
} else if (type === 16) {
82+
// int
83+
length = 4;
84+
} else if (type === 7) {
85+
// objectId
86+
length = 12;
87+
} else if (type === 19) {
88+
// decimal128
89+
length = 16;
90+
} else if (type === 8) {
91+
// boolean
92+
length = 1;
93+
} else if (type === 10 || type === 6 || type === 127 || type === 255) {
94+
// null, undefined, maxKey, minKey
95+
length = 0;
96+
}
97+
// Needs a size calculation
98+
else if (type === 11) {
99+
// regex
100+
length = findNull(bytes, findNull(bytes, offset) + 1) + 1 - offset;
101+
} else if (type === 3 || type === 4 || type === 15) {
102+
// object, array, code_w_scope
103+
length = NumberUtils.getSize(bytes, offset);
104+
} else if (type === 2 || type === 5 || type === 12 || type === 13 || type === 14) {
105+
// string, binary, dbpointer, code, symbol
106+
length = NumberUtils.getSize(bytes, offset) + 4;
107+
if (type === 5) {
108+
// binary subtype
109+
length += 1;
110+
}
111+
if (type === 12) {
112+
// dbPointer's objectId
113+
length += 12;
114+
}
115+
} else {
116+
throw new BSONOffsetError(
117+
`Invalid 0x${type.toString(16).padStart(2, '0')} type byte`,
118+
offset
119+
);
120+
}
121+
122+
if (length > documentSize) {
123+
throw new BSONOffsetError('value reports length larger than document', offset);
124+
}
125+
126+
elements.push([type, nameOffset, nameLength, offset, length]);
127+
offset += length;
128+
}
129+
130+
return elements;
131+
}

src/utils/number_utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { BSONOffsetError } from '../error';
2+
13
const FLOAT = new Float64Array(1);
24
const FLOAT_BYTES = new Uint8Array(FLOAT.buffer, 0, 8);
35

@@ -7,6 +9,13 @@ const FLOAT_BYTES = new Uint8Array(FLOAT.buffer, 0, 8);
79
* @internal
810
*/
911
export const NumberUtils = {
12+
getSize(source: Uint8Array, offset: number): number {
13+
if (source[offset + 3] > 127) {
14+
throw new BSONOffsetError('BSON size cannot be negative', offset);
15+
}
16+
return NumberUtils.getInt32LE(source, offset);
17+
},
18+
1019
/** Reads a little-endian 32-bit integer from source */
1120
getInt32LE(source: Uint8Array, offset: number): number {
1221
return (

test/node/error.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { expect } from 'chai';
22
import { loadESModuleBSON } from '../load_bson';
33

4-
import { __isWeb__, BSONError, BSONVersionError, BSONRuntimeError } from '../register-bson';
4+
import {
5+
__isWeb__,
6+
BSONError,
7+
BSONVersionError,
8+
BSONRuntimeError,
9+
onDemand
10+
} from '../register-bson';
511

612
const instanceOfChecksWork = !__isWeb__;
713

@@ -102,4 +108,24 @@ describe('BSONError', function () {
102108
expect(new BSONRuntimeError('Woops!')).to.have.property('name', 'BSONRuntimeError');
103109
});
104110
});
111+
112+
describe('class BSONOffsetError', () => {
113+
it('is a BSONError instance', function () {
114+
expect(BSONError.isBSONError(new onDemand.BSONOffsetError('Oopsie', 3))).to.be.true;
115+
});
116+
117+
it('has a name property equal to "BSONOffsetError"', function () {
118+
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('name', 'BSONOffsetError');
119+
});
120+
121+
it('sets the offset property', function () {
122+
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('offset', 3);
123+
});
124+
125+
it('includes the offset in the message', function () {
126+
expect(new onDemand.BSONOffsetError('Woops!', 3))
127+
.to.have.property('message')
128+
.that.matches(/offset: 3/i);
129+
});
130+
});
105131
});

test/node/exports.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const EXPECTED_EXPORTS = [
1818
'DBRef',
1919
'Binary',
2020
'ObjectId',
21+
'onDemand',
2122
'UUID',
2223
'Long',
2324
'Timestamp',

0 commit comments

Comments
 (0)