foreign_test.gno
11.41 Kb · 323 lines
1package foreign
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestForeign_BasicWrap(t *testing.T) {
9 got := Foreign("hello")
10 want := "\n\n<gno-foreign>\nhello\n</gno-foreign>\n\n"
11 if got != want {
12 t.Errorf("got %q, want %q", got, want)
13 }
14}
15
16func TestForeign_LeadingTrailingBlankLines(t *testing.T) {
17 // Parser requires a blank line before the opener (CM §4.6). Verify
18 // the helper emits exactly that.
19 got := Foreign("body")
20 if !strings.HasPrefix(got, "\n\n<gno-foreign>\n") {
21 t.Errorf("missing blank line before opener: %q", got)
22 }
23 if !strings.HasSuffix(got, "\n</gno-foreign>\n\n") {
24 t.Errorf("missing blank line after closer: %q", got)
25 }
26}
27
28func TestForeign_NormalizesCRLF(t *testing.T) {
29 got := Foreign("line1\r\nline2\r\nline3")
30 if strings.Contains(got, "\r") {
31 t.Errorf("CRLF not normalized: %q", got)
32 }
33 if !strings.Contains(got, "line1\nline2\nline3") {
34 t.Errorf("body content lost: %q", got)
35 }
36}
37
38func TestForeign_NormalizesBareCR(t *testing.T) {
39 got := Foreign("line1\rline2")
40 if strings.Contains(got, "\r") {
41 t.Errorf("bare CR not normalized: %q", got)
42 }
43 if !strings.Contains(got, "line1\nline2") {
44 t.Errorf("body content lost: %q", got)
45 }
46}
47
48func TestForeign_EscapesSentinelClose(t *testing.T) {
49 // Unbalanced </gno-foreign> in body would terminate the outer
50 // block early. Verify it's escaped to literal text — only the
51 // leading "<" is replaced with "<"; the trailing ">" stays
52 // literal (rendering as visible ">" in HTML).
53 got := Foreign("foo\n</gno-foreign>\nbar")
54 if strings.Contains(got, "\n</gno-foreign>\nbar") {
55 t.Errorf("unbalanced close not escaped: %q", got)
56 }
57 if !strings.Contains(got, "</gno-foreign>") {
58 t.Errorf("expected escaped close: %q", got)
59 }
60}
61
62func TestForeign_EscapesSentinelOpen(t *testing.T) {
63 // A bare opener inside body (without matching close in body)
64 // would be picked up as a nested inner foreign. Escape it.
65 got := Foreign("foo\n<gno-foreign>\nbar")
66 if !strings.Contains(got, "\n<gno-foreign>\n") {
67 t.Errorf("expected escaped inner opener: %q", got)
68 }
69}
70
71func TestForeign_EscapesIndentedSentinel(t *testing.T) {
72 // Parser strips 0-3 leading spaces before matching; helper
73 // must mirror that to catch indented sentinels.
74 got := Foreign("foo\n </gno-foreign>\nbar")
75 if !strings.Contains(got, "</gno-foreign>") {
76 t.Errorf("indented sentinel not escaped: %q", got)
77 }
78}
79
80func TestForeign_EscapesAttributeBearingOpener(t *testing.T) {
81 // `<gno-foreign label="x">` IS recognized by the parser as a
82 // nested opener. If left unmangled, the parser bumps framingDepth
83 // when it sees this line in body bytes, and then consumes the
84 // helper's own outer-close as a fake "inner close" — capturing
85 // realm content that follows the helper's output INTO the
86 // sandbox. Verify the helper escapes the leading "<".
87 got := Foreign(`<gno-foreign label="x">`)
88 if strings.Contains(got, `<gno-foreign label="x">`) {
89 t.Errorf("attribute-bearing opener was NOT mangled — escape vector:\n%q", got)
90 }
91 if !strings.Contains(got, `<gno-foreign label="x">`) {
92 t.Errorf("expected leading '<' escaped: %q", got)
93 }
94}
95
96func TestForeign_EscapesAttributeBearingCloser(t *testing.T) {
97 // golang.org/x/net/html zeroes the Attr slice on end tags, so the
98 // parser recognizes ANY `</gno-foreign attr…>` form as a sentinel
99 // close. Leaving such a line unmangled in body bytes would close
100 // the outer block early (framingDepth=0 case in the parser's
101 // Continue), letting body content that follows render OUTSIDE
102 // the sandbox at top level. Verify the helper escapes it.
103 got := Foreign(`</gno-foreign label="x">`)
104 if strings.Contains(got, `> </gno-foreign label="x"> </gno-foreign>`) {
105 t.Errorf("attribute-bearing closer was NOT mangled — escape vector:\n%q", got)
106 }
107 if !strings.Contains(got, `</gno-foreign label="x">`) {
108 t.Errorf("expected leading '<' escaped: %q", got)
109 }
110}
111
112func TestForeign_EscapesGnoForeignWithExtraContent(t *testing.T) {
113 // `<gno-foreign> extra` would not be recognized as a sentinel by
114 // the parser (tokenizer yields 2 tokens). But the helper is
115 // over-inclusive on purpose: anything that looks like a
116 // gno-foreign tag opener or closer gets mangled. Predictable
117 // behavior beats narrow matching that diverges from the parser
118 // on attribute-bearing forms.
119 got := Foreign("<gno-foreign> extra text")
120 if !strings.Contains(got, "<gno-foreign> extra text") {
121 t.Errorf("expected over-inclusive mangle of `<gno-foreign> extra`: %q", got)
122 }
123}
124
125func TestForeign_EscapesCaseVariantClose(t *testing.T) {
126 // The renderer-side parser recognizes tags case-insensitively
127 // (goldmark's tokenizer lowercases tag names). A case-variant close
128 // left unescaped would terminate the outer block early and let
129 // trailing body render OUTSIDE the sandbox. The helper must escape
130 // `</GNO-FOREIGN>` just like `</gno-foreign>`.
131 got := Foreign("foo\n</GNO-FOREIGN>\nbar")
132 if strings.Contains(got, "\n</GNO-FOREIGN>\nbar") {
133 t.Errorf("case-variant close not escaped — sandbox-escape vector: %q", got)
134 }
135 if !strings.Contains(got, "</GNO-FOREIGN>") {
136 t.Errorf("expected escaped case-variant close: %q", got)
137 }
138}
139
140func TestForeign_EscapesCaseVariantOpen(t *testing.T) {
141 // A case-variant opener left unescaped would be picked up by the
142 // parser as a nested inner foreign (framingDepth++), causing the
143 // helper's own outer-close to be consumed as a fake inner close and
144 // capturing trailing realm content into the sandbox.
145 got := Foreign("foo\n<GNO-FOREIGN>\nbar")
146 if !strings.Contains(got, "\n<GNO-FOREIGN>\n") {
147 t.Errorf("case-variant opener not escaped — capture vector: %q", got)
148 }
149}
150
151func TestForeign_EscapesMixedCaseSentinel(t *testing.T) {
152 // Mixed-case forms (the most likely hand-crafted evasion) must also
153 // be caught, including the attribute-bearing mixed-case opener.
154 for _, in := range []string{
155 "<Gno-Foreign>",
156 "</Gno-Foreign>",
157 `<GNO-foreign label="x">`,
158 "</gNo-fOrEiGn>",
159 } {
160 got := Foreign(in)
161 if !strings.Contains(got, "<") {
162 t.Errorf("mixed-case sentinel %q not escaped: %q", in, got)
163 }
164 }
165}
166
167func TestForeignWithLabel_StripsBidiAndZeroWidth(t *testing.T) {
168 // A bidi-override (U+202E) or zero-width (U+200C) run in the label
169 // is invisible-spoof payload; it must be stripped before the label
170 // is spliced into the opener attribute.
171 got := ForeignWithLabel("a\u202eb\u200cc", "body")
172 if !strings.Contains(got, `label="abc"`) {
173 t.Errorf("expected bidi/zero-width-stripped label: %q", got)
174 }
175}
176
177func TestForeignWithLabel_UnicodeSeparatorsBecomeSpaces(t *testing.T) {
178 // U+2028 / U+2029 / U+0085 must not survive in the single-line
179 // opener attribute; they fold to spaces like ASCII line breaks.
180 got := ForeignWithLabel("a\u2028b\u2029c\u0085d", "body")
181 if !strings.Contains(got, `label="a b c d"`) {
182 t.Errorf("expected space-folded label: %q", got)
183 }
184}
185
186func TestForeign_EscapesUnrelatedForeignPrefix(t *testing.T) {
187 // `<gno-foreignx>` is a different tag the parser would NOT treat as
188 // a sentinel, but the escaper is deliberately over-inclusive (it
189 // matches the `<gno-foreign` prefix alone), so it is escaped to
190 // visible literal text. Harmless, and the safe side of the trade.
191 got := Foreign("<gno-foreignx>")
192 if !strings.Contains(got, "<gno-foreignx>") {
193 t.Errorf("expected over-inclusive escape of <gno-foreignx>: %q", got)
194 }
195}
196
197func TestForeign_EscapesSlashAndFormFeedClosers(t *testing.T) {
198 // REGRESSION: goldmark's tokenizer ends a tag name at `/` and at
199 // form-feed (both HTML tag-name terminators), so `</gno-foreign/>`
200 // and `</gno-foreign\f>` are recognized as CLOSERS by the parser. A
201 // body line carrying one of these, if it reached the parser
202 // unescaped, would close the outer block early — a sandbox escape.
203 // The over-inclusive escaper must neutralize every such variant.
204 for _, in := range []string{
205 "foo\n</gno-foreign/>\nbar",
206 "foo\n</gno-foreign/ >\nbar",
207 "foo\n</gno-foreign\f>\nbar",
208 "foo\n<gno-foreign/>\nbar",
209 } {
210 got := Foreign(in)
211 if !strings.Contains(got, "<") {
212 t.Errorf("variant not escaped (sandbox-escape vector): %q -> %q", in, got)
213 }
214 }
215}
216
217func TestForeign_EmptyBody(t *testing.T) {
218 got := Foreign("")
219 want := "\n\n<gno-foreign>\n\n</gno-foreign>\n\n"
220 if got != want {
221 t.Errorf("got %q, want %q", got, want)
222 }
223}
224
225func TestForeign_PreservesInternalBlankLines(t *testing.T) {
226 got := Foreign("para1\n\npara2")
227 if !strings.Contains(got, "para1\n\npara2") {
228 t.Errorf("internal blank line lost: %q", got)
229 }
230}
231
232func TestForeignWithLabel_BasicWrap(t *testing.T) {
233 got := ForeignWithLabel("My Label", "body")
234 want := "\n\n<gno-foreign label=\"My Label\">\nbody\n</gno-foreign>\n\n"
235 if got != want {
236 t.Errorf("got %q, want %q", got, want)
237 }
238}
239
240func TestForeignWithLabel_EmptyLabelFallsBack(t *testing.T) {
241 // An empty label (or one that becomes empty after sanitization)
242 // should be identical to Foreign — no attribute on the opener.
243 got := ForeignWithLabel("", "body")
244 if strings.Contains(got, "label=") {
245 t.Errorf("empty label should not emit attribute: %q", got)
246 }
247 if got != Foreign("body") {
248 t.Errorf("empty label should match Foreign: got %q vs %q", got, Foreign("body"))
249 }
250}
251
252func TestForeignWithLabel_WhitespaceOnlyLabelFallsBack(t *testing.T) {
253 got := ForeignWithLabel(" ", "body")
254 if strings.Contains(got, "label=") {
255 t.Errorf("whitespace label should not emit attribute: %q", got)
256 }
257}
258
259func TestForeignWithLabel_EscapesAmpAndQuote(t *testing.T) {
260 got := ForeignWithLabel(`Tom & "Jerry"`, "body")
261 if !strings.Contains(got, `label="Tom & "Jerry""`) {
262 t.Errorf("expected & and \" escaped: %q", got)
263 }
264}
265
266func TestForeignWithLabel_EscapesAngleBrackets(t *testing.T) {
267 // An unescaped `>` would close the opener tag early. Must be
268 // escaped.
269 got := ForeignWithLabel(`a<b>c`, "body")
270 if !strings.Contains(got, `label="a<b>c"`) {
271 t.Errorf("expected < and > escaped: %q", got)
272 }
273}
274
275func TestForeignWithLabel_StripsNUL(t *testing.T) {
276 got := ForeignWithLabel("a\x00b\x00c", "body")
277 if strings.Contains(got, "\x00") {
278 t.Errorf("NUL not stripped: %q", got)
279 }
280 if !strings.Contains(got, `label="abc"`) {
281 t.Errorf("expected NUL-stripped label: %q", got)
282 }
283}
284
285func TestForeignWithLabel_ControlCharsBecomeSpaces(t *testing.T) {
286 // Newlines and tabs would break the single-line opener required
287 // by the parser. They must become spaces.
288 got := ForeignWithLabel("line1\nline2\ttab", "body")
289 if strings.Contains(got, "\nline2") || strings.Contains(got, "\t") {
290 t.Errorf("control chars not converted: %q", got)
291 }
292 if !strings.Contains(got, "line1 line2 tab") {
293 t.Errorf("expected space-separated label: %q", got)
294 }
295}
296
297func TestForeignWithLabel_TrimsLeadingTrailingSpace(t *testing.T) {
298 got := ForeignWithLabel(" hello ", "body")
299 if !strings.Contains(got, `label="hello"`) {
300 t.Errorf("expected trimmed label: %q", got)
301 }
302}
303
304func TestForeignWithLabel_PreservesBodyNormalization(t *testing.T) {
305 // Body normalization (CRLF, sentinel escaping) must still apply
306 // in the labeled variant.
307 got := ForeignWithLabel("L", "a\r\n</gno-foreign>\r\nb")
308 if strings.Contains(got, "\r") {
309 t.Errorf("CRLF not normalized in labeled variant: %q", got)
310 }
311 if !strings.Contains(got, "</gno-foreign>") {
312 t.Errorf("sentinel not escaped in labeled variant: %q", got)
313 }
314}
315
316func TestMaxBlocksPerRender(t *testing.T) {
317 // Surfaces gnoweb's per-render foreign-block cap to realms via the
318 // chain/markdown native. Pin the contract value; bump here if the
319 // renderer cap changes.
320 if got := MaxBlocksPerRender(); got != 256 {
321 t.Errorf("MaxBlocksPerRender() = %d, want 256", got)
322 }
323}