Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/03_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/nodes/Pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export class Pair<
}

toJS(doc: Document<DocValue>, 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
}
Expand Down
7 changes: 6 additions & 1 deletion src/nodes/YAMLMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,14 @@ export class YAMLMap<
ctx?: ToJSContext
): Map<any, any> | Record<any, any> {
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
}

Expand Down
20 changes: 7 additions & 13 deletions src/nodes/YAMLSeq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,20 +184,14 @@ export class YAMLSeq<
/** A plain JavaScript representation of this node. */
toJS(doc: Document<DocValue>, 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
}

Expand Down
14 changes: 12 additions & 2 deletions src/nodes/toJS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, Map<unknown, string>>

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 {
Expand Down
24 changes: 24 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`:
Expand All @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions tests/doc/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>([
['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)
Expand Down
Loading