Download
527 lines 16.4 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
package render_test

import (
	"io"
	"io/fs"
	"net/http"
	"net/http/httptest"
	"os"
	"path"
	"path/filepath"
	"strings"
	"testing"

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

// TestHTTPDConf holds the development server to examples/httpd.conf. It
// reads the config, answers every URL of a rendered site, the private one
// too, the way httpd would by httpd.conf(5), with a login and without,
// and checks the development server answers the same: status, body and
// Content Security Policy.
func TestHTTPDConf(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.configure(e.privateConfig("proj"))
	e.seed()
	e.git(e.work, "tag", "v1.0")
	e.git(e.work, "push", "-q", "origin", "v1.0")
	e.git("", "clone", "-q", "--bare", e.site.Lay.GitDir("proj"), e.site.Lay.GitDir("secret"))
	if err := e.site.RenderAll(false); err != nil {
		t.Fatal(err)
	}
	conf := readHTTPDConf(t, filepath.Join("..", "..", "examples", "httpd.conf"))
	htdocs := e.site.Lay.Htdocs
	// The example's private root is the test's private_htdocs.
	roots := map[string]string{"": htdocs, "/private/code.example.com": e.site.Cfg.PrivateHtdocs}
	srv := httptest.NewServer(e.site.Handler())
	defer srv.Close()
	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}

	urls := []string{"/", "/code/proj/", "/code/proj/src/", "/code/proj/nope", "/raw/proj/nope",
		"/search/nope/", "/static/nope.js", "/code/", "/private", "/private/code/nope", "/private/code/proj"}
	for _, root := range []string{htdocs, e.site.Cfg.PrivateHtdocs} {
		if err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
			if err != nil || d.IsDir() {
				return err
			}
			// A .gz twin is what gzip-static serves for its file, not a URL.
			if twin, ok := strings.CutSuffix(p, ".gz"); ok {
				if _, err := os.Stat(twin); err == nil {
					return nil
				}
			}
			rel, err := filepath.Rel(root, p)
			if err != nil {
				return err
			}
			urls = append(urls, publicURL(filepath.ToSlash(rel)))
			return nil
		}); err != nil {
			t.Fatal(err)
		}
	}

	want := map[string]int{} // URLs whose answer the site depends on, logged in
	for _, u := range []string{"/code/proj", "/code/proj/src/main.c", "/code/proj/foo.html",
		"/search/proj/", "/types/proj/code/c", "/raw/proj/foo.html", "/static/style.css",
		"/git/proj.git/HEAD", "/git/proj.git/info/refs", "/git/proj.git/objects/info/packs",
		"/private/", "/private/code/secret", "/private/code/secret/src/main.c", "/private/search/secret/",
		"/private/types/secret/code/c", "/private/raw/secret/foo.html", "/private/git/secret.git/info/refs"} {
		want[u] = http.StatusOK
	}
	want["/private/code/proj"] = http.StatusNotFound
	want["/code/secret"] = http.StatusNotFound
	urls = append(urls, "/code/secret")
	// A directory, as httpd has it: redirected to its path with a slash,
	// which is forbidden without an index.html. The search form submits to
	// the slash, as the redirect would drop its query.
	for u, code := range map[string]int{"/search/proj": http.StatusMovedPermanently,
		"/raw/proj/src": http.StatusMovedPermanently, "/raw/proj/src/": http.StatusForbidden} {
		urls = append(urls, u)
		want[u] = code
	}
	mustContain(t, "search form", e.read(e.page("")), `action="/search/proj/"`)
	// What git probes for and expects to be missing: a 403 here makes it
	// print an error on every clone.
	for _, u := range []string{"/git/proj.git/objects/00/00000000000000000000000000000000000000",
		"/git/proj.git/objects/info/alternates", "/git/proj.git/objects/info/http-alternates"} {
		urls = append(urls, u)
		want[u] = http.StatusNotFound
	}
	for _, req := range urls {
		for _, login := range []bool{true, false} {
			u := req
			got := conf.serve(t, roots, u, login)
			r, err := http.NewRequest("GET", srv.URL+u, nil)
			if err != nil {
				t.Fatal(err)
			}
			if login {
				r.SetBasicAuth(privUser, privPass)
			}
			res, err := client.Do(r)
			if err != nil {
				t.Fatal(err)
			}
			body, err := io.ReadAll(res.Body)
			_ = res.Body.Close() // read to the end above
			if err != nil {
				t.Fatal(err)
			}
			private := strings.HasPrefix(u, httpd.PrivatePrefix+"/")
			if w, ok := want[u]; ok && login {
				if got.status != w {
					t.Errorf("httpd.conf: GET %s: %d, want %d", u, got.status, w)
				}
				delete(want, u)
			}
			if private && !login && got.status != http.StatusUnauthorized {
				t.Errorf("httpd.conf: GET %s without a login: %d", u, got.status)
			}
			if res.StatusCode != got.status {
				t.Errorf("GET %s (login %v): dev server %d, httpd.conf %d (%s)", u, login, res.StatusCode, got.status, got.file)
				continue
			}
			if private && got.status == http.StatusOK && strings.Contains(got.header["Cache-Control"], "public") {
				t.Errorf("GET %s: private file cached as public: %q", u, got.header["Cache-Control"])
			}
			// The rules below are the same on both sites.
			u = strings.TrimPrefix(u, httpd.PrivatePrefix)
			if got.status == http.StatusOK && string(body) != got.body {
				t.Errorf("GET %s: dev server and httpd.conf serve different bodies (%s)", u, got.file)
			}
			for _, h := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "Location"} {
				if res.Header.Get(h) != got.header[h] {
					t.Errorf("GET %s: %s: dev server %q, httpd.conf %q", u, h, res.Header.Get(h), got.header[h])
				}
			}
			if cc := res.Header.Get("Cache-Control"); got.status == http.StatusOK && cc != got.header["Cache-Control"] {
				t.Errorf("GET %s: Cache-Control: dev server %q, httpd.conf %q", u, cc, got.header["Cache-Control"])
			}
			// URLs named by what they hold are cached for good, and all others
			// are asked for again each time, so no page outlives a push.
			named := strings.HasPrefix(u, "/snapshot/") || strings.HasPrefix(u, "/static/") ||
				strings.HasSuffix(u, "/corpus.json") || strings.Contains(u, "/objects/pack/pack-")
			if cc := got.header["Cache-Control"]; got.status == http.StatusOK && named != strings.Contains(cc, "immutable") {
				t.Errorf("GET %s: Cache-Control %q", u, cc)
			} else if got.status == http.StatusOK && !named && cc != "no-cache" {
				t.Errorf("GET %s: Cache-Control %q, want no-cache", u, cc)
			}
			if got.status == http.StatusOK && got.gz && !got.gzipStatic && !strings.HasPrefix(u, "/raw/") {
				t.Errorf("GET %s: %s.gz is never served: no gzip-static", u, got.file)
			}
			// A repository's foo.gz is a file of its own, not foo compressed.
			if strings.HasPrefix(u, "/raw/") && got.gzipStatic {
				t.Errorf("GET %s: gzip-static on the repositories' own files", u)
			}
		}
	}
	for u := range want {
		t.Errorf("GET %s: not among the site's URLs", u)
	}
	// httpd's errdocs serves the site's own pages for these, on the private
	// site too, and so does the dev server.
	for u, page := range map[string]string{"/code/nope": "Page not found", "/raw/proj/src/": "Nothing to show here",
		"/private/raw/secret/src/": "Nothing to show here"} {
		r, err := http.NewRequest("GET", srv.URL+u, nil)
		if err != nil {
			t.Fatal(err)
		}
		r.SetBasicAuth(privUser, privPass)
		res, err := client.Do(r)
		if err != nil {
			t.Fatal(err)
		}
		body, err := io.ReadAll(res.Body)
		_ = res.Body.Close() // read to the end above
		if err != nil {
			t.Fatal(err)
		}
		mustContain(t, u, string(body), "<h1>"+page+"</h1>", `href="/static/style.css?v=`)
	}
	e.mustExist("err/404.html", "err/403.html")
}

// publicURL is the URL httpd serves a file in htdocs, or in
// private_htdocs, at.
func publicURL(rel string) string {
	if rest, ok := strings.CutPrefix(rel, config.PrivateDir+"/"); ok {
		return httpd.PrivatePrefix + publicURL(rest)
	}
	for _, dir := range []string{"code/", "types/"} {
		if strings.HasPrefix(rel, dir) && strings.HasSuffix(rel, ".html") {
			return "/" + strings.TrimSuffix(rel, ".html")
		}
	}
	if dir, ok := strings.CutSuffix(rel, "index.html"); ok && (dir == "" || strings.HasSuffix(dir, "/")) {
		return "/" + dir
	}
	return "/" + rel
}

// ---- a model of httpd(8) serving an httpd.conf ----

type httpdConf struct {
	header     map[string]string // server-wide headers
	gzipStatic bool
	locations  []httpdLocation
}

type httpdLocation struct {
	loc        httpd.Location
	rewrite    string
	gzipStatic *bool // nil: the server's
	block      bool
	header     map[string]string // set, or "" for removed
	// root is the location's own root in the chroot, "" for the
	// server's.
	root string
	auth bool // asks for a login
	// elsewhere strips the path to serve from its root (acme-client's
	// challenges), so no URL of the site may land in it.
	elsewhere bool
}

type httpdAnswer struct {
	status     int
	file, body string
	header     map[string]string
	gz         bool // a .gz twin exists
	gzipStatic bool
	block      bool
}

// serve answers a GET of u as httpd.conf(5), server_http.c and
// server_file.c have it: the first matching location wins; a rewrite
// happens once, and then the new path is looked up again; that location
// asks for a login, and serves from its root, the server's or its own,
// which roots maps to a directory; a directory is redirected to its path
// with a slash, which serves its index.html, looked up again, or is
// forbidden.
func (c *httpdConf) serve(t *testing.T, roots map[string]string, u string, login bool) httpdAnswer {
	t.Helper()
	p := u
	if i := strings.IndexByte(p, '?'); i >= 0 {
		p = p[:i]
	}
	loc, caps := c.lookup(p)
	if loc != nil && loc.rewrite != "" {
		p = path.Clean(httpd.Rewrite(loc.rewrite, caps))
		loc, _ = c.lookup(p)
	}
	if loc != nil && loc.elsewhere {
		t.Fatalf("%s lands in a location that serves files from outside the site", u)
	}
	a := c.answer(loc)
	if a.block {
		a.status = http.StatusForbidden
		return a
	}
	if loc != nil && loc.auth && !login {
		a.status = http.StatusUnauthorized
		return a
	}
	dir := func(loc *httpdLocation) string {
		root := ""
		if loc != nil {
			root = loc.root
		}
		d, ok := roots[root]
		if !ok {
			t.Fatalf("%s: no directory for root %q", u, root)
		}
		return d
	}
	a.file = filepath.Join(dir(loc), filepath.FromSlash(p))
	if st, err := os.Stat(a.file); err == nil && st.IsDir() {
		if !strings.HasSuffix(p, "/") {
			a.status = http.StatusMovedPermanently
			a.header["Location"] = p + "/"
			return a
		}
		p += "index.html"
		loc, _ = c.lookup(p)
		a = c.answer(loc)
		a.file = filepath.Join(dir(loc), filepath.FromSlash(p))
		if _, err := os.Stat(a.file); err != nil {
			a.status = http.StatusForbidden
			return a
		}
	}
	b, err := os.ReadFile(a.file)
	if err != nil {
		a.status = http.StatusNotFound
		return a
	}
	_, err = os.Stat(a.file + ".gz")
	a.status, a.body, a.gz = http.StatusOK, string(b), err == nil
	return a
}

// answer is what loc, or the server when it is nil, sends with a response.
func (c *httpdConf) answer(loc *httpdLocation) httpdAnswer {
	// static-cache-control, on by default, sends no-cache with every file.
	a := httpdAnswer{header: map[string]string{"Cache-Control": "no-cache"}}
	for k, v := range c.header {
		a.header[k] = v
	}
	a.gzipStatic = c.gzipStatic
	if loc != nil {
		for k, v := range loc.header {
			a.header[k] = v
		}
		if loc.gzipStatic != nil {
			a.gzipStatic = *loc.gzipStatic
		}
	}
	a.block = loc != nil && loc.block
	return a
}

func (c *httpdConf) lookup(p string) (*httpdLocation, []string) {
	for i := range c.locations {
		if caps, ok := c.locations[i].loc.Match(p); ok {
			return &c.locations[i], caps
		}
	}
	return nil, nil
}

// readHTTPDConf reads the server block of an httpd.conf, expanding macros.
// Anything it does not model fails the test, so a new directive gets a
// model before the dev server is trusted to match it.
func readHTTPDConf(t *testing.T, file string) *httpdConf {
	t.Helper()
	src, err := os.ReadFile(file)
	if err != nil {
		t.Fatal(err)
	}
	stmts := parseHTTPDConf(t, tokenizeHTTPDConf(t, string(src)))
	c := &httpdConf{header: map[string]string{}}
	servers := 0
	for _, s := range stmts {
		switch s.words[0] {
		case "types":
		case "server":
			servers++
			for _, o := range s.block {
				switch o.words[0] {
				case "listen", "tls", "root", "default", "errdocs", "alias", "log":
				case "gzip-static":
					c.gzipStatic = true
				case "header":
					setHeader(t, c.header, o.words)
				case "location":
					c.locations = append(c.locations, readLocation(t, o))
				default:
					t.Fatalf("httpd.conf: server option %q has no model", o.words)
				}
			}
		default:
			t.Fatalf("httpd.conf: %q has no model", s.words)
		}
	}
	if servers != 1 {
		t.Fatalf("httpd.conf: %d server blocks, want 1", servers)
	}
	return c
}

func readLocation(t *testing.T, s httpdStmt) httpdLocation {
	t.Helper()
	l := httpdLocation{header: map[string]string{}}
	switch {
	case len(s.words) == 3 && s.words[1] == "match":
		l.loc = httpd.PatternLocation(s.words[2])
	case len(s.words) == 2:
		l.loc = httpd.GlobLocation(s.words[1])
	default:
		t.Fatalf("httpd.conf: location %q has no model", s.words)
	}
	if err := l.loc.Check(); err != nil {
		t.Fatalf("httpd.conf: %v", err)
	}
	for _, o := range s.block {
		switch {
		case len(o.words) == 1 && o.words[0] == "gzip-static":
			l.gzipStatic = new(true)
		case len(o.words) == 2 && o.words[0] == "no" && o.words[1] == "gzip-static":
			l.gzipStatic = new(false)
		case len(o.words) == 2 && o.words[0] == "root":
			l.root = o.words[1]
		case len(o.words) == 3 && o.words[0] == "request" && o.words[1] == "strip":
			l.elsewhere = true
		case len(o.words) == 4 && o.words[0] == "authenticate" && o.words[2] == "with":
			l.auth = true
		case len(o.words) == 1 && o.words[0] == "block":
			l.block = true
		case len(o.words) == 3 && o.words[0] == "request" && o.words[1] == "rewrite":
			l.rewrite = o.words[2]
			if err := httpd.CheckRewrite(l.loc, l.rewrite); err != nil {
				t.Fatalf("httpd.conf: %v", err)
			}
		case o.words[0] == "header":
			setHeader(t, l.header, o.words)
		default:
			t.Fatalf("httpd.conf: location option %q has no model", o.words)
		}
	}
	return l
}

// setHeader models `header set NAME VALUE always` and `header remove NAME
// always`. Every header here must be sent on errors too, as the dev server
// sends them.
func setHeader(t *testing.T, h map[string]string, w []string) {
	t.Helper()
	switch {
	case len(w) == 5 && w[1] == "set" && w[4] == "always":
		h[w[2]] = w[3]
	case len(w) == 4 && w[1] == "remove" && w[3] == "always":
		h[w[2]] = ""
	case len(w) == 4 && w[1] == "set" && w[2] == "Cache-Control":
		// Successful responses only, which is all it is for, as the dev
		// server sends it.
		h[w[2]] = w[3]
	default:
		t.Fatalf("httpd.conf: %q has no model", w)
	}
}

type httpdStmt struct {
	words []string
	block []httpdStmt
}

// tokenizeHTTPDConf splits a config into words, "{", "}" and "\n", with
// comments dropped, quotes removed and $macros expanded.
func tokenizeHTTPDConf(t *testing.T, src string) []string {
	t.Helper()
	var toks []string
	macros := map[string]string{}
	for line := range strings.Lines(src) {
		var words []string
		quoted := map[int]bool{}
		for i := 0; i < len(line); {
			switch c := line[i]; {
			case c == '#':
				i = len(line)
			case c == ' ' || c == '\t' || c == '\n':
				i++
			case c == '"':
				end := strings.IndexByte(line[i+1:], '"')
				if end < 0 {
					t.Fatalf("httpd.conf: unterminated quote: %q", line)
				}
				quoted[len(words)] = true
				words = append(words, line[i+1:i+1+end])
				i += end + 2
			case c == '{' || c == '}' || c == '=':
				words = append(words, string(c))
				i++
			default:
				end := strings.IndexAny(line[i:], " \t\n{}=#\"")
				if end < 0 {
					end = len(line) - i
				}
				words = append(words, line[i:i+end])
				i += end
			}
		}
		if len(words) == 3 && words[1] == "=" {
			macros[words[0]] = words[2]
			continue
		}
		for i, w := range words {
			if name, ok := strings.CutPrefix(w, "$"); ok && !quoted[i] {
				v, ok := macros[name]
				if !ok {
					t.Fatalf("httpd.conf: undefined macro %s", w)
				}
				w = v
			}
			toks = append(toks, w)
		}
		toks = append(toks, "\n")
	}
	return toks
}

func parseHTTPDConf(t *testing.T, toks []string) []httpdStmt {
	t.Helper()
	stmts, rest := parseHTTPDBlock(t, toks)
	if len(rest) != 0 {
		t.Fatalf("httpd.conf: stray %q", rest[0])
	}
	return stmts
}

func parseHTTPDBlock(t *testing.T, toks []string) ([]httpdStmt, []string) {
	var stmts []httpdStmt
	var cur httpdStmt
	flush := func() {
		if len(cur.words) > 0 {
			stmts = append(stmts, cur)
		}
		cur = httpdStmt{}
	}
	for len(toks) > 0 {
		tok := toks[0]
		toks = toks[1:]
		switch tok {
		case "\n":
			flush()
		case "{":
			cur.block, toks = parseHTTPDBlock(t, toks)
			flush()
		case "}":
			flush()
			return stmts, toks
		default:
			cur.words = append(cur.words, tok)
		}
	}
	flush()
	return stmts, nil
}