Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for pathlike aliases #380

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions src/compose/compose-doc.ts
Expand Up @@ -24,6 +24,7 @@ export function composeDoc(
const opts = Object.assign({ _directives: directives }, options)
const doc = new Document(undefined, opts) as Document.Parsed
const ctx: ComposeContext = {
anchors: options.version === 'next' ? new Map() : null,
atRoot: true,
directives: doc.directives,
options: doc.options,
Expand Down
23 changes: 21 additions & 2 deletions src/compose/compose-node.ts
Expand Up @@ -11,6 +11,7 @@ import { resolveEnd } from './resolve-end.js'
import { emptyScalarPosition } from './util-empty-scalar-position.js'

export interface ComposeContext {
anchors: Map<string, ParsedNode> | null
atRoot: boolean
directives: Directives
options: Readonly<Required<Omit<ParseOptions, 'lineCounter'>>>
Expand Down Expand Up @@ -52,13 +53,13 @@ export function composeNode(
case 'double-quoted-scalar':
case 'block-scalar':
node = composeScalar(ctx, token, tag, onError)
if (anchor) node.anchor = anchor.source.substring(1)
if (anchor) setAnchor(ctx, node, anchor, onError)
break
case 'block-map':
case 'block-seq':
case 'flow-collection':
node = composeCollection(CN, ctx, token, tag, onError)
if (anchor) node.anchor = anchor.source.substring(1)
if (anchor) setAnchor(ctx, node, anchor, onError)
break
default: {
const message =
Expand Down Expand Up @@ -138,3 +139,21 @@ function composeAlias(
if (re.comment) alias.comment = re.comment
return alias as Alias.Parsed
}

function setAnchor(
{ anchors }: ComposeContext,
node: ParsedNode,
anchor: SourceToken,
onError: ComposeErrorHandler
) {
const name = anchor.source.substring(1)
if (anchors) {
if (anchors.has(name)) {
const msg = `Anchors must be unique, ${name} is repeated`
onError(node.range, 'DUPLICATE_ANCHOR', msg)
} else {
anchors.set(name, node)
}
}
node.anchor = name
}
7 changes: 5 additions & 2 deletions src/doc/Document.ts
Expand Up @@ -424,11 +424,14 @@ export class Document<T extends Node = Node> {
mapAsMap: mapAsMap === true,
mapKeyWarned: false,
maxAliasCount: typeof maxAliasCount === 'number' ? maxAliasCount : 100,
resolved: new WeakMap(),
stringify
}
const res = toJS(this.contents, jsonArg ?? '', ctx)
if (typeof onAnchor === 'function')
for (const { count, res } of ctx.anchors.values()) onAnchor(res, count)
if (typeof onAnchor === 'function') {
for (const [node, { count }] of ctx.anchors)
onAnchor(ctx.resolved.get(node), count)
}
return typeof reviver === 'function'
? applyReviver(reviver, { '': res }, '', res)
: res
Expand Down
1 change: 1 addition & 0 deletions src/errors.ts
Expand Up @@ -10,6 +10,7 @@ export type ErrorCode =
| 'BAD_SCALAR_START'
| 'BLOCK_AS_IMPLICIT_KEY'
| 'BLOCK_IN_FLOW'
| 'DUPLICATE_ANCHOR'
| 'DUPLICATE_KEY'
| 'IMPOSSIBLE'
| 'KEY_OVER_1024_CHARS'
Expand Down
71 changes: 50 additions & 21 deletions src/nodes/Alias.ts
Expand Up @@ -8,6 +8,7 @@ import {
isAlias,
isCollection,
isPair,
isScalar,
Node,
NodeBase,
Range
Expand All @@ -24,6 +25,8 @@ export declare namespace Alias {
}
}

const RESOLVE = Symbol('_resolve')

export class Alias extends NodeBase {
source: string

Expand All @@ -39,46 +42,72 @@ export class Alias extends NodeBase {
})
}

/**
* Resolve the value of this alias within `doc`, finding the last
* instance of the `source` anchor before this node.
*/
resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined {
[RESOLVE](doc: Document) {
let found: Scalar | YAMLMap | YAMLSeq | undefined = undefined
// @ts-expect-error - TS doesn't notice the assignment in the visitor
let root: Node & { anchor: string } = undefined
const pathLike = this.source.includes('/')
visit(doc, {
Node: (_key: unknown, node: Node) => {
if (node === this) return visit.BREAK
if (node.anchor === this.source) found = node
const { anchor } = node
if (anchor === this.source) {
found = node
} else if (
doc.directives?.yaml.version === 'next' &&
anchor &&
pathLike &&
this.source.startsWith(anchor + '/') &&
(!root || root.anchor.length <= anchor.length)
) {
root = node as Node & { anchor: string }
}
}
})
return found
if (found) return { node: found, root: found }

if (isCollection(root)) {
const parts = this.source.substring(root.anchor.length + 1).split('/')
const node = root.getIn(parts, true)
if (isCollection(node) || isScalar(node)) return { node, root }
}

return { node: undefined, root }
}

/**
* Resolve the value of this alias within `doc`, finding the last
* instance of the `source` anchor before this node.
*/
resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined {
return this[RESOLVE](doc).node
}

toJSON(_arg?: unknown, ctx?: ToJSContext) {
if (!ctx) return { source: this.source }
const { anchors, doc, maxAliasCount } = ctx
const source = this.resolve(doc)
if (!source) {
const { anchors, doc, maxAliasCount, resolved } = ctx
const { node, root } = this[RESOLVE](doc)
if (!node) {
const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`
throw new ReferenceError(msg)
}
const data = anchors.get(source)
/* istanbul ignore if */
if (!data || data.res === undefined) {
const msg = 'This should not happen: Alias anchor was not resolved?'
throw new ReferenceError(msg)
}

if (maxAliasCount >= 0) {
const data = anchors.get(root)
if (!data) {
const msg = 'This should not happen: Alias anchor was not resolved?'
throw new ReferenceError(msg)
}
data.count += 1
if (data.aliasCount === 0)
data.aliasCount = getAliasCount(doc, source, anchors)
data.aliasCount ||= getAliasCount(doc, root, anchors)
if (data.count * data.aliasCount > maxAliasCount) {
const msg =
'Excessive alias count indicates a resource exhaustion attack'
throw new ReferenceError(msg)
}
}
return data.res

return resolved.get(node)
}

toString(
Expand All @@ -105,8 +134,8 @@ function getAliasCount(
anchors: ToJSContext['anchors']
): number {
if (isAlias(node)) {
const source = node.resolve(doc)
const anchor = anchors && source && anchors.get(source)
const { root } = node[RESOLVE](doc)
const anchor = root && anchors?.get(root)
return anchor ? anchor.count * anchor.aliasCount : 0
} else if (isCollection(node)) {
let count = 0
Expand Down
19 changes: 10 additions & 9 deletions src/nodes/toJS.ts
Expand Up @@ -5,7 +5,6 @@ import { hasAnchor, Node } from './Node.js'
export interface AnchorData {
aliasCount: number
count: number
res: unknown
}

export interface ToJSContext {
Expand All @@ -16,6 +15,7 @@ export interface ToJSContext {
mapKeyWarned: boolean
maxAliasCount: number
onCreate?: (res: unknown) => void
resolved: WeakMap<Node, unknown>

/** Requiring this directly in Pair would create circular dependencies */
stringify: typeof stringify
Expand All @@ -35,16 +35,17 @@ export function toJS(value: any, arg: string | null, ctx?: ToJSContext): any {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
if (Array.isArray(value)) return value.map((v, i) => toJS(v, String(i), ctx))
if (value && typeof value.toJSON === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (!ctx || !hasAnchor(value)) return value.toJSON(arg, ctx)
const data: AnchorData = { aliasCount: 0, count: 1, res: undefined }
ctx.anchors.set(value, data)
ctx.onCreate = res => {
data.res = res
delete ctx.onCreate
if (ctx) {
if (hasAnchor(value)) ctx.anchors.set(value, { aliasCount: 0, count: 1 })
ctx.onCreate = res => {
ctx.onCreate = undefined
ctx.resolved.set(value, res)
}
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const res = value.toJSON(arg, ctx)
if (ctx.onCreate) ctx.onCreate(res)
if (ctx?.onCreate) ctx.onCreate(res)
return res
}
if (typeof value === 'bigint' && !ctx?.keep) return Number(value)
Expand Down
76 changes: 76 additions & 0 deletions tests/next.ts
@@ -0,0 +1,76 @@
import { parse, parseDocument } from 'yaml'
import { source } from './_utils'

describe('relative-path alias', () => {
test('resolves a map value by key', () => {
const src = source`
- &a { foo: 1 }
- *a/foo
`
expect(parse(src, { version: 'next' })).toEqual([{ foo: 1 }, 1])
})

test('resolves a sequence value by index', () => {
const src = source`
- &a [ 2, 4, 8 ]
- *a/1
`
expect(parse(src, { version: 'next' })).toEqual([[2, 4, 8], 4])
})

test('resolves a deeper value', () => {
const src = source`
- &a { foo: [1, 42] }
- *a/foo/1
`
expect(parse(src, { version: 'next' })).toEqual([{ foo: [1, 42] }, 42])
})

test('resolves to an equal value', () => {
const src = source`
- &a { foo: [42] }
- *a/foo
`
const res = parse(src, { version: 'next' })
expect(res[1]).toBe(res[0].foo)
})

test('does not resolve an alias value', () => {
const src = source`
- &a { foo: *a }
- *a/foo
`
const doc = parseDocument(src, { version: 'next' })
expect(() => doc.toJS()).toThrow(ReferenceError)
})

test('does not resolve a later value', () => {
const src = source`
- *a/foo
- &a { foo: 1 }
`
const doc = parseDocument(src, { version: 'next' })
expect(() => doc.toJS()).toThrow(ReferenceError)
})
})

describe('unique anchors', () => {
test('repeats are fine without flag', () => {
const src = source`
- &a 1
- &a 2
- *a
`
expect(parse(src)).toEqual([1, 2, 2])
})

test("repeats are an error with 'next'", () => {
const src = source`
- &a 1
- &a 2
- *a
`
const doc = parseDocument(src, { version: 'next' })
expect(doc.errors).toMatchObject([{ code: 'DUPLICATE_ANCHOR' }])
})
})