diff --git a/.github/workflows/ci-module.yml b/.github/workflows/ci-module.yml index 54426ca..4b7ee12 100644 --- a/.github/workflows/ci-module.yml +++ b/.github/workflows/ci-module.yml @@ -1,14 +1,12 @@ name: ci on: - push: - branches: - - master - pull_request: - workflow_dispatch: + push: + branches: + - master + pull_request: + workflow_dispatch: jobs: - test: - uses: hapijs/.github/.github/workflows/ci-module.yml@master - with: - min-node-version: 14 + test: + uses: hapijs/.github/.github/workflows/ci-module.yml@min-node-22-hapi-21 diff --git a/.gitignore b/.gitignore index 8f679c9..af5a347 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ **/node_modules **/package-lock.json -coverage.* +coverage/ **/.DS_Store **/._* diff --git a/API.md b/API.md index 33eb20b..a1e8ce6 100644 --- a/API.md +++ b/API.md @@ -26,6 +26,7 @@ iterated on and values copied, the `__proto__` property leaks and becomes the ob ### `Bourne.parse(text, [reviver], [options])` Parses a given JSON-formatted text into an object where: + - `text` - the JSON text string. - `reviver` - the `JSON.parse()` optional `reviver` argument. - `options` - optional configuration object where: @@ -37,6 +38,7 @@ Parses a given JSON-formatted text into an object where: ### `Bourne.scan(obj, [options])` Scans a given object for prototype properties where: + - `obj` - the object being scanned. - `options` - optional configuration object where: - `protoAction` - optional string with one of: diff --git a/benchmarks/ignore.js b/benchmarks/ignore.js index 51780a3..bc50df3 100755 --- a/benchmarks/ignore.js +++ b/benchmarks/ignore.js @@ -1,43 +1,28 @@ -'use strict'; - -const Benchmark = require('benchmark'); -const Bourne = require('..'); +import { Bench } from 'tinybench'; +import * as Bourne from '../src/index.js'; const internals = { - text: '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }' + text: '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }', }; +internals.reviver = function (key, value) { + return value; +}; -const suite = new Benchmark.Suite(); - +const bench = new Bench(); -suite +bench .add('JSON.parse', () => { - JSON.parse(internals.text); }) .add('Bourne.parse', () => { - Bourne.parse(internals.text, { protoAction: 'ignore' }); }) .add('reviver', () => { - JSON.parse(internals.text, internals.reviver); - }) - .on('cycle', (event) => { + }); - console.log(String(event.target)); - }) - .on('complete', function () { - - console.log('Fastest is ' + this.filter('fastest').map('name')); - }) - .run({ async: true }); - - -internals.reviver = function (key, value) { - - return value; -}; +await bench.run(); +console.table(bench.table()); diff --git a/benchmarks/no__proto__.js b/benchmarks/no__proto__.js index 35d3675..5812661 100755 --- a/benchmarks/no__proto__.js +++ b/benchmarks/no__proto__.js @@ -1,47 +1,34 @@ -'use strict'; - -const Benchmark = require('benchmark'); -const Bourne = require('..'); +import { Bench } from 'tinybench'; +import * as Bourne from '../src/index.js'; const internals = { text: '{ "a": 5, "b": 6, "proto": { "x": 7 }, "c": { "d": 0, "e": "text", "\\u005f\\u005fproto": { "y": 8 }, "f": { "g": 2 } } }', - suspectRx: /"(?:_|\\u005f)(?:_|\\u005f)(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006f)(?:t|\\u0074)(?:o|\\u006f)(?:_|\\u005f)(?:_|\\u005f)"/ + suspectRx: + /"(?:_|\\u005f)(?:_|\\u005f)(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006f)(?:t|\\u0074)(?:o|\\u006f)(?:_|\\u005f)(?:_|\\u005f)"/, }; +internals.reviver = function (key, value) { + if (key.match(internals.suspectRx)) { + return undefined; + } -const suite = new Benchmark.Suite(); + return value; +}; +const bench = new Bench(); -suite +bench .add('JSON.parse', () => { - JSON.parse(internals.text); }) .add('Bourne.parse', () => { - Bourne.parse(internals.text); }) .add('reviver', () => { - JSON.parse(internals.text, internals.reviver); - }) - .on('cycle', (event) => { + }); - console.log(String(event.target)); - }) - .on('complete', function () { +await bench.run(); - console.log('Fastest is ' + this.filter('fastest').map('name')); - }) - .run({ async: true }); - - -internals.reviver = function (key, value) { - - if (key.match(internals.suspectRx)) { - return undefined; - } - - return value; -}; +console.table(bench.table()); diff --git a/benchmarks/remove.js b/benchmarks/remove.js index 3a33f12..f002d47 100755 --- a/benchmarks/remove.js +++ b/benchmarks/remove.js @@ -1,47 +1,32 @@ -'use strict'; - -const Benchmark = require('benchmark'); -const Bourne = require('..'); +import { Bench } from 'tinybench'; +import * as Bourne from '../src/index.js'; const internals = { - text: '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }' + text: '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }', }; +internals.reviver = function (key, value) { + if (key === '__proto__') { + return undefined; + } -const suite = new Benchmark.Suite(); + return value; +}; +const bench = new Bench(); -suite +bench .add('JSON.parse', () => { - JSON.parse(internals.text); }) .add('Bourne.parse', () => { - Bourne.parse(internals.text, { protoAction: 'remove' }); }) .add('reviver', () => { - JSON.parse(internals.text, internals.reviver); - }) - .on('cycle', (event) => { - - console.log(String(event.target)); - }) - .on('complete', function () { - - console.log('Fastest is ' + this.filter('fastest').map('name')); - }) - .run({ async: true }); - - -internals.reviver = function (key, value) { + }); - if (key === '__proto__') { - return undefined; - } - - return value; -}; +await bench.run(); +console.table(bench.table()); diff --git a/benchmarks/throw.js b/benchmarks/throw.js index 80d42f5..29d1b3e 100755 --- a/benchmarks/throw.js +++ b/benchmarks/throw.js @@ -1,61 +1,43 @@ -'use strict'; - -const Benchmark = require('benchmark'); -const Bourne = require('..'); +import { Bench } from 'tinybench'; +import * as Bourne from '../src/index.js'; const internals = { text: '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }', - invalid: '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } } }' + invalid: + '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } } }', }; +internals.reviver = function (key, value) { + if (key === '__proto__') { + throw new Error('kaboom'); + } -const suite = new Benchmark.Suite(); + return value; +}; +const bench = new Bench(); -suite +bench .add('JSON.parse', () => { - JSON.parse(internals.text); }) .add('JSON.parse error', () => { - try { JSON.parse(internals.invalid); - } - catch (ignoreErr) { } + } catch {} }) .add('Bourne.parse', () => { - try { Bourne.parse(internals.text); - } - catch (ignoreErr) { } + } catch {} }) .add('reviver', () => { - try { JSON.parse(internals.text, internals.reviver); - } - catch (ignoreErr) { } - }) - .on('cycle', (event) => { + } catch {} + }); - console.log(String(event.target)); - }) - .on('complete', function () { - - console.log('Fastest is ' + this.filter('fastest').map('name')); - }) - .run({ async: true }); - - -internals.reviver = function (key, value) { - - if (key === '__proto__') { - throw new Error('kaboom'); - } - - return value; -}; +await bench.run(); +console.table(bench.table()); diff --git a/lib/index.d.ts b/lib/index.d.ts deleted file mode 100644 index 4ad3038..0000000 --- a/lib/index.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -interface Reviver { - (this: any, key: string, value: any): any; -} - -interface ParseOptions { - /** - * - `'error'` - throw a `SyntaxError` when a `__proto__` key is found. This is the default value. - * - `'remove'` - deletes any `__proto__` keys from the result object. - * - `'ignore'` - skips all validation (same as calling `JSON.parse()` directly). - */ - protoAction?: 'error' | 'remove' | 'ignore'; -} - -/** - * Parses a given JSON-formatted text into an object. - * @param text the JSON text string. - */ -export function parse(text: string): any; - -/** - * Parses a given JSON-formatted text into an object. - * @param text the JSON text string. - * @param reviver the `JSON.parse()` optional `reviver` argument. - */ -export function parse(text: string, reviver: Reviver): any; - -/** - * Parses a given JSON-formatted text into an object. - * @param text the JSON text string. - * @param options optional configuration object. - */ -export function parse(text: string, options: ParseOptions): any; - -/** - * Parses a given JSON-formatted text into an object. - * @param text the JSON text string. - * @param reviver the `JSON.parse()` optional `reviver` argument. - * @param options optional configuration object. - */ -export function parse(text: string, reviver: Reviver, options: ParseOptions): any; - -interface ScanOptions { - /** - * - `'error'` - throw a `SyntaxError` when a `__proto__` key is found. This is the default value. - * - `'remove'` - deletes any `__proto__` keys from the input `obj`. - */ - protoAction?: 'error' | 'remove'; -} - -/** - * Scans a given object for prototype properties. - * @param obj the object being scanned. - * @param options optional configuration object. - */ -export function scan(obj: any, options?: ScanOptions): void; - -/** - * Parses a given JSON-formatted text into an object or `null` if an error is found. - * @param text the JSON text string. - * @param reviver the `JSON.parse()` optional `reviver` argument. - */ -export function safeParse(text: string, reviver?: Reviver) : any | null; diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000..12e357b --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,8 @@ +import DefaultOxfmtConfig from '@hapi/oxc-plugin/oxfmt'; +import { defineConfig } from 'oxfmt'; + +import type { OxfmtConfig } from 'oxfmt'; + +export default defineConfig({ + ...DefaultOxfmtConfig, +}) as OxfmtConfig; diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..fd4548e --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,11 @@ +import HapiRecommended from '@hapi/oxc-plugin/oxlint'; +import { defineConfig } from 'oxlint'; + +import type { OxlintConfig } from 'oxlint'; + +export default defineConfig({ + extends: [HapiRecommended], + env: { + ...HapiRecommended.env, + }, +}) as OxlintConfig; diff --git a/package.json b/package.json index f1b4f34..21569e5 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,49 @@ { - "name": "@hapi/bourne", - "description": "JSON parse with prototype poisoning protection", - "version": "3.0.0", - "repository": "git://github.com/hapijs/bourne", - "main": "lib/index.js", - "types": "lib/index.d.ts", - "files": [ - "lib" - ], - "keywords": [ - "JSON", - "parse", - "safe", - "prototype" - ], - "devDependencies": { - "@hapi/code": "^9.0.0", - "@hapi/eslint-plugin": "^6.0.0", - "@hapi/lab": "25.0.0-beta.1", - "@types/node": "^17.0.31", - "benchmark": "2.x.x", - "typescript": "^4.6.3" - }, - "scripts": { - "test": "lab -a @hapi/code -t 100 -L -Y", - "test-cov-html": "lab -a @hapi/code -r html -o coverage.html" - }, - "license": "BSD-3-Clause" + "name": "@hapi/bourne", + "version": "3.0.0", + "description": "JSON parse with prototype poisoning protection", + "keywords": [ + "JSON", + "parse", + "prototype", + "safe" + ], + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "git://github.com/hapijs/bourne.git" + }, + "files": [ + "src", + "API.md" + ], + "type": "module", + "types": "src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + } + }, + "scripts": { + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "fmt": "oxfmt --check", + "fmt:fix": "oxfmt", + "check": "npm run lint && npm run fmt && npm run typecheck && npm test" + }, + "devDependencies": { + "@hapi/oxc-plugin": "^1.0.1", + "@vitest/coverage-v8": "^4.1.9", + "oxfmt": "^0.57.0", + "oxlint": "^1.72.0", + "tinybench": "6.0.2", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=22" + } } diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..20fe3c6 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,68 @@ +interface Reviver { + (this: any, key: string, value: any): any; +} + +interface ParseOptions { + /** + * - `'error'` - throw a `SyntaxError` when a `__proto__` key is found. This is the default value. + * - `'remove'` - deletes any `__proto__` keys from the result object. + * - `'ignore'` - skips all validation (same as calling `JSON.parse()` directly). + */ + protoAction?: 'error' | 'remove' | 'ignore'; +} + +/** + * Parses a given JSON-formatted text into an object. + * + * @param text The JSON text string. + */ +export function parse(text: string): any; + +/** + * Parses a given JSON-formatted text into an object. + * + * @param text The JSON text string. + * @param reviver The `JSON.parse()` optional `reviver` argument. + */ +export function parse(text: string, reviver: Reviver): any; + +/** + * Parses a given JSON-formatted text into an object. + * + * @param text The JSON text string. + * @param options Optional configuration object. + */ +export function parse(text: string, options: ParseOptions): any; + +/** + * Parses a given JSON-formatted text into an object. + * + * @param text The JSON text string. + * @param reviver The `JSON.parse()` optional `reviver` argument. + * @param options Optional configuration object. + */ +export function parse(text: string, reviver: Reviver, options: ParseOptions): any; + +interface ScanOptions { + /** + * - `'error'` - throw a `SyntaxError` when a `__proto__` key is found. This is the default value. + * - `'remove'` - deletes any `__proto__` keys from the input `obj`. + */ + protoAction?: 'error' | 'remove'; +} + +/** + * Scans a given object for prototype properties. + * + * @param obj The object being scanned. + * @param options Optional configuration object. + */ +export function scan(obj: any, options?: ScanOptions): void; + +/** + * Parses a given JSON-formatted text into an object or `null` if an error is found. + * + * @param text The JSON text string. + * @param reviver The `JSON.parse()` optional `reviver` argument. + */ +export function safeParse(text: string, reviver?: Reviver): any | null; diff --git a/lib/index.js b/src/index.js similarity index 61% rename from lib/index.js rename to src/index.js index b5da747..e2b793e 100755 --- a/lib/index.js +++ b/src/index.js @@ -1,13 +1,7 @@ -'use strict'; - - -const internals = { - suspectRx: /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*\:/ -}; - - -exports.parse = function (text, ...args) { +const suspectRx = + /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*:/; +export function parse(text, ...args) { // Normalize arguments const firstOptions = typeof args[0] === 'object' && args[0]; @@ -26,28 +20,24 @@ exports.parse = function (text, ...args) { // Ignore null and non-objects - if (!obj || - typeof obj !== 'object') { - + if (!obj || typeof obj !== 'object') { return obj; } // Check original string for potential exploit - if (!text.match(internals.suspectRx)) { + if (!suspectRx.test(text)) { return obj; } // Scan result for proto keys - exports.scan(obj, options); + scan(obj, options); return obj; -}; - - -exports.scan = function (obj, options = {}) { +} +export function scan(obj, options = {}) { let next = [obj]; while (next.length) { @@ -55,7 +45,7 @@ exports.scan = function (obj, options = {}) { next = []; for (const node of nodes) { - if (Object.prototype.hasOwnProperty.call(node, '__proto__')) { // Avoid calling node.hasOwnProperty directly + if (Object.hasOwn(node, '__proto__')) { if (options.protoAction !== 'remove') { throw new SyntaxError('Object contains forbidden prototype property'); } @@ -65,23 +55,18 @@ exports.scan = function (obj, options = {}) { for (const key in node) { const value = node[key]; - if (value && - typeof value === 'object') { - + if (value && typeof value === 'object') { next.push(node[key]); } } } } -}; - - -exports.safeParse = function (text, reviver) { +} +export function safeParse(text, reviver) { try { - return exports.parse(text, reviver); - } - catch (ignoreError) { + return parse(text, reviver); + } catch { return null; } -}; +} diff --git a/test/index.js b/test/index.js index 5916205..19dc0d3 100755 --- a/test/index.js +++ b/test/index.js @@ -1,161 +1,152 @@ -'use strict'; - -const Code = require('@hapi/code'); -const Bourne = require('..'); -const Lab = require('@hapi/lab'); - - -const internals = {}; - - -const { describe, it } = exports.lab = Lab.script(); -const expect = Code.expect; +import { describe, it, expect } from 'vitest'; +import * as Bourne from '../src/index.js'; describe('Bourne', () => { - describe('parse()', () => { - it('parses object string', () => { - - expect(Bourne.parse('{"a": 5, "b": 6}')).to.equal({ a: 5, b: 6 }); + expect(Bourne.parse('{"a": 5, "b": 6}')).toEqual({ a: 5, b: 6 }); }); it('parses null string', () => { - - expect(Bourne.parse('null')).to.equal(null); + expect(Bourne.parse('null')).toBeNull(); }); it('parses zero string', () => { - - expect(Bourne.parse('0')).to.equal(0); + expect(Bourne.parse('0')).toBe(0); }); it('parses string string', () => { - - expect(Bourne.parse('"x"')).to.equal('x'); + expect(Bourne.parse('"x"')).toBe('x'); }); it('parses object string (reviver)', () => { - const reviver = (key, value) => { - return typeof value === 'number' ? value + 1 : value; }; - expect(Bourne.parse('{"a": 5, "b": 6}', reviver)).to.equal({ a: 6, b: 7 }); + expect(Bourne.parse('{"a": 5, "b": 6}', reviver)).toEqual({ a: 6, b: 7 }); }); it('sanitizes object string (reviver, options)', () => { - const reviver = (key, value) => { - return typeof value === 'number' ? value + 1 : value; }; - expect(Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', reviver, { protoAction: 'remove' })).to.equal({ a: 6, b: 7 }); + expect( + Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', reviver, { + protoAction: 'remove', + }), + ).toEqual({ a: 6, b: 7 }); }); it('sanitizes object string (options)', () => { - - expect(Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', { protoAction: 'remove' })).to.equal({ a: 5, b: 6 }); + expect(Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', { protoAction: 'remove' })).toEqual({ + a: 5, + b: 6, + }); }); it('sanitizes object string (null, options)', () => { - - expect(Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', null, { protoAction: 'remove' })).to.equal({ a: 5, b: 6 }); + expect( + Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', null, { + protoAction: 'remove', + }), + ).toEqual({ a: 5, b: 6 }); }); it('sanitizes nested object string', () => { - - const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }'; - expect(Bourne.parse(text, { protoAction: 'remove' })).to.equal({ a: 5, b: 6, c: { d: 0, e: 'text', f: { g: 2 } } }); + const text = + '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }'; + expect(Bourne.parse(text, { protoAction: 'remove' })).toEqual({ + a: 5, + b: 6, + c: { d: 0, e: 'text', f: { g: 2 } }, + }); }); it('ignores proto property', () => { - const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'; - expect(Bourne.parse(text, { protoAction: 'ignore' })).to.equal(JSON.parse(text)); + expect(Bourne.parse(text, { protoAction: 'ignore' })).toEqual(JSON.parse(text)); }); it('ignores proto value', () => { - - expect(Bourne.parse('{"a": 5, "b": "__proto__"}')).to.equal({ a: 5, b: '__proto__' }); + expect(Bourne.parse('{"a": 5, "b": "__proto__"}')).toEqual({ a: 5, b: '__proto__' }); }); it('errors on proto property', () => { - - expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__" : { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__" \n\r\t : { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__" \n \r \t : { "x": 7 } }')).to.throw(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }')).toThrow(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__" : { "x": 7 } }')).toThrow(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__" \n\r\t : { "x": 7 } }')).toThrow(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__" \n \r \t : { "x": 7 } }')).toThrow(SyntaxError); }); it('errors on proto property (null, null)', () => { - - expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', null, null)).to.throw(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', null, null)).toThrow(SyntaxError); }); it('errors on proto property (explicit options)', () => { - - expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', { protoAction: 'error' })).to.throw(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', { protoAction: 'error' })).toThrow( + SyntaxError, + ); }); it('errors on proto property (unicode)', () => { - - expect(() => Bourne.parse('{ "a": 5, "b": 6, "\\u005f_proto__": { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "_\\u005fp\\u0072oto__": { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "\\u005f\\u005f\\u0070\\u0072\\u006f\\u0074\\u006f\\u005f\\u005f": { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "\\u005F_proto__": { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "_\\u005Fp\\u0072oto__": { "x": 7 } }')).to.throw(SyntaxError); - expect(() => Bourne.parse('{ "a": 5, "b": 6, "\\u005F\\u005F\\u0070\\u0072\\u006F\\u0074\\u006F\\u005F\\u005F": { "x": 7 } }')).to.throw(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "\\u005f_proto__": { "x": 7 } }')).toThrow(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "_\\u005fp\\u0072oto__": { "x": 7 } }')).toThrow(SyntaxError); + expect(() => + Bourne.parse( + '{ "a": 5, "b": 6, "\\u005f\\u005f\\u0070\\u0072\\u006f\\u0074\\u006f\\u005f\\u005f": { "x": 7 } }', + ), + ).toThrow(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "\\u005F_proto__": { "x": 7 } }')).toThrow(SyntaxError); + expect(() => Bourne.parse('{ "a": 5, "b": 6, "_\\u005Fp\\u0072oto__": { "x": 7 } }')).toThrow(SyntaxError); + expect(() => + Bourne.parse( + '{ "a": 5, "b": 6, "\\u005F\\u005F\\u0070\\u0072\\u006F\\u0074\\u006F\\u005F\\u005F": { "x": 7 } }', + ), + ).toThrow(SyntaxError); }); }); describe('scan()', () => { - it('sanitizes nested object string', () => { - - const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }'; + const text = + '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }'; const obj = JSON.parse(text); Bourne.scan(obj, { protoAction: 'remove' }); - expect(obj).to.equal({ a: 5, b: 6, c: { d: 0, e: 'text', f: { g: 2 } } }); + expect(obj).toEqual({ a: 5, b: 6, c: { d: 0, e: 'text', f: { g: 2 } } }); }); it('errors on proto property', () => { - - const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }'; + const text = + '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }'; const obj = JSON.parse(text); - expect(() => Bourne.scan(obj)).to.throw(SyntaxError); + expect(() => Bourne.scan(obj)).toThrow(SyntaxError); }); it('does not break when hasOwnProperty is overwritten', () => { - const text = '{ "a": 5, "b": 6, "hasOwnProperty": "text", "__proto__": { "x": 7 } }'; const obj = JSON.parse(text); Bourne.scan(obj, { protoAction: 'remove' }); - expect(obj).to.equal({ a: 5, b: 6, hasOwnProperty: 'text' }); + expect(obj).toEqual({ a: 5, b: 6, hasOwnProperty: 'text' }); }); }); describe('safeParse()', () => { - it('parses object string', () => { - - expect(Bourne.safeParse('{"a": 5, "b": 6}')).to.equal({ a: 5, b: 6 }); + expect(Bourne.safeParse('{"a": 5, "b": 6}')).toEqual({ a: 5, b: 6 }); }); it('returns null on proto object string', () => { - - expect(Bourne.safeParse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }')).to.be.null(); + expect(Bourne.safeParse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }')).toBeNull(); }); it('returns null on invalid object string', () => { - - expect(Bourne.safeParse('{"a": 5, "b": 6')).to.be.null(); + expect(Bourne.safeParse('{"a": 5, "b": 6')).toBeNull(); }); }); }); diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index 3dffb52..0000000 --- a/test/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as Bourne from '..'; -import * as Lab from '@hapi/lab'; - -const { expect } = Lab.types; - -// parse - -expect.type(Bourne.parse('{}')); -expect.type(Bourne.parse('{}', () => {})); -expect.type(Bourne.parse('{}', (key, value) => ({ key, value }))); -expect.type(Bourne.parse('{}', {})); -expect.type(Bourne.parse('{}', { protoAction: 'error' })); -expect.type(Bourne.parse('{}', () => {}, { protoAction: 'error' })); - -expect.error(Bourne.parse({})); -expect.error(Bourne.parse('{}', '')); -expect.error(Bourne.parse('{}', { protAct: 'error' })); - -// scan - -expect.type(Bourne.scan({})); -expect.type(Bourne.scan({}, {})); -expect.type(Bourne.scan({}, { protoAction: 'remove' })); - -// safeParse - -expect.type(Bourne.safeParse('{}')); -expect.type(Bourne.safeParse('{}', () => {})); - -expect.error(Bourne.safeParse({})); -expect.error(Bourne.safeParse('{}', '')); diff --git a/test/typings.ts b/test/typings.ts new file mode 100644 index 0000000..2eb0ea7 --- /dev/null +++ b/test/typings.ts @@ -0,0 +1,47 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import * as Bourne from '../src/index.js'; + +describe('typings', () => { + describe('parse', () => { + it('parse — call signatures compile', () => { + expectTypeOf(Bourne.parse).toBeFunction(); + expectTypeOf(Bourne.parse('{}')).toBeAny(); + expectTypeOf(Bourne.parse('{}', () => {})).toBeAny(); + expectTypeOf(Bourne.parse('{}', (key, value) => ({ key, value }))).toBeAny(); + expectTypeOf(Bourne.parse('{}', {})).toBeAny(); + expectTypeOf(Bourne.parse('{}', { protoAction: 'error' })).toBeAny(); + expectTypeOf(Bourne.parse('{}', () => {}, { protoAction: 'error' })).toBeAny(); + }); + + it('parse — invalid calls rejected', () => { + try { + // @ts-expect-error first arg must be string + Bourne.parse({}); + } catch {} + // @ts-expect-error reviver must be function or options object + Bourne.parse('{}', ''); + // @ts-expect-error invalid option key + Bourne.parse('{}', { protAct: 'error' }); + }); + }); + + describe('scan', () => { + it('returns void, accepts options', () => { + expectTypeOf(Bourne.scan).toBeFunction(); + expectTypeOf(Bourne.scan({})).toBeVoid(); + expectTypeOf(Bourne.scan({}, {})).toBeVoid(); + expectTypeOf(Bourne.scan({}, { protoAction: 'remove' })).toBeVoid(); + }); + }); + + describe('safeParse', () => { + it('call signatures compile, invalid calls rejected', () => { + expectTypeOf(Bourne.safeParse).toBeFunction(); + // @ts-expect-error first arg must be string + Bourne.safeParse({}); + // @ts-expect-error reviver must be function + Bourne.safeParse('{}', ''); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..849762f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": true, + "isolatedDeclarations": true, + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ESNext" + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2420cb5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import Oxc from '@hapi/oxc-plugin/vitest'; +import { defineConfig } from 'vitest/config'; + +import type { ViteUserConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [Oxc()], + test: { + environment: 'node', + include: ['test/**/*.{js,ts}'], + typecheck: { + enabled: true, + include: ['test/**/*.{js,ts}'], + }, + coverage: { + provider: 'v8', + include: ['src/**'], + thresholds: { + 100: true, + }, + }, + }, +}) as ViteUserConfig;