vitrine

implement expose-git; block direct .git URLs by default

commit 8af31ec12bcee064581152c56177dadedea0b2c8 · parent 51e714cd19 · browse this tree
Ximianth, 2026-07-28 14:07:23 UTC

Two halves:

Default (expose-git absent or false): .git is hidden from listings AND
now rejected on direct URLs.  Previously only the listing was filtered;
a direct request like /mount/.git/config?fmt=raw served the file
(verified against the running instance before this change).  That latent
gap is closed: serveFromTree rejects any path containing a .git segment.

With expose-git true: .git lists and serves like any other directory,
and newServer runs git update-server-info in the exhibit at startup,
because ordinary commits never maintain .git/info/refs and dumb-HTTP
clone either fails or silently delivers a stale tip without it
(verified empirically; gc is the only ordinary op that refreshes it).
Writing into .git is a deliberate, opt-in exception to vitrine's
read-only stance, and failure is a loud log line rather than fatal,
since the exhibit may not be a git repo at all.

Tests cover both directions at the HTTP level, and the
update-server-info test builds a real scratch repo with one commit
and asserts info/refs exists after server construction.
diff --git a/serve.go b/serve.go
index 172f9d8..8b8e472 100644
--- a/serve.go
+++ b/serve.go
@@ -3,8 +3,10 @@ package main
 import (
 	"fmt"
 	"io/fs"
+	"log"
 	"net/http"
 	"os"
+	"os/exec"
 	"path"
 	"sort"
 	"strings"
@@ -31,10 +33,32 @@ func newServer(exhibits map[string]exhibit) (*server, error) {
 			return nil, fmt.Errorf("opening exhibit %q: %w", name, err)
 		}
 		s.trees[name] = tree{root: root, exposeGit: ex.ExposeGit}
+		if ex.ExposeGit {
+			updateGitServerInfo(name, ex.Path)
+		}
 	}
 	return s, nil
 }
 
+// updateGitServerInfo runs `git update-server-info` in the exhibit directory,
+// regenerating .git/info/refs and .git/objects/info/packs so that dumb-HTTP
+// clone sees current refs (ordinary commits never maintain those files;
+// without them a clone either fails or silently delivers a stale tip).
+//
+// This writes into the exhibit's .git directory: a deliberate, opt-in exception
+// to vitrine's otherwise strictly read-only stance toward the trees it serves.
+// Failure is logged, not fatal: the exhibit may not be a git repo,
+// and plain file serving should keep working regardless.
+// Executing git from PATH is a conscious choice; vitrine is glue,
+// and reimplementing the two info files buys nothing but maintenance.
+func updateGitServerInfo(name, dir string) {
+	cmd := exec.Command("git", "update-server-info")
+	cmd.Dir = dir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		log.Printf("exhibit %q: git update-server-info failed (dumb-HTTP clone may be stale or broken): %v: %s", name, err, strings.TrimSpace(string(out)))
+	}
+}
+
 func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// The "/-/" prefix is reserved for server-internal assets,
 	// keeping it distinct from any configured mount name.
@@ -110,8 +134,25 @@ func (s *server) mountChildren(prefix string) []dirEntry {
 	return entries
 }
 
+// hasGitSegment reports whether any segment of a slash-separated relative path is ".git".
+// Listings hide .git at every depth (nested repos included), so URL reachability matches.
+func hasGitSegment(rel string) bool {
+	for _, seg := range strings.Split(rel, "/") {
+		if seg == ".git" {
+			return true
+		}
+	}
+	return false
+}
+
 // serveFromTree dispatches a request that resolved inside a mounted tree.
+// Unless the exhibit opts in via expose-git, any path with a .git segment is rejected here,
+// which covers direct URLs as well as listings (listings are filtered in serveDir).
 func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
+	if !tr.exposeGit && hasGitSegment(rel) {
+		http.NotFound(w, r)
+		return
+	}
 	root := tr.root
 	info, err := root.Stat(rel)
 	if err != nil {
@@ -154,7 +195,8 @@ func serveRaw(w http.ResponseWriter, r *http.Request, root *os.Root, rel string)
 }
 
 // serveDir renders a directory listing.
-// The .git directory is omitted: it's not useful to browse and clutters the view.
+// The .git directory is omitted unless the exhibit opts in via expose-git:
+// it's not useful to browse and clutters the view.
 func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
 	ents, err := fs.ReadDir(tr.root.FS(), rel)
 	if err != nil {
@@ -164,7 +206,7 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount
 	var listing []dirEntry
 	listing = append(listing, dirEntry{Name: "../", Href: "../"})
 	for _, e := range ents {
-		if e.Name() == ".git" {
+		if e.Name() == ".git" && !tr.exposeGit {
 			continue
 		}
 		n := e.Name()
diff --git a/serve_test.go b/serve_test.go
index c820ef6..5ac9140 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -4,6 +4,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -183,3 +184,71 @@ func TestNestedMountBreadcrumbsReachRoot(t *testing.T) {
 		}
 	}
 }
+
+func TestGitUnreachableByDefault(t *testing.T) {
+	srv := newTestServer(t)
+	for _, url := range []string{
+		"/demo/.git/",
+		"/demo/.git",
+		"/demo/.git/config",
+		"/demo/.git/config?fmt=raw",
+	} {
+		rec := get(t, srv, url)
+		if rec.Code != http.StatusNotFound {
+			t.Errorf("%s: status = %d, want 404", url, rec.Code)
+		}
+		if strings.Contains(rec.Body.String(), "should be hidden") {
+			t.Errorf("%s: leaked .git contents", url)
+		}
+	}
+}
+
+// newExposeGitTestServer builds a server around a real scratch git repo
+// (one commit) with expose-git enabled on the exhibit.
+func newExposeGitTestServer(t *testing.T) (*server, string) {
+	t.Helper()
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# repo\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	for _, args := range [][]string{
+		{"init", "-q"},
+		{"-c", "user.name=t", "-c", "user.email=t@t", "add", "."},
+		{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"},
+	} {
+		cmd := exec.Command("git", args...)
+		cmd.Dir = dir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			t.Fatalf("git %v: %v: %s", args, err, out)
+		}
+	}
+	srv, err := newServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}})
+	if err != nil {
+		t.Fatal(err)
+	}
+	return srv, dir
+}
+
+func TestExposeGitListsAndServes(t *testing.T) {
+	srv, _ := newExposeGitTestServer(t)
+	rec := get(t, srv, "/repo/")
+	if !strings.Contains(rec.Body.String(), `href=".git/"`) {
+		t.Errorf(".git missing from listing; body:\n%s", rec.Body.String())
+	}
+	rec = get(t, srv, "/repo/.git/HEAD?fmt=raw")
+	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ref:") {
+		t.Errorf(".git/HEAD: status = %d, body = %q", rec.Code, rec.Body.String())
+	}
+}
+
+func TestExposeGitRunsUpdateServerInfo(t *testing.T) {
+	srv, dir := newExposeGitTestServer(t)
+	// Ordinary commits never write info/refs; server construction must have.
+	if _, err := os.Stat(filepath.Join(dir, ".git/info/refs")); err != nil {
+		t.Fatalf("info/refs not generated: %v", err)
+	}
+	rec := get(t, srv, "/repo/.git/info/refs?fmt=raw")
+	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "refs/heads/") {
+		t.Errorf("info/refs: status = %d, body = %q", rec.Code, rec.Body.String())
+	}
+}