PureYAML/Sources/Emitting/Dumper.NestingLimit.swift
Mihaela Mihaljevic 2fcf54684e fix: correct emitter and tagged round-trip and validation bugs
Seven correctness fixes to the recently landed anchors/styles, canonical,
timestamp/binary, and full-node-key work, each reproduced and covered by a
regression test.

- Stream dump doubled the `---` marker under explicitDocumentStart or
  canonical: the single-document body now renders marker-free and the
  stream path owns marker emission, so a two-document stream reparses as
  two documents, not four.
- Tagged flow-mapping keys used block-context safety, so a key like `a,b`
  emitted bare and reparsed as several entries; flow keys now use the
  flow-aware plain-scalar check.
- Plain scalars ending in `:` reparse as an empty-valued mapping key, so
  both the model and tagged emit paths now fall back to quoting them.
- An alias mapping key pointing at a defined non-scalar anchor raised the
  misleading undefinedAlias; it now raises expectedScalarKey, and a truly
  undefined alias key still raises undefinedAlias.
- Tagged duplicate-key detection keyed on source spelling: it now keys on
  the resolved scalar value plus effective tag, so 0x1 and 1 collide while
  1 and "1" do not and custom-tagged keys stay distinct.
- Dumping a tree nested past the emitter limit overflowed the stack.

- Timestamp now accepts a leap-second value of 60, counted as 60 elapsed
  seconds in the epoch math.
- Tagged.Dumper honors sortKeys in its block and canonical paths for
  all-plain-string-key mappings, matching the model dumper.

Nesting-guard design: dump is public and non-throwing across 90+ call
sites, so making it throwing would be a breaking change. A tree nested
deeper than the parser's own maxNestingDepth cannot round-trip through
PureYAML (the parser rejects it), so no faithful emission exists. Each
dump entry point now measures depth iteratively (no recursion, so the
guard itself cannot overflow) and, past the limit, returns a documented,
reparseable marker document that resolves to null. Under the limit the
existing recursive renderers run byte-identically, so parsed trees and
every realistic hand-built tree are unaffected. This mirrors the parser's
enforceNestingLimit, which bounds depth before any recursive consumer.
2026-07-16 16:29:08 +02:00

78 lines
3.2 KiB
Swift

extension PureYAML.Emitting {
/// The deepest collection nesting the recursive renderers walk before a
/// value is refused. It matches the parser's ``PureYAML/Parsing/Parser/maxNestingDepth``
/// exactly: a value nested deeper than the parser accepts can never round-trip
/// through PureYAML, so emitting it would produce YAML the paired parser
/// rejects. Parsed trees are always within this bound, so real round-trips
/// are unaffected.
static var maxNestingDepth: Int {
PureYAML.Parsing.Parser.maxNestingDepth
}
/// The reparseable marker returned for a value nested past ``maxNestingDepth``.
/// It is a valid YAML document that resolves to `null`, with a comment that
/// names the refused limit, so a caller never receives a crash or silent
/// truncation.
static var nestingLimitDocument: String {
"# value nesting exceeds the emitter limit of \(maxNestingDepth); not serialized\nnull\n"
}
/// Iteratively (no recursion, so this cannot itself overflow) reports whether
/// a model value nests collections deeper than ``maxNestingDepth``. Complex
/// keys count toward depth like values; scalars are leaves.
static func exceedsNestingLimit(_ value: PureYAML.Model.Value) -> Bool {
var stack: [(value: PureYAML.Model.Value, depth: Int)] = [(value, 1)]
while let (node, depth) = stack.popLast() {
switch node {
case let .sequence(values):
if depth > maxNestingDepth {
return true
}
for child in values {
stack.append((child, depth + 1))
}
case let .mapping(mapping):
if depth > maxNestingDepth {
return true
}
for pair in mapping.pairs {
stack.append((pair.value, depth + 1))
stack.append((pair.keyNode.value, depth + 1))
}
case .null, .bool, .int, .double, .string:
continue
}
}
return false
}
/// Iteratively reports whether a tagged node nests collections deeper than
/// ``maxNestingDepth``. An alias renders as a `*name` reference, not an
/// expanded subtree, so it is a leaf here and its aliased node is not walked.
static func exceedsNestingLimit(_ node: PureYAML.Tagged.Node) -> Bool {
var stack: [(node: PureYAML.Tagged.Node, depth: Int)] = [(node, 1)]
while let (current, depth) = stack.popLast() {
switch current {
case let .sequence(sequence):
if depth > maxNestingDepth {
return true
}
for child in sequence.values {
stack.append((child, depth + 1))
}
case let .mapping(mapping):
if depth > maxNestingDepth {
return true
}
for pair in mapping.pairs {
stack.append((pair.value, depth + 1))
stack.append((pair.keyNode, depth + 1))
}
case .scalar, .alias:
continue
}
}
return false
}
}