Download
232 lines 7.1 KiBGoModified 2026-10-20 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
package render_test

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"testing"
)

// These tests push a hostile commit, then undo it the two ways a
// maintainer would (a revert commit, or a force push back to before it),
// and check that the site holds nothing the hostile commit put there.

// hostile commits files that render, plus a file "x" beside a directory
// "x.html". The file's page is code/<repo>/x.html, but the directory's
// children's pages go in a directory of that same name (x.html/y is
// code/<repo>/x.html/y.html), so rendering fails partway, after some of
// its pages are written.
func (e *env) pushHostile() {
	e.t.Helper()
	for _, p := range []string{"a1", "m1", "z1"} {
		e.write("evil/"+p+".txt", "evil\n")
	}
	e.write("x", "file\n")
	e.write("x.html/y", "beside the file\n")
	e.commit("hostile")
	e.push(false)
	_, err := e.site.RenderRepo(e.repo, false)
	e.t.Logf("hostile render: %v", err)
	if err == nil {
		e.t.Fatal("rendering the colliding pages should fail")
	}
}

func (e *env) mustBeClean() {
	e.t.Helper()
	for _, p := range []string{"evil/a1.txt", "evil/m1.txt", "evil/z1.txt", "x", "x.html/y"} {
		e.mustNotExist(e.page(p), e.page(p)+".gz", e.raw(p))
	}
	e.mustNotExist(e.page("evil"), "code/"+e.repo+"/evil", "raw/"+e.repo+"/evil")
	e.mustExist(e.page("README.md"), e.page("src/main.c"))
}

func TestRevertAfterFailedRender(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	e.pushHostile()
	e.git(e.work, "revert", "--no-edit", "HEAD")
	e.push(false)
	e.render()
	e.mustBeClean()
}

func TestRollbackAfterFailedRender(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	e.pushHostile()
	e.git(e.work, "reset", "-q", "--hard", "HEAD~1")
	e.push(true)
	e.render()
	e.mustBeClean()
}

// A render that dies outright (killed, a crash, a full disk) leaves the
// state of the render before it, marked unfinished, and pages of its own
// commit. The next render must clean those up as well.
func TestRevertAfterInterruptedRender(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	statePath := e.site.Lay.StatePath(e.repo)
	before, err := e.site.LoadState(e.repo)
	if err != nil {
		t.Fatal(err)
	}
	e.write("evil.txt", "evil\n")
	e.commit("hostile")
	e.push(false)
	e.render()
	e.mustExist(e.page("evil.txt"), e.raw("evil.txt"))
	// What the render would have left had it died before saving.
	before.Unfinished = true
	b, err := json.Marshal(before)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(statePath, b, 0o644); err != nil {
		t.Fatal(err)
	}
	e.git(e.work, "revert", "--no-edit", "HEAD")
	e.push(false)
	e.render()
	e.mustNotExist(e.page("evil.txt"), e.raw("evil.txt"))
	if st, err := e.site.LoadState(e.repo); err != nil || st.Unfinished {
		t.Errorf("state after a finished render: %+v, %v", st, err)
	}
}

// A tag moved or deleted in the bare repository itself runs no hook, and
// pushers cannot delete tags at all, so the next render has to notice
// that a tag's tarball holds the wrong commit or names no tag.
func TestTagTarballsFollowTags(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.write("evil.txt", "evil\n")
	e.commit("hostile")
	e.git(e.work, "tag", "v1")
	e.git(e.work, "tag", "-a", "-m", "release", "v2")
	e.push(false)
	e.git(e.work, "push", "-q", "origin", "--tags")
	e.render()
	v1, v2 := "archive/proj/proj-v1.tar.gz", "archive/proj/proj-v2.tar.gz"
	mustContain(t, "v1 tarball", gunzip(t, e.h(v1)), "evil.txt")
	e.mustExist(v2)
	// A write that died leaves a temporary file beside the tarballs.
	tmp := "archive/proj/.proj-v3.tar.gz.tmp-123"
	if err := os.WriteFile(e.h(tmp), []byte("evil"), 0o644); err != nil {
		t.Fatal(err)
	}

	e.git(e.work, "revert", "--no-edit", "HEAD")
	e.push(false)
	bare := e.site.Lay.GitDir(e.repo)
	e.git(bare, "tag", "-f", "v1", "main")
	e.git(bare, "tag", "-d", "v2")
	e.render()
	mustNotContain(t, "v1 tarball", gunzip(t, e.h(v1)), "evil.txt")
	e.mustNotExist(v2, tmp)
	mustNotContain(t, "tag list", e.read("iframes/proj/downloads.html"), "proj-v2")
}

// A superseded tip tarball stays for archive_retention, for pages loaded
// before the push, unless a force push took its commit off the branch.
func TestRollbackRemovesTipTarball(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	e.write("evil.txt", "evil\n")
	e.commit("hostile")
	e.push(false)
	e.render()
	evil := "snapshot/proj/proj@" + strings.TrimSpace(e.git(e.work, "rev-parse", "--short=12", "HEAD")) + ".tar.gz"
	e.mustExist(evil)

	// A revert keeps the commit in the branch's history, so its tarball
	// stays for now.
	e.git(e.work, "revert", "--no-edit", "HEAD")
	e.push(false)
	e.render()
	e.mustExist(evil)

	e.git(e.work, "reset", "-q", "--hard", "HEAD~2")
	e.push(true)
	e.render()
	e.mustNotExist(evil)
}

// A commit whose files add up to more than repo_max_bytes is not
// rendered: the site keeps the last render, untouched and not marked
// unfinished, and a tag of it gets no tarball.
func TestRepoMaxBytes(t *testing.T) {
	e := newEnv(t, "proj", "repo_max_bytes: 4096\n")
	e.seed()
	e.render()
	readme := e.read(e.page("README.md"))

	e.write("big/a.txt", strings.Repeat("a", 3000))
	e.write("big/b.txt", strings.Repeat("b", 3000))
	e.commit("too big")
	e.push(false)
	e.git(e.work, "tag", "big")
	e.git(e.work, "push", "-q", "origin", "big")
	_, err := e.site.RenderRepo(e.repo, false)
	if err == nil || !strings.Contains(err.Error(), "repo_max_bytes") {
		t.Fatalf("rendering a tree over the cap: %v", err)
	}
	e.mustNotExist(e.page("big/a.txt"), e.raw("big/a.txt"), "archive/proj/proj-big.tar.gz")
	if got := e.read(e.page("README.md")); got != readme {
		t.Error("a refused render rewrote a page")
	}
	if st, err := e.site.LoadState(e.repo); err != nil || st.Unfinished {
		t.Errorf("state after a refused render: %+v, %v", st, err)
	}
	// A tag event for it removes a tarball the tag's old target had.
	if err := os.MkdirAll(e.h("archive/proj"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(e.h("archive/proj/proj-big.tar.gz"), []byte("old"), 0o644); err != nil {
		t.Fatal(err)
	}
	head := strings.TrimSpace(e.git(e.work, "rev-parse", "HEAD"))
	if err := e.site.HandleTag(e.repo, "big", head); err == nil {
		t.Error("archiving a tag over the cap should fail")
	}
	e.mustNotExist("archive/proj/proj-big.tar.gz")

	e.git(e.work, "revert", "--no-edit", "HEAD")
	e.push(false)
	e.render()
	e.mustNotExist(e.page("big/a.txt"))
	mustContain(t, "drawer", e.read(e.drawer()), `id="README.md"`)
}

// repo_max_files refuses a commit of too many files the same way.
func TestRepoMaxFiles(t *testing.T) {
	e := newEnv(t, "proj", "repo_max_files: 15\n")
	e.seed() // 12 files
	e.render()
	for i := range 5 {
		e.write(fmt.Sprintf("many/%d", i), "")
	}
	e.commit("too many")
	e.push(false)
	_, err := e.site.RenderRepo(e.repo, false)
	if err == nil || !strings.Contains(err.Error(), "repo_max_files") {
		t.Fatalf("rendering a tree over the cap: %v", err)
	}
	e.mustNotExist(e.page("many/0"), e.raw("many/0"))
	if st, err := e.site.LoadState(e.repo); err != nil || st.Unfinished {
		t.Errorf("state after a refused render: %+v, %v", st, err)
	}

	e.rm("many/0")
	e.rm("many/1")
	e.commit("fewer")
	e.push(false)
	e.render()
	e.mustExist(e.page("many/2"), e.raw("many/2"))
}