package render import ( "fmt" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // asciiPunctuation is every ASCII punctuation character, the set CommonMark // declares escapable. const asciiPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" // TestEscapableSet pins the escapes each renderer honors, which is what // EscapeMarkdown's character classes are derived from. func TestEscapableSet(t *testing.T) { t.Parallel() // Measured against gomarkdown and glamour as vendored today. const ( wantHTML = "!#$&()*+-.:<>[\\]^_`{|}~" wantPlain = "!#()*+-.<>[\\]_`{|}" ) var gotHTML, gotPlain strings.Builder for _, r := range asciiPunctuation { escaped := `X\` + string(r) + `Y` // HTML escapes markup characters, so compare the entity form. wantLiteral := "X" + string(r) + "Y" switch r { case '<': wantLiteral = "X<Y" case '>': wantLiteral = "X>Y" case '&': wantLiteral = "X&Y" case '"': wantLiteral = "X"Y" } html := strings.TrimSuffix(strings.TrimPrefix(HTMLFromMarkdown(escaped), "
"), "
") if html == wantLiteral { _, _ = gotHTML.WriteRune(r) } plain, err := PlaintextFromMarkdown(escaped) require.NoError(t, err) if plain == "X"+string(r)+"Y" { _, _ = gotPlain.WriteRune(r) } } require.Equal(t, wantHTML, gotHTML.String(), "the set of characters gomarkdown honors as escapes has changed; re-derive EscapeMarkdown's character classes") require.Equal(t, wantPlain, gotPlain.String(), "the set of characters glamour honors as escapes has changed; re-derive EscapeMarkdown's character classes") // Every character EscapeMarkdown escapes must be honored by both renderers. for _, r := range inlineCritical + blockStart + leadingEmphasis { assert.Contains(t, wantHTML, string(r), "gomarkdown does not honor \\%s", string(r)) assert.Contains(t, wantPlain, string(r), "glamour does not honor \\%s", string(r)) } // foldStart characters are folded precisely because they are not escapable. for _, r := range foldStart { assert.NotContains(t, wantPlain, string(r), "glamour now honors \\%s, so it could be escaped instead of folded", string(r)) } } // TestEscapeMarkdownControlValues guards the label values body templates compare // with `eq`. Escaping one silently changes template control flow. func TestEscapeMarkdownControlValues(t *testing.T) { t.Parallel() for _, v := range []string{ "user_override", // migrations/000553, {{if eq .Labels.limit_source "user_override"}} "service", // migrations/000568, {{if eq .Labels.account_type "service"}} "0", // migrations/000480, {{if eq .Data.retention_days "0"}} "autobuild", "initiator", "user-override", "1.5", "10.0.0.1", "bobby-workspace", } { require.Equal(t, v, EscapeMarkdown(v), "escaping changed a control value") } } func TestEscapeMarkdown(t *testing.T) { t.Parallel() // Emphasis and code tags are accepted residuals: no destination. structuralTags := []string{ ""}, // Neutralized by autolinking being off. See // TestEscapeMarkdownNoAutolink. {name: "BareURL", value: "Eve https://attacker.example/login", inertRaw: true}, {name: "Mailto", value: "Eve mailto:eve@attacker.example", inertRaw: true}, {name: "ATXHeading", value: "Eve\n## URGENT"}, {name: "SetextH1", value: "URGENT: re-auth required\n===\nx"}, {name: "SetextH1Spaced", value: "Eve\n=== \n#### x"}, {name: "SetextH1Repeated", value: "Eve\n===\n===\nx"}, {name: "SetextH2", value: "Eve\n---\nx"}, {name: "ThematicBreak", value: "Eve\n----\nx"}, {name: "ThematicBreakStars", value: "Eve\n***\nnext"}, {name: "ThematicBreakUnderscores", value: "Eve\n___\nnext"}, {name: "ThematicBreakSpacedStars", value: "Eve\n* * *\nnext"}, {name: "ThematicBreakSpacedUnderscores", value: "Eve\n_ _ _\nnext"}, {name: "BulletList", value: "Eve\n- one\n- two"}, {name: "BulletListStar", value: "Eve\n* one\n* two"}, {name: "BulletListStarIndented", value: "Eve\n * one\n * two"}, {name: "OrderedList", value: "Eve\n1. one\n2. two"}, {name: "OrderedListMultiDigit", value: "Eve\n99. one\n100. two"}, {name: "OrderedListParen", value: "Eve\n1) one\n2) two"}, {name: "Blockquote", value: "Eve\n> quoted"}, // Neutralized by the Tables extension being off; the escaper's own // handling is covered by TestEscapeMarkdownColon. {name: "Table", value: "a | b\n--- | ---\nc | d", inertRaw: true}, // Neutralized by the safelink policy. See // TestEscapeMarkdownNoAutolink/SafelinkRejectsUnsafeSchemes. {name: "JavascriptScheme", value: "[click](javascript:alert(1))", inertRaw: true}, // Asserts escaping does not re-enable the value's own backslashes. {name: "EscapeForging", value: `Eve \[Re-auth\](https://attacker.example)`, inertRaw: true}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() // Rendered twice, the second with line breaks doubled: only a // blank line lets a block construct interrupt a paragraph. rawProducedTag := false for _, value := range []string{tc.value, strings.ReplaceAll(tc.value, "\n", "\n\n")} { escaped := EscapeMarkdown(value) html := HTMLFromNotificationMarkdown(suspendedBody(escaped)) plain, err := PlaintextFromMarkdown(suspendedBody(escaped)) require.NoError(t, err) raw := HTMLFromNotificationMarkdown(suspendedBody(value)) for _, tag := range structuralTags { assert.NotContains(t, html, tag, "value %q rendered HTML: %s", value, html) rawProducedTag = rawProducedTag || strings.Contains(raw, tag) } // A backslash the value did not contain means a character the // renderer does not honor was escaped. if !strings.Contains(value, `\`) { assert.NotContains(t, html, `\`, "value %q leaked a literal backslash into HTML", value) assert.NotContains(t, plain, `\`, "value %q leaked a literal backslash into plaintext", value) } } // Without this, a row whose value can never reach a line-start // position passes whether or not EscapeMarkdown runs. if !tc.inertRaw { assert.True(t, rawProducedTag, "vacuous row: %q produces no structural tag even unescaped, so the assertions above guard nothing; fix the value or set inertRaw with a reason", tc.value) } }) } }) t.Run("PreservesBenignValues", func(t *testing.T) { t.Parallel() // Also why this change leaves the notification golden files untouched. for _, value := range []string{ "William Tables", "bobby-workspace", "Bobby's Template", "O'Brien-Smith (Eng) 100%", "autodeleted due to dormancy (autobuild)", "José Müller 日本語", // Documented multi-line custom notification, see // docs/admin/monitoring/notifications/index.md. "Test results:\n • ✅ success", "Test results:\n • ❌ failed (3 tests failed)", } { t.Run(value, func(t *testing.T) { t.Parallel() escaped := EscapeMarkdown(value) require.Equal(t, HTMLFromNotificationMarkdown(suspendedBody(value)), HTMLFromNotificationMarkdown(suspendedBody(escaped))) wantPlain, err := PlaintextFromMarkdown(suspendedBody(value)) require.NoError(t, err) gotPlain, err := PlaintextFromMarkdown(suspendedBody(escaped)) require.NoError(t, err) require.Equal(t, wantPlain, gotPlain) }) } }) t.Run("ControlCharacters", func(t *testing.T) { t.Parallel() for _, tc := range []struct { name string value string want string }{ {"KeepsNewlines", "a\nb", "a\nb"}, {"FoldsCarriageReturn", "a\r\nb", "a \nb"}, {"FoldsTab", "a\tb", "a b"}, {"FoldsVerticalTab", "a\vb", "a b"}, {"DropsNul", "a\x00b", "ab"}, {"DropsBell", "a\x07b", "ab"}, {"DropsDelete", "a\x7fb", "ab"}, {"Empty", "", ""}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() require.Equal(t, tc.want, EscapeMarkdown(tc.value)) }) } }) t.Run("AngleBracketsAreNeutralised", func(t *testing.T) { t.Parallel() // "Ops