diff --git a/docs/03_options.md b/docs/03_options.md index 297e0499..b8e3be77 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -121,12 +121,14 @@ These options influence how the document is transformed into "native" JavaScript Used by: `parse()`, `doc.toJS()` and `node.toJS()` -| Name | Type | Default | Description | -| ------------- | ------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| mapAsMap | `boolean` | `false` | Use Map rather than Object to represent mappings. | -| maxAliasCount | `number` | `100` | Prevent [exponential entity expansion attacks] by limiting data aliasing; set to `-1` to disable checks; `0` disallows all alias nodes. | -| onAnchor | `(value: any, count: number) => void` | | Optional callback for each aliased anchor in the document. | -| reviver | `(key: any, value: any) => any` | | Optionally apply a [reviver function] to the output, following the JSON specification but with appropriate extensions for handling `Map` and `Set`. | +| Name | Type | Default | Description | +| ------------------- | ------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| freeze | `boolean` | `false` | Freeze deeply on the Array and Object values representing the YAML sequences and mappings. Incompatible with a `reviver`. | +| mapAsMap | `boolean` | `false` | Use Map rather than Object to represent mappings. Incompatible with `preferNullPrototype`. | +| maxAliasCount | `number` | `100` | Prevent [exponential entity expansion attacks] by limiting data aliasing; set to `-1` to disable checks; `0` disallows all alias nodes. | +| onAnchor | `(value: any, count: number) => void` | | Optional callback for each aliased anchor in the document. | +| preferNullPrototype | `boolean` | `freeze` | Use a null prototype for Object values representing YAML mappings. Incompatible with `mapAsMap`. | +| reviver | `(key: any, value: any) => any` | | Optionally apply a [reviver function] to the output, following the JSON specification but with appropriate extensions for handling `Map` and `Set`. | [exponential entity expansion attacks]: https://en.wikipedia.org/wiki/Billion_laughs_attack [reviver function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Using_the_reviver_parameter diff --git a/src/nodes/Pair.ts b/src/nodes/Pair.ts index fb9d24a3..b3a60164 100644 --- a/src/nodes/Pair.ts +++ b/src/nodes/Pair.ts @@ -34,7 +34,11 @@ export class Pair< } toJS(doc: Document, ctx: ToJSContext): MapLike { - const pair = ctx.mapAsMap ? new Map() : {} + const pair: MapLike = ctx.mapAsMap + ? new Map() + : ctx.preferNullPrototype + ? Object.create(null) + : {} addPairToJSMap(doc, ctx, pair, this) return pair } diff --git a/src/nodes/YAMLMap.ts b/src/nodes/YAMLMap.ts index c9b921a0..6e2843b8 100644 --- a/src/nodes/YAMLMap.ts +++ b/src/nodes/YAMLMap.ts @@ -217,9 +217,14 @@ export class YAMLMap< ctx?: ToJSContext ): Map | Record { ctx ??= new ToJSContext() - const map = ctx.mapAsMap ? new Map() : {} + const map: MapLike = ctx.mapAsMap + ? new Map() + : ctx.preferNullPrototype + ? Object.create(null) + : {} if (this.anchor) ctx.setAnchor(this, map) for (const pair of this.values.values()) addPairToJSMap(doc, ctx, map, pair) + if (ctx.freeze) Object.freeze(map) return map } diff --git a/src/nodes/YAMLSeq.ts b/src/nodes/YAMLSeq.ts index 7aad3253..490ef08d 100644 --- a/src/nodes/YAMLSeq.ts +++ b/src/nodes/YAMLSeq.ts @@ -184,20 +184,14 @@ export class YAMLSeq< /** A plain JavaScript representation of this node. */ toJS(doc: Document, ctx?: ToJSContext): any[] { ctx ??= new ToJSContext() - if (this.anchor) { - const res: unknown[] = [] - if (this.anchor) ctx.setAnchor(this, res) - for (const item of this) res.push(item.toJS(doc, ctx)) - return res - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - const res = Array.from(this, item => item.toJS(doc, ctx)) - if (ctx.reviverSources) { - for (let i = 0; i < this.length; ++i) - ctx.setSource(res, String(i), this[i]) + const res: unknown[] = [] + if (this.anchor) ctx.setAnchor(this, res) + for (let i = 0; i < this.length; ++i) { + const item = this[i] + res.push(item.toJS(doc, ctx)) + ctx.setSource(res, String(i), item) } - + if (ctx.freeze) Object.freeze(res) return res } diff --git a/src/nodes/toJS.ts b/src/nodes/toJS.ts index bf85ca81..7c8ef817 100644 --- a/src/nodes/toJS.ts +++ b/src/nodes/toJS.ts @@ -11,15 +11,25 @@ export class ToJSContext { new Map() /** Cached anchor and alias nodes in the order they occur in the document */ aliasResolveCache?: Node[] + freeze: boolean mapAsMap: boolean mapKeyWarned = false maxAliasCount: number + preferNullPrototype: boolean reviverSources?: WeakMap> constructor(opt: ToJSOptions = {}) { - this.mapAsMap = opt.mapAsMap === true + this.freeze = Boolean(opt.freeze) + const hasReviver = typeof opt.reviver === 'function' + if (this.freeze && hasReviver) + throw new Error('Incompatible options: freeze and reviver') + this.mapAsMap = Boolean(opt.mapAsMap) this.maxAliasCount = opt.maxAliasCount ?? 100 - if (typeof opt.reviver === 'function') this.reviverSources = new WeakMap() + this.preferNullPrototype = Boolean(opt.preferNullPrototype ?? opt.freeze) + if (this.mapAsMap && opt.preferNullPrototype) { + throw new Error('Incompatible options: mapAsMap and preferNullPrototype') + } + if (hasReviver) this.reviverSources = new WeakMap() } setAnchor(node: Node, res: unknown): void { diff --git a/src/options.ts b/src/options.ts index 953ed62a..e3406698 100644 --- a/src/options.ts +++ b/src/options.ts @@ -180,9 +180,22 @@ export type CreateNodeOptions = { } export type ToJSOptions = { + /** + * Apply `Object.freeze()` deeply on the Array and Object values + * representing the YAML sequences and mappings. + * + * Not compatible with a `reviver`, + * and ineffective for mappings if `mapAsMap: true` is set. + * + * Default: `false` + */ + freeze?: boolean + /** * Use Map rather than Object to represent mappings. * + * Not compatible with `preferNullPrototype: true`. + * * Default: `false` */ mapAsMap?: boolean @@ -201,6 +214,15 @@ export type ToJSOptions = { */ onAnchor?: (value: unknown, count: number) => void + /** + * Use a null prototype for maps represented by Object values. + * + * Not compatible with `mapAsMap: true`. + * + * Default: `freeze ? true : false` + */ + preferNullPrototype?: boolean + /** * Optional function that may filter or modify the output JS value, * based on the one available in `JSON.parse`: @@ -210,6 +232,8 @@ export type ToJSOptions = { * Includes extensions beyond the `JSON.parse` equivalent * for handling Map and Set objects, * and so the `key` argument is not always a string. + * + * Not compatible with `freeze: true`. */ reviver?: ( this: any, diff --git a/tests/doc/parse.ts b/tests/doc/parse.ts index 5b977878..b6c8971a 100644 --- a/tests/doc/parse.ts +++ b/tests/doc/parse.ts @@ -761,6 +761,63 @@ describe('keepSourceTokens', () => { }) }) +describe('freeze', () => { + test('plain object & array', () => { + const src = 'a: 1\nb: 2\nc: [d, e]' + const obj = YAML.parse(src, { freeze: true }) + expect(obj).toEqual({ a: 1, b: 2, c: ['d', 'e'] }) + expect(Object.isFrozen(obj)).toBe(true) + expect(Object.isFrozen(obj.c)).toBe(true) + expect(Object.getPrototypeOf(obj)).toBeNull() + }) + + test('mapAsMap & array', () => { + const src = 'a: 1\nb: 2\nc: [d, e]' + const obj = YAML.parse(src, { freeze: true, mapAsMap: true }) + expect(obj).toEqual( + new Map([ + ['a', 1], + ['b', 2], + ['c', ['d', 'e']] + ]) + ) + expect(Object.isFrozen(obj)).toBe(true) // ineffective, alas + expect(Object.isFrozen(obj.get('c'))).toBe(true) + expect(Object.getPrototypeOf(obj)).toBe(Map.prototype) + }) + + test('!!merge', () => { + const src = '- &a { a: *a, b: B }\n- { <<: *a, b: X }\n' + const obj = YAML.parse(src, { freeze: true, merge: true }) + const aRef = obj[0] + expect(obj).toEqual([ + { a: aRef, b: 'B' }, + { a: aRef, b: 'X' } + ]) + expect(Object.isFrozen(obj)).toBe(true) + expect(Object.isFrozen(aRef)).toBe(true) + expect(Object.getPrototypeOf(aRef)).toBeNull() + }) +}) + +describe('preferNullPrototype', () => { + test('plain object', () => { + const src = 'a: 1\nb: 2\nc: [d, e]' + const obj = YAML.parse(src, { preferNullPrototype: true }) + expect(obj).toEqual({ a: 1, b: 2, c: ['d', 'e'] }) + expect(Object.getPrototypeOf(obj)).toBeNull() + }) + + test('!!pairs', () => { + const src = '!!pairs [ a: 1, b: 2 ]' + const doc = YAML.parseDocument(src) + const res = doc.toJS({ preferNullPrototype: true }) + expect(res).toEqual([{ a: 1 }, { b: 2 }]) + expect(Object.getPrototypeOf(res[0])).toBeNull() + expect(Object.getPrototypeOf(res[1])).toBeNull() + }) +}) + describe('reviver', () => { test('MDN example', () => { const reviver = vi.fn((_key, value) => value)