Sitelet https://github.com/VirtusLab/scala-yaml/commit/f55435a2a5465cf4ac7e6c4abe7091853216bde1
Skip to content

Commit f55435a

Browse files
committed
Fix for encoding of non-tagged scalar values
1 parent 5e66df8 commit f55435a

2 files changed

Lines changed: 252 additions & 1 deletion

File tree

‎core/shared/src/main/scala/org/virtuslab/yaml/internal/dump/present/PresenterImpl.scala‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ object PresenterImpl extends Presenter {
9999
val value = s.value
100100
val style = s.style
101101
val tag = s.metadata.tag
102-
if (tag.contains(Tag.str)) {
102+
if (tag.isEmpty || tag.contains(Tag.str)) {
103103
if (
104104
(style eq ScalarStyle.DoubleQuoted) || (style eq ScalarStyle.Literal) ||
105105
(style eq ScalarStyle.Folded) || requiresDoubleQuoting(value)
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package org.virtuslab.yaml.internal.dump.present
2+
3+
import org.virtuslab.yaml._
4+
import org.virtuslab.yaml.internal.load.parse.EventKind._
5+
import org.virtuslab.yaml.internal.load.parse.NodeEventMetadata
6+
import org.virtuslab.yaml.internal.load.reader.token.ScalarStyle
7+
8+
class PresenterBugsSuite extends munit.FunSuite {
9+
10+
private val nl = System.lineSeparator()
11+
12+
private def presentScalar(
13+
value: String,
14+
tag: Tag = Tag.str,
15+
style: ScalarStyle = ScalarStyle.Plain
16+
): String =
17+
PresenterImpl.asString(
18+
Seq(
19+
DocumentStart(),
20+
Scalar(value, style, NodeEventMetadata(tag = Some(tag))),
21+
DocumentEnd()
22+
)
23+
)
24+
25+
// -----------------------------------------------------------------------
26+
// Bug 1: Trailing whitespace check is inside the `case _` default branch.
27+
//
28+
// The trailing-whitespace check in requiresDoubleQuoting only runs when
29+
// the first character doesn't match any explicit case. Strings like
30+
// "true " (first char 't') skip it, so trailing whitespace is not detected.
31+
// YAML strips trailing whitespace on plain scalars (§7.3.3), causing
32+
// data corruption.
33+
// -----------------------------------------------------------------------
34+
35+
test("bug1: trailing whitespace on boolean-like string must be quoted") {
36+
val result = presentScalar("true ")
37+
assert(
38+
result.contains("\"true \""),
39+
s"Expected 'true ' to be double-quoted, but got: $result"
40+
)
41+
}
42+
43+
test("bug1: trailing whitespace on number-like string must be quoted") {
44+
val result = presentScalar("123 ")
45+
assert(
46+
result.contains("\"123 \""),
47+
s"Expected '123 ' to be double-quoted, but got: $result"
48+
)
49+
}
50+
51+
test("bug1: trailing whitespace on null-like string must be quoted") {
52+
val result = presentScalar("null ")
53+
assert(
54+
result.contains("\"null \""),
55+
s"Expected 'null ' to be double-quoted, but got: $result"
56+
)
57+
}
58+
59+
test("bug1: trailing whitespace on float-like string must be quoted") {
60+
val result = presentScalar("3.14 ")
61+
assert(
62+
result.contains("\"3.14 \""),
63+
s"Expected '3.14 ' to be double-quoted, but got: $result"
64+
)
65+
}
66+
67+
// -----------------------------------------------------------------------
68+
// Bug 2: Trailing colon not handled.
69+
//
70+
// "a:" is not quoted. Per §7.3.3 rule [130], `:` in a plain scalar
71+
// requires a following ns-plain-safe character. A trailing colon
72+
// (followed by newline/EOF) fails that lookahead and gets parsed as
73+
// a mapping value indicator.
74+
// -----------------------------------------------------------------------
75+
76+
test("bug2: trailing colon must be quoted") {
77+
val result = presentScalar("a:")
78+
assert(
79+
result.contains("\"a:\""),
80+
s"Expected 'a:' to be double-quoted, but got: $result"
81+
)
82+
}
83+
84+
test("bug2: trailing colon followed by nothing should be quoted") {
85+
val result = presentScalar("host:")
86+
assert(
87+
result.contains("\"host:\""),
88+
s"Expected 'host:' to be double-quoted, but got: $result"
89+
)
90+
}
91+
92+
// -----------------------------------------------------------------------
93+
// Bug 4: `tag.exists(_ eq Tag.str)` uses reference equality.
94+
//
95+
// Per §3.2.1.3, tags must be compared by character-by-character string
96+
// comparison. A Tag constructed for "tag:yaml.org,2002:str" that isn't
97+
// the Tag.str singleton falls to the `else sb.append(value)` branch:
98+
// unquoted and unescaped.
99+
// -----------------------------------------------------------------------
100+
101+
test("bug4: non-singleton str tag must still trigger quoting logic") {
102+
val nonSingletonStr = CoreSchemaTag("tag:yaml.org,2002:str")
103+
assert(
104+
nonSingletonStr != Tag.str || !(nonSingletonStr eq Tag.str),
105+
"Precondition: nonSingletonStr should not be reference-equal to Tag.str"
106+
)
107+
val result = presentScalar("true", tag = nonSingletonStr)
108+
assert(
109+
result.contains("\"true\""),
110+
s"Expected 'true' with non-singleton str tag to be double-quoted, but got: $result"
111+
)
112+
}
113+
114+
test("bug4: non-singleton null tag must still trigger null rendering") {
115+
val nonSingletonNull = CoreSchemaTag("tag:yaml.org,2002:null")
116+
val result = presentScalar("", tag = nonSingletonNull)
117+
assert(
118+
result.contains("!!null"),
119+
s"Expected empty string with non-singleton null tag to render as !!null, but got: $result"
120+
)
121+
}
122+
123+
test("bug4: scalar with no tag should still apply quoting for ambiguous values") {
124+
val events = Seq(
125+
DocumentStart(),
126+
Scalar("true", ScalarStyle.Plain, NodeEventMetadata.empty),
127+
DocumentEnd()
128+
)
129+
val result = PresenterImpl.asString(events)
130+
assert(
131+
!result.trim.equals("true"),
132+
s"Expected 'true' with no tag to be quoted or handled safely, but got: $result"
133+
)
134+
}
135+
136+
// -----------------------------------------------------------------------
137+
// Bug 6: escapeFolded doesn't handle folded-style semantics.
138+
//
139+
// Per §6.5, §8.1.3, in folded style single newlines between non-empty
140+
// lines are converted to spaces (rule [72] b-as-space). The current
141+
// implementation is identical to literal style, so content with single
142+
// newlines will not round-trip correctly.
143+
// -----------------------------------------------------------------------
144+
145+
// test("bug6: folded style must round-trip single newlines correctly") {
146+
// val input = "abc\ndef"
147+
// val folded = presentScalar(input, style = ScalarStyle.Folded)
148+
// val parsed = folded.as[String]
149+
// parsed match {
150+
// case Right(result) =>
151+
// assertEquals(
152+
// result,
153+
// input,
154+
// s"Folded scalar did not round-trip. Emitted:\n$folded"
155+
// )
156+
// case Left(err) =>
157+
// fail(s"Failed to parse folded output: ${err.msg}\nEmitted:\n$folded")
158+
// }
159+
// }
160+
161+
// -----------------------------------------------------------------------
162+
// Bug 7: Block scalars at top level emit at indent == 0.
163+
//
164+
// Per §8.1.1.1 and §6.1, block scalar content indentation level is
165+
// n + m, and the explicit indicator range is 1-9 (rule [163]).
166+
// At n=0, content must still be indented at least 1 space.
167+
// -----------------------------------------------------------------------
168+
169+
// test("bug7: literal block scalar at top level must indent content") {
170+
// val input = "abc\ndef"
171+
// val result = presentScalar(input, style = ScalarStyle.Literal)
172+
// val parsed = result.as[String]
173+
// parsed match {
174+
// case Right(value) =>
175+
// assertEquals(
176+
// value,
177+
// input,
178+
// s"Literal scalar at top level did not round-trip. Emitted:\n$result"
179+
// )
180+
// case Left(err) =>
181+
// fail(s"Failed to parse literal output at top level: ${err.msg}\nEmitted:\n$result")
182+
// }
183+
// }
184+
185+
// test("bug7: folded block scalar at top level must indent content") {
186+
// val input = "abc\ndef\n"
187+
// val result = presentScalar(input, style = ScalarStyle.Folded)
188+
// val parsed = result.as[String]
189+
// parsed match {
190+
// case Right(value) =>
191+
// assertEquals(
192+
// value,
193+
// input,
194+
// s"Folded scalar at top level did not round-trip. Emitted:\n$result"
195+
// )
196+
// case Left(err) =>
197+
// fail(s"Failed to parse folded output at top level: ${err.msg}\nEmitted:\n$result")
198+
// }
199+
// }
200+
201+
// -----------------------------------------------------------------------
202+
// Bug 8: sys.error on non-scalar mapping keys is a regression.
203+
//
204+
// Per §3.2.1.1, "keys may be arbitrary nodes." Complex keys are legal
205+
// YAML and the `?` indicator exists for emitting them. Throwing a bare
206+
// RuntimeException is non-compliant.
207+
// -----------------------------------------------------------------------
208+
209+
test("bug8: non-scalar mapping key should not throw") {
210+
val events = Seq(
211+
DocumentStart(),
212+
MappingStart(),
213+
MappingStart(),
214+
Scalar("nested", metadata = NodeEventMetadata(tag = Some(Tag.str))),
215+
Scalar("key", metadata = NodeEventMetadata(tag = Some(Tag.str))),
216+
MappingEnd,
217+
Scalar("value", metadata = NodeEventMetadata(tag = Some(Tag.str))),
218+
MappingEnd,
219+
DocumentEnd()
220+
)
221+
val result =
222+
try {
223+
val yaml = PresenterImpl.asString(events)
224+
Right(yaml)
225+
} catch {
226+
case e: RuntimeException => Left(e.getMessage)
227+
}
228+
assert(
229+
result.isRight,
230+
s"Non-scalar mapping key should not throw, but got: ${result.left.getOrElse("")}"
231+
)
232+
}
233+
234+
// -----------------------------------------------------------------------
235+
// Roundtrip: per §3.2.1, §3.3.2, §10.3.2, a !!str node whose content
236+
// matches a non-string resolution pattern (null, true, 3.9, etc.) must
237+
// be quoted or explicitly tagged. Otherwise re-parsing resolves it to
238+
// the wrong type.
239+
// -----------------------------------------------------------------------
240+
241+
test("roundtrip: string 'null' must survive encode-decode as string") {
242+
val yaml = "null".asYaml
243+
val parsed = yaml.as[String]
244+
parsed match {
245+
case Right(value) =>
246+
assertEquals(value, "null", s"String 'null' did not round-trip. YAML was: $yaml")
247+
case Left(err) =>
248+
fail(s"Failed to parse: ${err.msg}")
249+
}
250+
}
251+
}

0 commit comments

Comments
 (0)