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()) + } +}