From 77621e69bf06820b562b12bfe11940f7facea5a6 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Tue, 22 Sep 2026 21:25:55 -0300 Subject: [PATCH 1/2] feat: Add freeze option for parse() and .toJS() --- docs/03_options.md | 1 + src/doc/Document.ts | 2 ++ src/nodes/YAMLMap.ts | 1 + src/nodes/YAMLSeq.ts | 20 +++++++------------- src/nodes/toJS.ts | 4 +++- src/options.ts | 13 +++++++++++++ tests/doc/parse.ts | 36 ++++++++++++++++++++++++++++++++++++ 7 files changed, 63 insertions(+), 14 deletions(-) diff --git a/docs/03_options.md b/docs/03_options.md index 297e0499..62af5ad1 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -123,6 +123,7 @@ Used by: `parse()`, `doc.toJS()` and `node.toJS()` | 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. | | 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. | diff --git a/src/doc/Document.ts b/src/doc/Document.ts index 4fc583a4..3b0ae1b3 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -360,6 +360,8 @@ export class Document { /** A plain JavaScript representation of the document `value`. */ toJS(opt: ToJSOptions = {}): any { + if (opt.freeze && opt.reviver) + throw new Error('Incompatible options: freeze and reviver') const ctx = new ToJSContext(opt) const res = this.value.toJS(this, ctx) if (typeof opt.onAnchor === 'function') { diff --git a/src/nodes/YAMLMap.ts b/src/nodes/YAMLMap.ts index c9b921a0..59a934c9 100644 --- a/src/nodes/YAMLMap.ts +++ b/src/nodes/YAMLMap.ts @@ -220,6 +220,7 @@ export class YAMLMap< const map = ctx.mapAsMap ? new Map() : {} 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..c4e2f54c 100644 --- a/src/nodes/toJS.ts +++ b/src/nodes/toJS.ts @@ -11,13 +11,15 @@ 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 reviverSources?: WeakMap> constructor(opt: ToJSOptions = {}) { - this.mapAsMap = opt.mapAsMap === true + this.freeze = Boolean(opt.freeze) + this.mapAsMap = Boolean(opt.mapAsMap) this.maxAliasCount = opt.maxAliasCount ?? 100 if (typeof opt.reviver === 'function') this.reviverSources = new WeakMap() } diff --git a/src/options.ts b/src/options.ts index 953ed62a..44667bad 100644 --- a/src/options.ts +++ b/src/options.ts @@ -180,6 +180,17 @@ 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. * @@ -210,6 +221,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..226d57fe 100644 --- a/tests/doc/parse.ts +++ b/tests/doc/parse.ts @@ -761,6 +761,42 @@ 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) + }) + + 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) + }) + + 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) + }) +}) + describe('reviver', () => { test('MDN example', () => { const reviver = vi.fn((_key, value) => value) From 5b1e1d91be0faf189244ed0a7220ea97c7309d5f Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Wed, 23 Sep 2026 00:12:57 -0300 Subject: [PATCH 2/2] feat: Add preferNullPrototype option for parse() and .toJS() --- docs/03_options.md | 15 ++++++++------- src/doc/Document.ts | 2 -- src/nodes/Pair.ts | 6 +++++- src/nodes/YAMLMap.ts | 6 +++++- src/nodes/toJS.ts | 10 +++++++++- src/options.ts | 11 +++++++++++ tests/doc/parse.ts | 21 +++++++++++++++++++++ 7 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/03_options.md b/docs/03_options.md index 62af5ad1..b8e3be77 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -121,13 +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 | -| ------------- | ------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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. | -| 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/doc/Document.ts b/src/doc/Document.ts index 3b0ae1b3..4fc583a4 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -360,8 +360,6 @@ export class Document { /** A plain JavaScript representation of the document `value`. */ toJS(opt: ToJSOptions = {}): any { - if (opt.freeze && opt.reviver) - throw new Error('Incompatible options: freeze and reviver') const ctx = new ToJSContext(opt) const res = this.value.toJS(this, ctx) if (typeof opt.onAnchor === 'function') { 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 59a934c9..6e2843b8 100644 --- a/src/nodes/YAMLMap.ts +++ b/src/nodes/YAMLMap.ts @@ -217,7 +217,11 @@ 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) diff --git a/src/nodes/toJS.ts b/src/nodes/toJS.ts index c4e2f54c..7c8ef817 100644 --- a/src/nodes/toJS.ts +++ b/src/nodes/toJS.ts @@ -15,13 +15,21 @@ export class ToJSContext { mapAsMap: boolean mapKeyWarned = false maxAliasCount: number + preferNullPrototype: boolean reviverSources?: WeakMap> constructor(opt: ToJSOptions = {}) { 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 44667bad..e3406698 100644 --- a/src/options.ts +++ b/src/options.ts @@ -194,6 +194,8 @@ export type ToJSOptions = { /** * Use Map rather than Object to represent mappings. * + * Not compatible with `preferNullPrototype: true`. + * * Default: `false` */ mapAsMap?: boolean @@ -212,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`: diff --git a/tests/doc/parse.ts b/tests/doc/parse.ts index 226d57fe..b6c8971a 100644 --- a/tests/doc/parse.ts +++ b/tests/doc/parse.ts @@ -768,6 +768,7 @@ describe('freeze', () => { 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', () => { @@ -782,6 +783,7 @@ describe('freeze', () => { ) 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', () => { @@ -794,6 +796,25 @@ describe('freeze', () => { ]) 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() }) })