Skip to content

Commit 6fc4356

Browse files
committed
fix: keep manifestXmlJsonml output well-formed XML
Motivation: Jsonnet documents std.manifestXmlJsonml as converting a JsonML-encoded value to a string containing XML. XML names and characters must satisfy XML 1.0, and a JsonML element is array-shaped. The previous implementation accepted a bare top-level string and silently dropped illegal XML control characters, which could either produce non-JsonML output or lose user data. Modification: Replace scalatags rendering with a small XML renderer, validate XML element and attribute names, reject top-level non-array values, reject text and attribute characters outside the XML 1.0 Char production, escape XML-sensitive text and attribute characters, stringify complex attribute values with compact JSON, and add regression tests for invalid names, invalid XML characters, top-level non-array input, complex attributes, valid XML names, and supplementary Unicode characters. Result: sjsonnet now rejects inputs that cannot produce XML instead of emitting invalid markup or silently dropping data, while preserving valid XML names and Unicode text. The behavior intentionally follows the Jsonnet documentation XML contract even where cpp-jsonnet, go-jsonnet, and jrsonnet currently emit invalid XML for some cases. References: - https://jsonnet.org/ref/stdlib.html#std-manifestXmlJsonml - https://www.w3.org/TR/xml/#NT-Name - https://www.w3.org/TR/xml/#charsets - https://www.w3.org/TR/xml/#sec-starttags - https://www.jsonml.org/syntax/
1 parent e686d89 commit 6fc4356

13 files changed

Lines changed: 142 additions & 26 deletions

sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,105 @@ object ManifestModule extends AbstractFunctionModule {
3737
case ujson.Null => "null"
3838
}
3939

40+
private def isXmlNameStartChar(c: Int): Boolean =
41+
c == ':' || c == '_' ||
42+
(c >= 'A' && c <= 'Z') ||
43+
(c >= 'a' && c <= 'z') ||
44+
(c >= 0xc0 && c <= 0xd6) ||
45+
(c >= 0xd8 && c <= 0xf6) ||
46+
(c >= 0xf8 && c <= 0x2ff) ||
47+
(c >= 0x370 && c <= 0x37d) ||
48+
(c >= 0x37f && c <= 0x1fff) ||
49+
(c >= 0x200c && c <= 0x200d) ||
50+
(c >= 0x2070 && c <= 0x218f) ||
51+
(c >= 0x2c00 && c <= 0x2fef) ||
52+
(c >= 0x3001 && c <= 0xd7ff) ||
53+
(c >= 0xf900 && c <= 0xfdcf) ||
54+
(c >= 0xfdf0 && c <= 0xfffd) ||
55+
(c >= 0x10000 && c <= 0xeffff)
56+
57+
private def isXmlNameChar(c: Int): Boolean =
58+
isXmlNameStartChar(c) ||
59+
c == '-' || c == '.' ||
60+
(c >= '0' && c <= '9') ||
61+
c == 0xb7 ||
62+
(c >= 0x300 && c <= 0x36f) ||
63+
(c >= 0x203f && c <= 0x2040)
64+
65+
private def isXmlChar(c: Int): Boolean =
66+
c == 0x9 || c == 0xa || c == 0xd ||
67+
(c >= 0x20 && c <= 0xd7ff) ||
68+
(c >= 0xe000 && c <= 0xfffd) ||
69+
(c >= 0x10000 && c <= 0x10ffff)
70+
71+
private def isXmlName(s: String): Boolean = {
72+
if (s.isEmpty) false
73+
else {
74+
var i = 0
75+
var c = s.codePointAt(i)
76+
if (!isXmlNameStartChar(c)) false
77+
else {
78+
i += Character.charCount(c)
79+
while (i < s.length) {
80+
c = s.codePointAt(i)
81+
if (!isXmlNameChar(c)) return false
82+
i += Character.charCount(c)
83+
}
84+
true
85+
}
86+
}
87+
}
88+
89+
private def codePointName(c: Int): String =
90+
"U+" + c.toHexString.toUpperCase.reverse.padTo(4, '0').reverse
91+
92+
private def quotedJsonString(s: String): String = {
93+
val out = new StringBuilderWriter(s.length + 2)
94+
BaseRenderer.escape(out, s, unicode = false)
95+
out.toString
96+
}
97+
98+
private def xmlNameOrFail(name: String, kind: String): String = {
99+
// std.manifestXmlJsonml returns XML. XML start-tags, end-tags, empty-element tags, and
100+
// attributes all use the XML 1.0 Name production, so names that cannot form XML are rejected.
101+
if (!isXmlName(name)) {
102+
Error.fail(
103+
s"invalid XML $kind name " + quotedJsonString(name)
104+
)
105+
}
106+
name
107+
}
108+
109+
private def xmlAttrValue(v: ujson.Value): String = v match {
110+
case ujson.Str(str) => str
111+
case ujson.Num(n) => ujson.write(n)
112+
case ujson.True => "true"
113+
case ujson.False => "false"
114+
case ujson.Null => "null"
115+
case other => ujson.write(other)
116+
}
117+
118+
private def appendXmlEscaped(s: String, out: StringBuilder, attribute: Boolean): Unit = {
119+
var i = 0
120+
while (i < s.length) {
121+
val c = s.codePointAt(i)
122+
if (!isXmlChar(c)) Error.fail("invalid XML character " + codePointName(c))
123+
c match {
124+
case '<' => out.append("&lt;")
125+
case '>' => out.append("&gt;")
126+
case '&' => out.append("&amp;")
127+
case '"' if attribute => out.append("&quot;")
128+
case _ =>
129+
if (c <= 0xffff) out.append(c.toChar)
130+
else {
131+
out.append(Character.highSurrogate(c))
132+
out.append(Character.lowSurrogate(c))
133+
}
134+
}
135+
i += Character.charCount(c)
136+
}
137+
}
138+
40139
/**
41140
* [[https://jsonnet.org/ref/stdlib.html#std-manifestJson std.manifestJson(value)]].
42141
*
@@ -619,39 +718,39 @@ object ManifestModule extends AbstractFunctionModule {
619718
* the JsonML input.
620719
*/
621720
builtin("manifestXmlJsonml", "value") { (pos, ev, value: Val) =>
622-
import scalatags.Text.all.{value => _, _}
623-
def rec(v: ujson.Value): Frag = {
721+
def rec(v: ujson.Value, out: StringBuilder): Unit = {
624722
v match {
625-
case ujson.Str(ss) => ss
723+
case ujson.Str(ss) => appendXmlEscaped(ss, out, attribute = false)
626724
case ujson.Arr(mutable.Seq(ujson.Str(t), attrs: ujson.Obj, children @ _*)) =>
627-
tag(t)(
628-
// TODO remove the `toSeq` once this is fixed in scala3
629-
attrs.value.toSeq.map {
630-
case (k, ujson.Str(v)) => attr(k) := v
631-
632-
// use ujson.write to make sure output number format is same as
633-
// google/jsonnet, e.g. whole numbers are printed without the
634-
// decimal point and trailing zero
635-
case (k, ujson.Num(v)) => attr(k) := ujson.write(v)
636-
637-
case (k, ujson.True) => attr(k) := "true"
638-
case (k, ujson.False) => attr(k) := "false"
639-
case (k, ujson.Null) => attr(k) := "null"
640-
641-
case (k, v) =>
642-
Error.fail(
643-
"std.manifestXmlJsonml: unsupported attribute type " + ujsonTypeName(v)
644-
)
645-
}.toSeq,
646-
children.map(rec)
647-
)
725+
val tagName = xmlNameOrFail(t, "tag")
726+
out.append('<').append(tagName)
727+
// TODO remove the `toSeq` once this is fixed in scala3
728+
attrs.value.toSeq.foreach { case (k, v) =>
729+
out.append(' ').append(xmlNameOrFail(k, "attribute")).append("=\"")
730+
appendXmlEscaped(xmlAttrValue(v), out, attribute = true)
731+
out.append('"')
732+
}
733+
out.append('>')
734+
children.foreach(rec(_, out))
735+
out.append("</").append(tagName).append('>')
648736
case ujson.Arr(mutable.Seq(ujson.Str(t), children @ _*)) =>
649-
tag(t)(children.map(rec).toSeq)
737+
val tagName = xmlNameOrFail(t, "tag")
738+
out.append('<').append(tagName).append('>')
739+
children.foreach(rec(_, out))
740+
out.append("</").append(tagName).append('>')
650741
case x =>
651742
Error.fail("std.manifestXmlJsonml: unsupported type " + ujsonTypeName(x))
652743
}
653744
}
654-
rec(Materializer(value)(ev)).render
745+
val out = new StringBuilder
746+
Materializer(value)(ev) match {
747+
case arr: ujson.Arr => rec(arr, out)
748+
case other =>
749+
Error.fail(
750+
s"Expected a JSONML value (an array), got ${ujsonTypeName(other)}"
751+
)
752+
}
753+
out.toString
655754
}
656755
)
657756
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
std.manifestXmlJsonml(["", {}])
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sjsonnet.Error: [std.manifestXmlJsonml] invalid XML tag name ""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
std.manifestXmlJsonml(["tag", { "bad attr": "x" }])
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sjsonnet.Error: [std.manifestXmlJsonml] invalid XML attribute name "bad attr"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
std.manifestXmlJsonml(["1tag"])
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sjsonnet.Error: [std.manifestXmlJsonml] invalid XML tag name "1tag"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
std.manifestXmlJsonml(["tag", std.char(1)])
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sjsonnet.Error: [std.manifestXmlJsonml] invalid XML character U+0001
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
std.manifestXmlJsonml("text")

0 commit comments

Comments
 (0)