Skip to content

Commit

Permalink
feat(NODE-5957): add parse to elements API
Browse files Browse the repository at this point in the history
  • Loading branch information
nbbeeken committed Mar 4, 2024
1 parent b64e912 commit 290b9ce
Show file tree
Hide file tree
Showing 10 changed files with 544 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/bson.ts
Expand Up @@ -54,6 +54,7 @@ export { BSONValue } from './bson_value';
export { BSONError, BSONVersionError, BSONRuntimeError } from './error';
export { BSONType } from './constants';
export { EJSON } from './extended_json';
export { onDemand } from './parser/on_demand/index';

/** @public */
export interface Document {
Expand Down
22 changes: 22 additions & 0 deletions src/error.ts
Expand Up @@ -81,3 +81,25 @@ export class BSONRuntimeError extends BSONError {
super(message);
}
}

/**
* @public
* @category Error
*
* @experimental
*
* An error generated when BSON bytes are invalid.
* Reports the offset the parser was able to reach before encountering the error.
*/
export class BSONOffsetError extends BSONError {
public get name(): 'BSONOffsetError' {
return 'BSONOffsetError';
}

public offset: number;

constructor(message: string, offset: number) {
super(`${message}. offset: ${offset}`);
this.offset = offset;
}
}
28 changes: 28 additions & 0 deletions src/parser/on_demand/index.ts
@@ -0,0 +1,28 @@
import { type BSONError, BSONOffsetError } from '../../error';
import { type BSONElement, parseToElements } from './parse_to_elements';
/**
* @experimental
* @public
*
* A new set of BSON APIs that are currently experimental and not intended for production use.
*/
export type OnDemand = {
BSONOffsetError: {
new (message: string, offset: number): BSONOffsetError;
isBSONError(value: unknown): value is BSONError;
};
parseToElements: (this: void, bytes: Uint8Array, startOffset?: number) => Iterable<BSONElement>;
};

/**
* @experimental
* @public
*/
const onDemand: OnDemand = Object.create(null);

onDemand.parseToElements = parseToElements;
onDemand.BSONOffsetError = BSONOffsetError;

Object.freeze(onDemand);

export { onDemand };
131 changes: 131 additions & 0 deletions src/parser/on_demand/parse_to_elements.ts
@@ -0,0 +1,131 @@
import { BSONOffsetError } from '../../error';
import { NumberUtils } from '../../utils/number_utils';

/**
* @public
* @experimental
*/
export type BSONElement = [
type: number,
nameOffset: number,
nameLength: number,
offset: number,
length: number
];

/**
* Searches for null terminator.
* **Does not** bounds check since this should **ONLY** be used within parseToElements which has asserted that `bytes` ends with a `0x00`.
* So this will at most iterate to the document's terminator and error if that is the offset reached.
*/
function findNull(bytes: Uint8Array, offset: number): number {
let nullTerminatorOffset = offset;

for (; bytes[nullTerminatorOffset] !== 0x00; nullTerminatorOffset++);

if (nullTerminatorOffset === bytes.length - 1) {
throw new BSONOffsetError('Null terminator not found', offset);
}

return nullTerminatorOffset;
}

/**
* @public
* @experimental
*/
export function parseToElements(bytes: Uint8Array, startOffset = 0): Iterable<BSONElement> {
if (bytes.length < 5) {
throw new BSONOffsetError(
`Input must be at least 5 bytes, got ${bytes.length} bytes`,
startOffset
);
}

const documentSize = NumberUtils.getSize(bytes, startOffset);

if (documentSize > bytes.length - startOffset) {
throw new BSONOffsetError(
`Parsed documentSize (${documentSize} bytes) does not match input length (${bytes.length} bytes)`,
startOffset
);
}

if (bytes[startOffset + documentSize - 1] !== 0x00) {
throw new BSONOffsetError('BSON documents must end in 0x00', startOffset + documentSize);
}

const elements: BSONElement[] = [];
let offset = startOffset + 4;

while (offset <= documentSize + startOffset) {
const type = bytes[offset];
offset += 1;

if (type === 0) {
if (offset - startOffset !== documentSize) {
throw new BSONOffsetError(`Invalid 0x00 type byte`, offset);
}
break;
}

const nameOffset = offset;
const nameLength = findNull(bytes, offset) - nameOffset;
offset += nameLength + 1;

let length: number;

if (type === 1 || type === 18 || type === 9 || type === 17) {
// double, long, date, timestamp
length = 8;
} else if (type === 16) {
// int
length = 4;
} else if (type === 7) {
// objectId
length = 12;
} else if (type === 19) {
// decimal128
length = 16;
} else if (type === 8) {
// boolean
length = 1;
} else if (type === 10 || type === 6 || type === 127 || type === 255) {
// null, undefined, maxKey, minKey
length = 0;
}
// Needs a size calculation
else if (type === 11) {
// regex
length = findNull(bytes, findNull(bytes, offset) + 1) + 1 - offset;
} else if (type === 3 || type === 4 || type === 15) {
// object, array, code_w_scope
length = NumberUtils.getSize(bytes, offset);
} else if (type === 2 || type === 5 || type === 12 || type === 13 || type === 14) {
// string, binary, dbpointer, code, symbol
length = NumberUtils.getSize(bytes, offset) + 4;
if (type === 5) {
// binary subtype
length += 1;
}
if (type === 12) {
// dbPointer's objectId
length += 12;
}
} else {
throw new BSONOffsetError(
`Invalid 0x${type.toString(16).padStart(2, '0')} type byte`,
offset
);
}

if (length > documentSize) {
throw new BSONOffsetError('value reports length larger than document', offset);
}

elements.push([type, nameOffset, nameLength, offset, length]);
offset += length;
}

return elements;
}
9 changes: 9 additions & 0 deletions src/utils/number_utils.ts
@@ -1,3 +1,5 @@
import { BSONOffsetError } from '../error';

const FLOAT = new Float64Array(1);
const FLOAT_BYTES = new Uint8Array(FLOAT.buffer, 0, 8);

Expand All @@ -7,6 +9,13 @@ const FLOAT_BYTES = new Uint8Array(FLOAT.buffer, 0, 8);
* @internal
*/
export const NumberUtils = {
getSize(source: Uint8Array, offset: number): number {
if (source[offset + 3] > 127) {
throw new BSONOffsetError('BSON size cannot be negative', offset);
}
return NumberUtils.getInt32LE(source, offset);
},

/** Reads a little-endian 32-bit integer from source */
getInt32LE(source: Uint8Array, offset: number): number {
return (
Expand Down
28 changes: 27 additions & 1 deletion test/node/error.test.ts
@@ -1,7 +1,13 @@
import { expect } from 'chai';
import { loadESModuleBSON } from '../load_bson';

import { __isWeb__, BSONError, BSONVersionError, BSONRuntimeError } from '../register-bson';
import {
__isWeb__,
BSONError,
BSONVersionError,
BSONRuntimeError,
onDemand
} from '../register-bson';

const instanceOfChecksWork = !__isWeb__;

Expand Down Expand Up @@ -102,4 +108,24 @@ describe('BSONError', function () {
expect(new BSONRuntimeError('Woops!')).to.have.property('name', 'BSONRuntimeError');
});
});

describe('class BSONOffsetError', () => {
it('is a BSONError instance', function () {
expect(BSONError.isBSONError(new onDemand.BSONOffsetError('Oopsie', 3))).to.be.true;
});

it('has a name property equal to "BSONOffsetError"', function () {
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('name', 'BSONOffsetError');
});

it('sets the offset property', function () {
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('offset', 3);
});

it('includes the offset in the message', function () {
expect(new onDemand.BSONOffsetError('Woops!', 3))
.to.have.property('message')
.that.matches(/offset: 3/i);
});
});
});
1 change: 1 addition & 0 deletions test/node/exports.test.ts
Expand Up @@ -18,6 +18,7 @@ const EXPECTED_EXPORTS = [
'DBRef',
'Binary',
'ObjectId',
'onDemand',
'UUID',
'Long',
'Timestamp',
Expand Down

0 comments on commit 290b9ce

Please sign in to comment.