Download
144 lines 4.7 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
package markdown

import (
	"net/url"
	"strings"
	"testing"

	"code.bruce-hill.com/repo-renderer/internal/highlight"
)

// escapePath percent-encodes each segment of a slash-separated path, as
// the renderer's URLs do.
func escapePath(p string) string {
	segs := strings.Split(p, "/")
	for i, s := range segs {
		segs[i] = url.PathEscape(s)
	}
	return strings.Join(segs, "/")
}

func pageURL(p string) string {
	if p == "" {
		return "/code/r"
	}
	return "/code/r/" + escapePath(p)
}

func TestResolve(t *testing.T) {
	ctx := Context{Dir: "docs/sub", PageURL: pageURL,
		ImageURL: func(p string) string { return "/raw/r/" + escapePath(p) }}
	cases := []struct{ in, want string }{
		{"guide.md", "/code/r/docs/sub/guide.md"},
		{"./guide.md#top", "/code/r/docs/sub/guide.md#top"},
		{"../intro.md", "/code/r/docs/intro.md"},
		{"../../README.md", "/code/r/README.md"},
		{"../../", "/code/r"},
		{"../../../escape.md", "../../../escape.md"},
		{"a%20b.md", "/code/r/docs/sub/a%20b.md"},
		{"https://example.com/x", "https://example.com/x"},
		{"//cdn.example.com/x", "//cdn.example.com/x"},
		{"mailto:a@b.c", "mailto:a@b.c"},
		{"#frag", "#frag"},
		{"/top.md", "/code/r/top.md"},
		{"/src/", "/code/r/src"},
		{"/", "/code/r"},
		{"/../escape.md", "/../escape.md"},
		{"", ""},
	}
	for _, c := range cases {
		if got := Resolve(c.in, ctx, false); got != c.want {
			t.Errorf("Resolve(%q) = %q, want %q", c.in, got, c.want)
		}
	}
	if got := Resolve("pic.png", ctx, true); got != "/raw/r/docs/sub/pic.png" {
		t.Errorf("image: %q", got)
	}
	if got := Resolve("/d.png", ctx, true); got != "/raw/r/d.png" {
		t.Errorf("root-relative image: %q", got)
	}
	if got := Resolve("../d.svg", ctx, false); got != "/code/r/docs/d.svg" {
		t.Errorf("link to an image: %q", got)
	}
}

func TestRender(t *testing.T) {
	hl, err := highlight.New(nil, nil, "github")
	if err != nil {
		t.Fatal(err)
	}
	r := New(hl)
	src := "# Title\n\n- [x] done\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n~~gone~~ <em>raw</em>\n\n<div>block</div>\n\n```go\nvar x int\n```\n\n    indented\n\nfoot[^1]\n\n[^1]: note\n"
	out, err := r.Render([]byte(src), Context{PageURL: pageURL, ImageURL: func(p string) string { return "/raw/r/" + p }})
	if err != nil {
		t.Fatal(err)
	}
	s := string(out)
	for _, want := range []string{
		`<h1 id="title">Title</h1>`, `type="checkbox"`, `<table>`, `<del>gone</del>`,
		`&lt;em&gt;raw&lt;/em&gt;`, `<pre class="raw-html">&lt;div&gt;block&lt;/div&gt;`,
		`<pre class="chroma" data-lang="go"><code class="chroma"><span class="kd">var</span>`,
		`<pre class="chroma"><code class="chroma">indented`, `class="footnotes"`,
	} {
		if !strings.Contains(s, want) {
			t.Errorf("missing %q in:\n%s", want, s)
		}
	}
	if strings.Contains(s, "<em>raw</em>") || strings.Contains(s, "<div>block</div>") {
		t.Error("raw HTML passed through")
	}
}

func TestStripPandocMeta(t *testing.T) {
	cases := []struct{ in, want string }{
		{"% Title\n% Me\n% 2026\n\n# Body\n", "\n# Body\n"},
		{"% A very long\n  title\n% Me; You\n# Body\n", "# Body\n"},
		{"% Only title\nText\n", "Text\n"},
		{"# No meta\n% not at start\n", "# No meta\n% not at start\n"},
		{"%\n", ""},
		{"% no newline", ""},
		{"", ""},
	}
	for _, c := range cases {
		if got := string(StripPandocMeta([]byte(c.in))); got != c.want {
			t.Errorf("StripPandocMeta(%q) = %q, want %q", c.in, got, c.want)
		}
	}
	hl, err := highlight.New(nil, nil, "github")
	if err != nil {
		t.Fatal(err)
	}
	out, err := New(hl).Render([]byte("% My Title\n% Author\n\nHello *world*\n"), Context{})
	if err != nil {
		t.Fatal(err)
	}
	if s := string(out); strings.Contains(s, "My Title") || strings.Contains(s, "Author") || !strings.Contains(s, "<em>world</em>") {
		t.Errorf("rendered: %s", s)
	}
}

// Script-capable URLs must never reach an href or src, however they are
// written (inline, reference, autolink, entity-encoded, image),
// and attribute-breaking text in a fence info string must stay inside the
// attribute.
func TestHostileMarkup(t *testing.T) {
	hl, err := highlight.New(nil, nil, "github")
	if err != nil {
		t.Fatal(err)
	}
	r := New(hl)
	src := "[a](javascript:alert(1)) [b](JaVaScRiPt:alert(2)) [c](&#106;avascript:alert(3))\n\n" +
		"[d][ref] <javascript:alert(4)> ![e](javascript:alert(5)) [f](vbscript:x) [g](data:text/html;base64,AAAA)\n\n" +
		"[ref]: javascript:alert(6)\n\n" +
		"```x\" onmouseover=\"alert(7)\nbody\n```\n"
	out, err := r.Render([]byte(src), Context{PageURL: pageURL, ImageURL: func(p string) string { return "/raw/r/" + p }})
	if err != nil {
		t.Fatal(err)
	}
	s := strings.ToLower(string(out))
	for _, bad := range []string{`href="javascript`, `src="javascript`, `href="vbscript`, `href="data:`, `href="&#106;`, `" onmouseover=`} {
		if strings.Contains(s, bad) {
			t.Errorf("output contains %q:\n%s", bad, out)
		}
	}
}