fix(parsing): reject an anchor or tag on an alias node

An alias is a bare reference and cannot carry node properties, so '&b *a'
(an anchor on an alias) and a tag on an alias are malformed. parseNode
ignored any pending anchor or tag when it reached an alias token, dropping
the property and accepting the document.

The alias case now rejects a pending anchor or tag, and moves to its own
parseAlias helper so parseNode stays within the complexity limit. A bare
alias and an anchor or tag on a real node are unaffected.

Measured against the official YAML test-suite, parse-level conformance
rises from 266/333 (79.9%) to 267/333 (80.2%) with no regression.
This commit is contained in:
Mihaela M 2026-06-27 21:11:10 +02:00
parent 8649f1722a
commit 779ebe596d
4 changed files with 54 additions and 6 deletions

View File

@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- An alias node can no longer carry an anchor or tag. An alias is a bare reference, so `&b *a`
(an anchor on an alias) and a tag on an alias are now rejected; the parser previously dropped the
property and accepted the document. A bare alias and an anchor or tag on a real node are
unaffected. Lifts official test-suite parse-level conformance from 266/333 (79.9%) to 267/333
(80.2%), with no regression.
- A comment can no longer interrupt a multi-line plain scalar. A comment terminates a plain
scalar, so content that tries to continue it on a following line (`key: word1` then a `#` line
then ` word2`, or `a` then `b # ...` then `c`) is now rejected instead of being silently merged

View File

@ -27,6 +27,7 @@ public extension PureYAML.Parsing {
case blockSequenceAsInlineMappingValue(line: Int)
case multilineImplicitKey(line: Int)
case commentInMultilineScalar(line: Int)
case aliasWithProperties(line: Int, column: Int)
public var description: String {
switch self {
@ -82,6 +83,8 @@ public extension PureYAML.Parsing {
"an implicit mapping key must not span multiple lines at line \(line)"
case let .commentInMultilineScalar(line):
"a comment cannot interrupt a multi-line plain scalar at line \(line)"
case let .aliasWithProperties(line, column):
"an alias node cannot have an anchor or tag at line \(line), column \(column)"
}
}
@ -139,6 +142,8 @@ public extension PureYAML.Parsing {
"multilineImplicitKey"
case .commentInMultilineScalar:
"commentInMultilineScalar"
case .aliasWithProperties:
"aliasWithProperties"
}
}
@ -170,7 +175,8 @@ public extension PureYAML.Parsing {
let .unexpectedEvent(_, _, line, _),
let .unexpectedToken(_, _, line, _),
let .nestingTooDeep(line, _),
let .commentNotPrecededByWhitespace(line, _):
let .commentNotPrecededByWhitespace(line, _),
let .aliasWithProperties(line, _):
line
}
}
@ -203,7 +209,8 @@ public extension PureYAML.Parsing {
let .unexpectedEvent(_, _, _, column),
let .unexpectedToken(_, _, _, column),
let .nestingTooDeep(_, column),
let .commentNotPrecededByWhitespace(_, column):
let .commentNotPrecededByWhitespace(_, column),
let .aliasWithProperties(_, column):
column
}
}

View File

@ -207,6 +207,21 @@ extension PureYAML.Parsing.TokenEventParser {
throw PureYAML.Parsing.ParseError.nestingTooDeep(line: mark.line, column: mark.column)
}
/// An alias is a bare reference, so it cannot carry an anchor or tag of its own (`&b *a`).
mutating func parseAlias(
anchor: String,
token: PureYAML.Parsing.Token,
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
guard properties.anchor == nil, properties.tag == nil else {
let mark = properties.mark ?? token.mark
throw PureYAML.Parsing.ParseError.aliasWithProperties(line: mark.line, column: mark.column)
}
_ = try cursor.advance()
try emit(.alias(anchor: anchor, mark: token.mark))
return PureYAML.Parsing.NodeResult(kind: .alias, endMark: token.endMark)
}
mutating func parseNode(
properties initialProperties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
@ -223,10 +238,7 @@ extension PureYAML.Parsing.TokenEventParser {
switch token.kind {
case let .alias(anchor):
_ = try cursor.advance()
let mark = properties.mark ?? token.mark
try emit(.alias(anchor: anchor, mark: mark))
return PureYAML.Parsing.NodeResult(kind: .alias, endMark: token.endMark)
return try parseAlias(anchor: anchor, token: token, properties: properties)
case .blockEntry:
return try parseBlockSequence(properties: properties)
case .blockScalarHeader:

View File

@ -0,0 +1,24 @@
@testable import PureYAML
import Testing
@Suite("An alias node carries no properties")
struct AliasPropertiesTests {
@Test("An anchor or tag on an alias is rejected", arguments: [
"key1: &a value\nkey2: &b *a\n", // an anchor on an alias
"key1: &a value\nkey2: !!str *a\n", // a tag on an alias
])
func test_aliasWithPropertiesRejected(_ yaml: String) throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse(yaml)
}
}
@Test("A bare alias, and an anchor on a real node, stay valid")
func test_validAliasesAndAnchors() throws {
#expect(try PureYAML.parse("key1: &a value\nkey2: *a\n") == .mapping(.init([
.init(key: "key1", value: .string("value")),
.init(key: "key2", value: .string("value")),
])))
#expect(try PureYAML.parse("- &a x\n- *a\n") == .sequence(.init([.string("x"), .string("x")])))
}
}