Skip to content
Merged
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
9 changes: 8 additions & 1 deletion sjsonnet/src-js/sjsonnet/Platform.scala
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,14 @@ object Platform {
}
ujson.Arr(buf)
}
case Left(e) => Error.fail("Error converting YAML to JSON: " + e.getMessage)
case Left(e) =>
// scala-yaml rejects cyclic (and forward) aliases at compose time with
// "There is no anchor for <identity hash> alias"; the hash is
// non-deterministic, so normalize to the same stable message the JVM
// backend produces.
if (e.getMessage != null && e.getMessage.startsWith("There is no anchor for"))
Error.fail("Recursive YAML alias reference")
else Error.fail("Error converting YAML to JSON: " + e.getMessage)
}
}

Expand Down
85 changes: 52 additions & 33 deletions sjsonnet/src-jvm/sjsonnet/Platform.scala
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,18 @@ object Platform {
private def parseYamlDecimalLong(value: String): ujson.Num =
ujson.Num(java.lang.Long.parseLong(value).toDouble)

private def yamlNodeToJson(node: Node, input: String): ujson.Value = node match {
private def yamlNodeToJson(node: Node, input: String): ujson.Value =
yamlNodeToJson(node, input, new java.util.IdentityHashMap[Node, java.lang.Boolean]())

// SnakeYAML resolves an alias to the anchor's Node instance, so a cyclic alias
// (e.g. `a: &x [*x]`) re-enters a collection node that is still being converted
// and would overflow the stack. Only collection nodes are tracked (scalars
// cannot contain references); revisiting a completed node is a shared (DAG)
// alias and stays legal.
private def yamlNodeToJson(
node: Node,
input: String,
inProgress: java.util.IdentityHashMap[Node, java.lang.Boolean]): ujson.Value = node match {
case sn: ScalarNode =>
val rawValue = sn.getValue
// SnakeYAML 2.x strips the trailing newline from clip-chomped block scalars
Expand Down Expand Up @@ -249,43 +260,51 @@ object Platform {
}

case mn: MappingNode =>
val buf = upickle.core.LinkedHashMap[String, ujson.Value]()
buf.sizeHint(mn.getValue.size)
for (tuple <- mn.getValue.asScala) {
val keyNode = tuple.getKeyNode
if (keyNode.getTag == Tag.MERGE) {
// YAML merge key (<<): merge referenced mapping(s) with lower priority.
// Convert to JSON first so nested merge keys are resolved recursively.
val mergeObjs: Seq[ujson.Obj] = tuple.getValueNode match {
case mapNode: MappingNode =>
Seq(yamlNodeToJson(mapNode, input).asInstanceOf[ujson.Obj])
case seqNode: SequenceNode =>
seqNode.getValue.asScala.map { node =>
yamlNodeToJson(node, input).asInstanceOf[ujson.Obj]
}.toSeq
case other => Error.fail("Invalid YAML merge value: " + other.getTag)
}
for (obj <- mergeObjs; (k, v) <- obj.value) {
if (!buf.contains(k)) {
buf(k) = v
if (inProgress.put(mn, java.lang.Boolean.TRUE) != null)
Error.fail("Recursive YAML alias reference")
try {
val buf = upickle.core.LinkedHashMap[String, ujson.Value]()
buf.sizeHint(mn.getValue.size)
for (tuple <- mn.getValue.asScala) {
val keyNode = tuple.getKeyNode
if (keyNode.getTag == Tag.MERGE) {
// YAML merge key (<<): merge referenced mapping(s) with lower priority.
// Convert to JSON first so nested merge keys are resolved recursively.
val mergeObjs: Seq[ujson.Obj] = tuple.getValueNode match {
case mapNode: MappingNode =>
Seq(yamlNodeToJson(mapNode, input, inProgress).asInstanceOf[ujson.Obj])
case seqNode: SequenceNode =>
seqNode.getValue.asScala.map { node =>
yamlNodeToJson(node, input, inProgress).asInstanceOf[ujson.Obj]
}.toSeq
case other => Error.fail("Invalid YAML merge value: " + other.getTag)
}
for (obj <- mergeObjs; (k, v) <- obj.value) {
if (!buf.contains(k)) {
buf(k) = v
}
}
} else {
val key = keyNode match {
case sn: ScalarNode => yamlScalarKey(sn, input)
case other => Error.fail("Invalid YAML mapping key type: " + other.getTag)
}
buf(key) = yamlNodeToJson(tuple.getValueNode, input, inProgress)
}
} else {
val key = keyNode match {
case sn: ScalarNode => yamlScalarKey(sn, input)
case other => Error.fail("Invalid YAML mapping key type: " + other.getTag)
}
buf(key) = yamlNodeToJson(tuple.getValueNode, input)
}
}
ujson.Obj(buf)
ujson.Obj(buf)
} finally inProgress.remove(mn)

case sn: SequenceNode =>
val buf = new mutable.ArrayBuffer[ujson.Value](sn.getValue.size)
for (n <- sn.getValue.asScala) {
buf += yamlNodeToJson(n, input)
}
ujson.Arr(buf)
if (inProgress.put(sn, java.lang.Boolean.TRUE) != null)
Error.fail("Recursive YAML alias reference")
try {
val buf = new mutable.ArrayBuffer[ujson.Value](sn.getValue.size)
for (n <- sn.getValue.asScala) {
buf += yamlNodeToJson(n, input, inProgress)
}
ujson.Arr(buf)
} finally inProgress.remove(sn)

case _ =>
Error.fail("Unsupported YAML node type: " + node.getClass.getSimpleName)
Expand Down
9 changes: 8 additions & 1 deletion sjsonnet/src-native/sjsonnet/Platform.scala
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,14 @@ object Platform {
}
ujson.Arr(buf)
}
case Left(e) => Error.fail("Error converting YAML to JSON: " + e.getMessage)
case Left(e) =>
// scala-yaml rejects cyclic (and forward) aliases at compose time with
// "There is no anchor for <identity hash> alias"; the hash is
// non-deterministic, so normalize to the same stable message the JVM
// backend produces.
if (e.getMessage != null && e.getMessage.startsWith("There is no anchor for"))
Error.fail("Recursive YAML alias reference")
else Error.fail("Error converting YAML to JSON: " + e.getMessage)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Cyclic YAML aliases must fail with a clean error instead of overflowing
// the stack (previously: java.lang.StackOverflowError crashed the process).
std.parseYaml("a: &x [*x]")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
sjsonnet.Error: [std.parseYaml] Recursive YAML alias reference
at [<root>].(error.parseYaml_cyclic_alias.jsonnet:3:14)

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// A cycle appearing only after several successful conversions (including a
// shared alias) must still be reported as a cycle: the in-progress set must
// neither false-positive on the earlier nodes nor miss the later cycle.
std.parseYaml("ok1: {v: 1}\nok2: [1, 2, 3]\nshared: &s {a: 1}\nreuse: *s\nbad: &x [*x]")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
sjsonnet.Error: [std.parseYaml] Recursive YAML alias reference
at [<root>].(error.parseYaml_cyclic_alias_after_ok.jsonnet:4:14)

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Cyclic alias through a mapping: the anchor value contains itself.
std.parseYaml("a: &x {b: *x}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
sjsonnet.Error: [std.parseYaml] Recursive YAML alias reference
at [<root>].(error.parseYaml_cyclic_alias_mapping.jsonnet:2:14)

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Shared (non-cyclic) aliases must keep working after the cycle guard:
// the same anchor referenced twice is a DAG, not a cycle.
std.assertEqual(
std.parseYaml("a: &x {v: 1}\nb: *x\nc: *x"),
{a: {v: 1}, b: {v: 1}, c: {v: 1}}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
true
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Diamond sharing plus nested alias reuse: the same anchor referenced multiple
// times at different positions (including twice within one sequence) is a DAG,
// not a cycle. The cycle guard must not reject it, and each reference converts
// to an equal value.
local dag = std.parseYaml("a: &x {v: 1}\nb: *x\nc: *x\nd: [*x, *x]\ninner: &in {w: 2}\ne: {p: *in, q: *in}");
std.assertEqual(dag, {
a: {v: 1}, b: {v: 1}, c: {v: 1},
d: [{v: 1}, {v: 1}],
inner: {w: 2},
e: {p: {w: 2}, q: {w: 2}},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
true
Loading