1package foreign23import (4 "strings"5 "testing"6)78func 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}1516func TestForeign_LeadingTrailingBlankLines(t *testing.T) {17 // Parser requires a blank line before the opener (CM §4.6). Verify18 // 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}2728func 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}3738func 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}4748func TestForeign_EscapesSentinelClose(t *testing.T) {49 // Unbalanced </gno-foreign> in body would terminate the outer50 // block early. Verify it's escaped to literal text — only the51 // leading "<" is replaced with "<"; the trailing ">" stays52 // 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}6162func 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}7071func TestForeign_EscapesIndentedSentinel(t *testing.T) {72 // Parser strips 0-3 leading spaces before matching; helper73 // 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}7980func TestForeign_EscapesAttributeBearingOpener(t *testing.T) {81 // `<gno-foreign label="x">` IS recognized by the parser as a82 // nested opener. If left unmangled, the parser bumps framingDepth83 // when it sees this line in body bytes, and then consumes the84 // helper's own outer-close as a fake "inner close" — capturing85 // realm content that follows the helper's output INTO the86 // 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}9596func TestForeign_EscapesAttributeBearingCloser(t *testing.T) {97 // golang.org/x/net/html zeroes the Attr slice on end tags, so the98 // parser recognizes ANY `</gno-foreign attr…>` form as a sentinel99 // close. Leaving such a line unmangled in body bytes would close100 // the outer block early (framingDepth=0 case in the parser's101 // Continue), letting body content that follows render OUTSIDE102 // 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}111112func TestForeign_EscapesGnoForeignWithExtraContent(t *testing.T) {113 // `<gno-foreign> extra` would not be recognized as a sentinel by114 // the parser (tokenizer yields 2 tokens). But the helper is115 // over-inclusive on purpose: anything that looks like a116 // gno-foreign tag opener or closer gets mangled. Predictable117 // behavior beats narrow matching that diverges from the parser118 // 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}124125func TestForeign_EscapesCaseVariantClose(t *testing.T) {126 // The renderer-side parser recognizes tags case-insensitively127 // (goldmark's tokenizer lowercases tag names). A case-variant close128 // left unescaped would terminate the outer block early and let129 // trailing body render OUTSIDE the sandbox. The helper must escape130 // `</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}139140func TestForeign_EscapesCaseVariantOpen(t *testing.T) {141 // A case-variant opener left unescaped would be picked up by the142 // parser as a nested inner foreign (framingDepth++), causing the143 // helper's own outer-close to be consumed as a fake inner close and144 // 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}150151func TestForeign_EscapesMixedCaseSentinel(t *testing.T) {152 // Mixed-case forms (the most likely hand-crafted evasion) must also153 // 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}166167func TestForeignWithLabel_StripsBidiAndZeroWidth(t *testing.T) {168 // A bidi-override (U+202E) or zero-width (U+200C) run in the label169 // is invisible-spoof payload; it must be stripped before the label170 // 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}176177func TestForeignWithLabel_UnicodeSeparatorsBecomeSpaces(t *testing.T) {178 // U+2028 / U+2029 / U+0085 must not survive in the single-line179 // 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}185186func TestForeign_EscapesUnrelatedForeignPrefix(t *testing.T) {187 // `<gno-foreignx>` is a different tag the parser would NOT treat as188 // a sentinel, but the escaper is deliberately over-inclusive (it189 // matches the `<gno-foreign` prefix alone), so it is escaped to190 // 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}196197func TestForeign_EscapesSlashAndFormFeedClosers(t *testing.T) {198 // REGRESSION: goldmark's tokenizer ends a tag name at `/` and at199 // form-feed (both HTML tag-name terminators), so `</gno-foreign/>`200 // and `</gno-foreign\f>` are recognized as CLOSERS by the parser. A201 // body line carrying one of these, if it reached the parser202 // 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}216217func 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}224225func 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}231232func 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}239240func 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}251252func 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}258259func 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}265266func TestForeignWithLabel_EscapesAngleBrackets(t *testing.T) {267 // An unescaped `>` would close the opener tag early. Must be268 // 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}274275func 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}284285func TestForeignWithLabel_ControlCharsBecomeSpaces(t *testing.T) {286 // Newlines and tabs would break the single-line opener required287 // 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}296297func 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}303304func TestForeignWithLabel_PreservesBodyNormalization(t *testing.T) {305 // Body normalization (CRLF, sentinel escaping) must still apply306 // 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}315316func TestMaxBlocksPerRender(t *testing.T) {317 // Surfaces gnoweb's per-render foreign-block cap to realms via the318 // chain/markdown native. Pin the contract value; bump here if the319 // renderer cap changes.320 if got := MaxBlocksPerRender(); got != 256 {321 t.Errorf("MaxBlocksPerRender() = %d, want 256", got)322 }323}324Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.