A cmd/specsrht/main.go => cmd/specsrht/main.go +410 -0
@@ 0,0 1,410 @@
+// Command specsrht is the spec.sr.ht daemon, and — invoked under another name
+// — the receive hooks of every space it serves.
+//
+// It runs three things in one process:
+//
+// - the hook RPC socket at <repos>/.specsrht/hook.sock, which the pre-receive,
+// update and post-receive hooks of every space call. Validation lives here
+// and not in the hooks because bleve is single-writer and this process holds
+// the index, and because the push path and the API must not be able to
+// disagree about what is valid.
+// - an HTTP listener on -b (default localhost:5091). Phase 1 serves /healthz
+// and nothing else; the web UI is Phase 2 and mounts where mountWeb says.
+// - the reconciler, at startup and then periodically, repairing the
+// divergence a killed daemon leaves between git refs, Postgres and the
+// index.
+//
+// # Running as a hook
+//
+// Every hook a space's repository runs is a symlink to this binary. When
+// argv[0] names a hook the process handles that hook and exits, touching
+// neither the configuration nor the database; see the hooks package. The
+// explicit form `specsrht hook <name>` does the same thing by hand.
+//
+// # Flags
+//
+// Parsed by core-go's server.New:
+//
+// -b addr bind address (repeatable); default localhost:5091
+// -d debug (verbose request logging in core-go)
+// -m addr Prometheus metrics bind (default random port)
+// -p addr pprof bind (default random localhost port)
+//
+// Configuration comes from the shared SourceHut config.ini through core-go's
+// fixed search path. Every required key is checked before anything is opened,
+// and all missing ones are reported together, so a misconfigured instance
+// fails once at startup rather than once per restart.
+//
+// # Shutdown
+//
+// SIGINT and SIGTERM both start a warm shutdown. core-go's server.Run only
+// handles SIGINT — which is why compare.sr.ht's systemd unit sets
+// KillSignal=SIGINT — so this daemon installs a handler that turns a SIGTERM
+// into that same SIGINT. A unit for this service therefore needs no
+// KillSignal= line: the systemd default works.
+package main
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ chimw "github.com/go-chi/chi/v5/middleware"
+ _ "github.com/lib/pq" // registers the "postgres" database/sql driver
+ "github.com/vaughan0/go-ini"
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+ coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/hooks"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+const (
+ // serviceName is the SourceHut service identifier and our config section.
+ // The ".sr.ht" suffix is what puts us in the nav network list.
+ serviceName = "spec.sr.ht"
+
+ // defaultBind is the address core-go binds when no -b is given.
+ defaultBind = "localhost:5091"
+
+ // pingTimeout bounds the startup connectivity check against Postgres.
+ pingTimeout = 10 * time.Second
+
+ // shutdownGrace is how long the hook socket is given to finish the calls
+ // already in flight once the HTTP listener has drained. A push being
+ // validated at that moment finishes rather than being failed closed.
+ shutdownGrace = 30 * time.Second
+)
+
+func main() {
+ // Hook mode first: a hook must not read a config file, open Postgres, or
+ // bind anything. It talks to the daemon over a socket and exits.
+ if _, _, isHook := hooks.ModeFromArgs(os.Args); isHook {
+ os.Exit(hooks.Run(hooks.Runtime{Args: os.Args}))
+ }
+
+ log := newLogger()
+ slog.SetDefault(log)
+
+ if err := run(log); err != nil {
+ // Plain text, not a log record. A startup failure is read by a human
+ // on a terminal, and the configuration report is deliberately several
+ // lines long — a structured handler would escape it into one.
+ fmt.Fprintf(os.Stderr, "spec.sr.ht did not start: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+// newLogger builds the process logger. LOG_LEVEL raises or lowers verbosity;
+// everything goes to stderr, because a hook's stdout is forwarded to the
+// pushing client and this binary is both programs.
+func newLogger() *slog.Logger {
+ level := new(slog.LevelVar)
+ level.Set(parseLevel(os.Getenv("LOG_LEVEL")))
+ return slog.New(scribe.NewTintHandler(
+ scribe.WithWriter(os.Stderr),
+ scribe.WithLevel(level),
+ ))
+}
+
+func parseLevel(s string) slog.Level {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "debug":
+ return slog.LevelDebug
+ case "warn", "warning":
+ return slog.LevelWarn
+ case "error":
+ return slog.LevelError
+ default:
+ return slog.LevelInfo
+ }
+}
+
+func run(log *slog.Logger) error {
+ // LoadConfig never fails on a missing file — it returns a nil ini.File —
+ // so validateConfig is what turns an unconfigured instance into one clear
+ // message instead of a panic deep inside the first request.
+ conf := config.LoadConfig()
+ cfg, err := validateConfig(conf)
+ if err != nil {
+ return err
+ }
+
+ pool, err := openDatabase(cfg.ConnectionString)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+
+ svc, err := service.New(cfg, pool)
+ if err != nil {
+ return err
+ }
+
+ // Refresh every space's hooks before anything can be pushed to it. This is
+ // fatal on failure by design: a space whose hooks are missing accepts
+ // pushes that are never validated, which is the one outcome the whole
+ // receive path exists to prevent. A daemon that will not start is loud; a
+ // space quietly accepting malformed documents is not.
+ binary, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("locate this binary, which every hook symlinks to: %w", err)
+ }
+ if err := refreshHooks(context.Background(), log, svc, binary); err != nil {
+ return err
+ }
+
+ hookSrv, err := hooks.NewServer(hooks.Options{
+ Backend: svc,
+ Socket: hooks.SocketPath(cfg.Repos),
+ Log: log,
+ OnPush: pushNotifier(log),
+ })
+ if err != nil {
+ return err
+ }
+ if err := hookSrv.Listen(); err != nil {
+ return err
+ }
+
+ // server.New parses -b/-d/-m/-p and runs crypto.InitCrypto(conf), whose
+ // two required keys validateConfig already checked, so it cannot fatal
+ // here for a reason we have not already reported.
+ srv := coreserver.New(serviceName, defaultBind, conf, os.Args)
+ mountRoutes(srv.AnonRouter(), conf)
+
+ ctx, stop := context.WithCancel(context.Background())
+ defer stop()
+
+ served := make(chan error, 1)
+ go func() { served <- hookSrv.Serve(ctx) }()
+ go svc.RunReconciler(ctx, service.DefaultReconcileInterval, reconcileReporter(log))
+
+ bridgeSIGTERM(log)
+
+ log.Info("spec.sr.ht starting",
+ "bind", defaultBind,
+ "repos", cfg.Repos,
+ "cache", cfg.Cache,
+ "origin", cfg.Origin,
+ "hook_socket", hookSrv.Socket(),
+ "reconcile_interval", service.DefaultReconcileInterval.String(),
+ )
+
+ // Blocks until SIGINT — which bridgeSIGTERM makes SIGTERM equivalent to —
+ // and then drains the HTTP listeners.
+ srv.Run()
+
+ log.Info("draining the hook socket", "grace", shutdownGrace.String())
+ stop()
+ select {
+ case err := <-served:
+ if err != nil {
+ log.Error("hook socket stopped with an error", scribe.Err(err))
+ }
+ case <-time.After(shutdownGrace):
+ log.Warn("hook socket did not drain in time; closing it")
+ }
+ if err := hookSrv.Close(); err != nil {
+ log.Error("could not close the hook socket", scribe.Err(err))
+ }
+ log.Info("spec.sr.ht stopped")
+ return nil
+}
+
+// validateConfig checks every key this daemon needs before anything is opened,
+// and reports all of the missing ones at once so an operator fixes the config
+// in one pass instead of discovering each gap on a separate restart.
+//
+// service.LoadConfig owns our own section and collects its own gaps the same
+// way; the two lists are merged into one message. The keys checked here are
+// the ones core-go itself fatals on, which belong to server.New's contract
+// rather than to service/ — duplicating them there would give the instance two
+// lists to keep in sync.
+func validateConfig(conf ini.File) (service.Config, error) {
+ var missing []string
+ require := func(section, key, why string) {
+ if v, ok := conf.Get(section, key); !ok || strings.TrimSpace(v) == "" {
+ missing = append(missing, fmt.Sprintf("[%s] %s — %s", section, key, why))
+ }
+ }
+
+ // Both are read by crypto.InitCrypto, which server.New calls and which
+ // fatals with a terse message when either is absent. The webhook key is
+ // required even though v1 emits no webhooks.
+ require("sr.ht", "network-key", "fernet key for the unified-login cookie")
+ require("webhooks", "private-key", "webhook signing key; crypto.InitCrypto requires it")
+
+ cfg, cfgErr := service.LoadConfig(conf)
+
+ if len(missing) == 0 && cfgErr == nil {
+ return cfg, nil
+ }
+
+ var b strings.Builder
+ b.WriteString("incomplete configuration.")
+ if len(missing) > 0 {
+ fmt.Fprintf(&b, "\n\nMissing keys the SourceHut runtime requires:\n\t%s",
+ strings.Join(missing, "\n\t"))
+ }
+ if cfgErr != nil {
+ fmt.Fprintf(&b, "\n\n%s", cfgErr)
+ }
+ return service.Config{}, errors.New(b.String())
+}
+
+// openDatabase opens the pool the whole daemon shares — request handlers, the
+// reconciler and the hook RPC alike — and proves it works before serving.
+//
+// sql.Open alone connects lazily, so a wrong DSN would first surface as a
+// rejected push. Fail-closed makes that safe but not pleasant; failing at
+// startup names the problem while somebody is still watching.
+func openDatabase(dsn string) (*sql.DB, error) {
+ pool, err := sql.Open("postgres", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("open the database: %w", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
+ defer cancel()
+ if err := pool.PingContext(ctx); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("reach the database: %w", err)
+ }
+ return pool, nil
+}
+
+// refreshHooks installs this binary's hooks into every space.
+//
+// It runs at startup rather than only at space creation so that an upgrade
+// which changes the wire protocol, the socket path or the hook set repairs
+// every repository by restarting — there is no separate migration step and no
+// repository left speaking last week's protocol.
+func refreshHooks(ctx context.Context, log *slog.Logger, svc *service.Service, binary string) error {
+ spaces, err := svc.ListSpaces(ctx)
+ if err != nil {
+ return fmt.Errorf("list spaces to refresh their hooks: %w", err)
+ }
+ for _, sp := range spaces {
+ if err := hooks.InstallSpace(svc.ReposRoot(), sp.Ref, hooks.InstallOptions{Binary: binary}); err != nil {
+ return fmt.Errorf("install the receive hooks of %s: %w "+
+ "(a space whose hooks are missing would accept unvalidated pushes, so this is fatal; "+
+ "repair or remove the repository and start again)", sp.Ref, err)
+ }
+ }
+ log.Info("receive hooks refreshed", "spaces", len(spaces), "binary", binary)
+ return nil
+}
+
+// mountRoutes installs what Phase 1 serves over HTTP.
+func mountRoutes(router chi.Router, conf ini.File) {
+ // server.New already froze the anonymous router for direct middleware
+ // registration, so middleware and routes go in together inside a Group —
+ // which chi permits on a fresh inline mux sharing the same routing tree.
+ router.Group(func(r chi.Router) {
+ r.Use(chimw.RealIP)
+ r.Use(chimw.Recoverer)
+ r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ fmt.Fprint(w, "ok")
+ })
+ })
+
+ mountWeb(router, conf)
+}
+
+// mountWeb is where Phase 2's web UI attaches.
+//
+// It is left empty rather than stubbed with a placeholder route: the read
+// plane is a milestone of its own, and a handler that renders nothing would be
+// indistinguishable from one that is broken. The chain it will need is the one
+// compare.sr.ht and dolt.sr.ht assemble by hand — RealIP, Recoverer, Logger,
+// config.Middleware(conf, serviceName), database.Middleware(pool) and
+// Service.Resolver().Middleware() — and deliberately not core-go's
+// WithDefaultMiddleware, whose auth middleware 401s any un-cookied request and
+// would make the read plane, which is anonymous-capable, unreachable.
+func mountWeb(_ chi.Router, _ ini.File) {}
+
+// pushNotifier is what the daemon does when a push lands.
+//
+// Phase 1 records it and nothing more. Reindexing and advancing the space's
+// index rev stamp are Phase 2's, because bleve and the stamp arrive together:
+// moving the stamp now, with no index behind it, would assert that the index
+// is current and remove the reconciler's only way of noticing that it is not.
+func pushNotifier(log *slog.Logger) hooks.PushNotifier {
+ return func(_ context.Context, space core.SpaceRef, updates []hooks.RefUpdate) error {
+ refs := make([]string, 0, len(updates))
+ for _, u := range updates {
+ refs = append(refs, u.String())
+ }
+ log.Info("push landed; reindex pending",
+ "space", space.String(),
+ "refs", refs,
+ "note", "indexing lands in Phase 2; the reconciler reports the staleness until then")
+ return nil
+ }
+}
+
+// reconcileReporter logs the outcome of each reconciler pass. A failure is not
+// fatal: the next pass tries again, and a daemon that exits on a transient
+// Postgres error takes the push path down with it.
+func reconcileReporter(log *slog.Logger) func(*service.ReconcileReport, error) {
+ return func(rep *service.ReconcileReport, err error) {
+ if err != nil {
+ log.Error("reconciler pass failed", scribe.Err(err))
+ return
+ }
+ attrs := []any{
+ "spaces", rep.Spaces,
+ "repaired", len(rep.Repaired),
+ "stale_indexes", len(rep.Reindex),
+ "failures", len(rep.Failures),
+ }
+ for _, r := range rep.Repaired {
+ log.Info("reconciler repaired divergence", "repair", fmt.Sprint(r))
+ }
+ for _, f := range rep.Failures {
+ log.Warn("reconciler could not repair", "failure", fmt.Sprint(f))
+ }
+ log.Info("reconciler pass complete", attrs...)
+ }
+}
+
+// bridgeSIGTERM makes systemd's default stop signal work.
+//
+// core-go's server.Run listens for SIGINT only, which is why compare.sr.ht's
+// unit carries KillSignal=SIGINT. Rather than require that line here, a
+// SIGTERM is turned into the SIGINT server.Run is waiting for. Both signals
+// are registered before Run installs its own handler, so a signal arriving in
+// the gap is caught rather than killing the process outright.
+//
+// After the first shutdown signal server.Run calls signal.Reset(os.Interrupt),
+// restoring the default disposition — so a second signal terminates
+// immediately, which is the documented behaviour and is why this keeps
+// forwarding rather than stopping after one.
+func bridgeSIGTERM(log *slog.Logger) {
+ sig := make(chan os.Signal, 2)
+ signal.Notify(sig, syscall.SIGTERM, os.Interrupt)
+ go func() {
+ for s := range sig {
+ if s != syscall.SIGTERM {
+ continue
+ }
+ log.Info("SIGTERM received; starting the warm shutdown core-go waits for SIGINT to begin")
+ if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil {
+ log.Error("could not raise SIGINT for the warm shutdown", scribe.Err(err))
+ }
+ }
+ }()
+}
A cmd/specsrht/main_test.go => cmd/specsrht/main_test.go +169 -0
@@ 0,0 1,169 @@
+package main
+
+import (
+ "log/slog"
+ "strings"
+ "testing"
+
+ "github.com/vaughan0/go-ini"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/hooks"
+)
+
+// completeConfig is an instance config with every key this daemon needs: the
+// two core-go fatals on, and everything service.LoadConfig reads.
+func completeConfig() ini.File {
+ return ini.File{
+ "sr.ht": {
+ "network-key": "wF4z9nZ6C0oL0kQyq3M0j0m3iSvWq7iFhk1v9E9WcBM=",
+ "owner-name": "bigbes",
+ "owner-email": "bigbes@example.invalid",
+ },
+ "webhooks": {
+ "private-key": "eV5B1o1M4a0dQKr2v4h0hR3vJm0m5g7jL1kQpS4Xk1s=",
+ },
+ serviceName: {
+ "origin": "https://spec.srht.bigb.es",
+ "repos": "/var/lib/spec",
+ "cache": "/var/cache/spec",
+ "connection-string": "postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable",
+ },
+ }
+}
+
+func without(conf ini.File, section, key string) ini.File {
+ out := ini.File{}
+ for s, kv := range conf {
+ copied := ini.Section{}
+ for k, v := range kv {
+ if s == section && k == key {
+ continue
+ }
+ copied[k] = v
+ }
+ out[s] = copied
+ }
+ return out
+}
+
+func TestValidateConfigAcceptsACompleteConfig(t *testing.T) {
+ cfg, err := validateConfig(completeConfig())
+ if err != nil {
+ t.Fatalf("validateConfig: %v", err)
+ }
+ if cfg.Repos != "/var/lib/spec" || cfg.Cache != "/var/cache/spec" {
+ t.Errorf("config not carried through: %+v", cfg)
+ }
+ if cfg.Instance.OwnerName != "bigbes" {
+ t.Errorf("owner not carried through: %+v", cfg.Instance)
+ }
+}
+
+func TestValidateConfigNamesEachMissingKey(t *testing.T) {
+ tests := []struct {
+ section, key, want string
+ }{
+ {"sr.ht", "network-key", "[sr.ht] network-key"},
+ {"webhooks", "private-key", "[webhooks] private-key"},
+ {"sr.ht", "owner-name", "[sr.ht] owner-name"},
+ {"sr.ht", "owner-email", "[sr.ht] owner-email"},
+ {serviceName, "origin", "[spec.sr.ht] origin"},
+ {serviceName, "repos", "[spec.sr.ht] repos"},
+ {serviceName, "cache", "[spec.sr.ht] cache"},
+ {serviceName, "connection-string", "[spec.sr.ht] connection-string"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.section+"/"+tt.key, func(t *testing.T) {
+ _, err := validateConfig(without(completeConfig(), tt.section, tt.key))
+ if err == nil {
+ t.Fatalf("validateConfig accepted a config with no %s", tt.want)
+ }
+ if !strings.Contains(err.Error(), tt.want) {
+ t.Errorf("the error does not name %s:\n%v", tt.want, err)
+ }
+ })
+ }
+}
+
+// TestValidateConfigReportsEveryGapAtOnce is the whole point of doing this at
+// startup: an operator fixes the config in one pass instead of discovering
+// each gap on a separate restart.
+func TestValidateConfigReportsEveryGapAtOnce(t *testing.T) {
+ conf := ini.File{}
+ _, err := validateConfig(conf)
+ if err == nil {
+ t.Fatal("validateConfig accepted an empty config")
+ }
+ for _, want := range []string{
+ "[sr.ht] network-key",
+ "[webhooks] private-key",
+ "[sr.ht] owner-name",
+ "[sr.ht] owner-email",
+ "[spec.sr.ht] origin",
+ "[spec.sr.ht] repos",
+ "[spec.sr.ht] cache",
+ "[spec.sr.ht] connection-string",
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("the error does not name %s:\n%v", want, err)
+ }
+ }
+}
+
+// TestValidateConfigRejectsAnUnusableValue: a present but wrong key is as
+// fatal as a missing one, and service.Config.Validate is what says so.
+func TestValidateConfigRejectsAnUnusableValue(t *testing.T) {
+ conf := completeConfig()
+ conf[serviceName]["repos"] = "var/lib/spec" // relative
+ _, err := validateConfig(conf)
+ if err == nil {
+ t.Fatal("validateConfig accepted a relative repos root")
+ }
+ if !strings.Contains(err.Error(), "absolute path") {
+ t.Errorf("the error does not explain the problem:\n%v", err)
+ }
+}
+
+// TestBlankIsMissing: an empty value satisfies "the key is present" and
+// nothing else.
+func TestBlankIsMissing(t *testing.T) {
+ conf := completeConfig()
+ conf["sr.ht"]["network-key"] = " "
+ _, err := validateConfig(conf)
+ if err == nil {
+ t.Fatal("validateConfig accepted a blank network-key")
+ }
+ if !strings.Contains(err.Error(), "[sr.ht] network-key") {
+ t.Errorf("the error does not name the blank key:\n%v", err)
+ }
+}
+
+func TestParseLevel(t *testing.T) {
+ tests := map[string]slog.Level{
+ "": slog.LevelInfo,
+ "info": slog.LevelInfo,
+ "nonsense": slog.LevelInfo,
+ "debug": slog.LevelDebug,
+ " DEBUG ": slog.LevelDebug,
+ "warn": slog.LevelWarn,
+ "warning": slog.LevelWarn,
+ "error": slog.LevelError,
+ }
+ for in, want := range tests {
+ if got := parseLevel(in); got != want {
+ t.Errorf("parseLevel(%q) = %v want %v", in, got, want)
+ }
+ }
+}
+
+// TestHookDispatchIsCheckedBeforeAnythingElse guards the one ordering in main
+// that matters: a hook must not read a config file or open Postgres, because
+// it runs once per ref of every push and its only job is to reach the daemon.
+func TestHookDispatchIsCheckedBeforeAnythingElse(t *testing.T) {
+ if _, _, ok := hooks.ModeFromArgs([]string{"/var/lib/spec/~bigbes/rfcs/hooks/update"}); !ok {
+ t.Error("main would treat an update hook invocation as a daemon start")
+ }
+ if _, _, ok := hooks.ModeFromArgs([]string{"/usr/local/bin/specsrht", "-b", "localhost:5091"}); ok {
+ t.Error("main would treat a daemon start as a hook invocation")
+ }
+}
A hooks/client.go => hooks/client.go +90 -0
@@ 0,0 1,90 @@
+package hooks
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "time"
+)
+
+const (
+ // DefaultDialTimeout bounds finding the daemon. A unix socket connect is
+ // immediate when the daemon is listening, so this is generous enough to
+ // survive a loaded box and short enough that a push against a dead daemon
+ // fails while the human is still looking at the terminal.
+ DefaultDialTimeout = 5 * time.Second
+
+ // DefaultTimeout bounds one call end to end. Validation walks the pushed
+ // tree and asks Postgres about every document id in it, so it is not
+ // instantaneous; but a hook that hangs holds the push open indefinitely,
+ // and a rejected push is the better failure.
+ DefaultTimeout = 60 * time.Second
+)
+
+// Client is a hook's end of the RPC: one connection, one request, one
+// response, no reuse. Pushes are rare and serial, so a pool would be state to
+// get wrong for no gain.
+type Client struct {
+ // Socket is the daemon's unix socket.
+ Socket string
+
+ // DialTimeout and Timeout default to the constants above when zero.
+ DialTimeout time.Duration
+ Timeout time.Duration
+}
+
+// Call sends one request and returns the daemon's answer.
+//
+// Every failure here — cannot connect, cannot write, cannot parse — is
+// returned as an error, and every caller on the rejecting path turns it into a
+// rejection. That is the fail-closed rule: the daemon not answering is never
+// permission to proceed.
+func (c Client) Call(ctx context.Context, req Request) (Response, error) {
+ if c.Socket == "" {
+ return Response{}, fmt.Errorf("hooks: no daemon socket to call")
+ }
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = DefaultTimeout
+ }
+ dialTimeout := c.DialTimeout
+ if dialTimeout <= 0 {
+ dialTimeout = DefaultDialTimeout
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ dialer := net.Dialer{Timeout: dialTimeout}
+ conn, err := dialer.DialContext(ctx, "unix", c.Socket)
+ if err != nil {
+ return Response{}, fmt.Errorf("hooks: reach the spec.sr.ht daemon on %s: %w", c.Socket, err)
+ }
+ defer conn.Close()
+
+ if deadline, ok := ctx.Deadline(); ok {
+ if err := conn.SetDeadline(deadline); err != nil {
+ return Response{}, fmt.Errorf("hooks: set deadline on %s: %w", c.Socket, err)
+ }
+ }
+
+ if err := WriteRequest(conn, req); err != nil {
+ return Response{}, fmt.Errorf("hooks: send %s to %s: %w", req.Method, c.Socket, err)
+ }
+ // Half-close so a daemon that reads to EOF is not left waiting. The
+ // response still arrives on the read half.
+ if uc, ok := conn.(*net.UnixConn); ok {
+ if err := uc.CloseWrite(); err != nil {
+ return Response{}, fmt.Errorf("hooks: finish sending %s to %s: %w", req.Method, c.Socket, err)
+ }
+ }
+
+ resp, err := ReadResponse(conn)
+ if err != nil {
+ return Response{}, fmt.Errorf("hooks: read the answer to %s from %s: %w", req.Method, c.Socket, err)
+ }
+ if err := resp.Validate(); err != nil {
+ return Response{}, fmt.Errorf("hooks: %w", err)
+ }
+ return resp, nil
+}
A hooks/doc.go => hooks/doc.go +117 -0
@@ 0,0 1,117 @@
+// Package hooks is spec.sr.ht's receive path: the git hooks a space's bare
+// repository runs on every push, and the daemon-side RPC endpoint they call.
+//
+// # Why the hooks are service code
+//
+// bleve is single-writer and the running specsrht daemon holds the index open,
+// so a separate hook process cannot reindex; and validation needs the same
+// schema and policy logic the API uses, which must not be duplicated in a
+// shell script that will drift from it. Both hooks are therefore thin shims
+// that RPC into the daemon over a unix socket, calling [service.Service]
+// entry points. Nothing in this package shells out to git.
+//
+// Daemon availability is consequently part of the push path, so the behaviour
+// is stated rather than discovered: **fail closed**. If the daemon is
+// unreachable, unresponsive, or answers anything this package cannot parse,
+// the push is rejected. A rejected push is recoverable in one command; a
+// silently unvalidated, unindexed one is a corruption discovered weeks later.
+//
+// # Which hook does what
+//
+// Three hooks are installed, not two, and the reason is a property of git that
+// the design did not account for. Both facts below were verified against
+// git 2.55 rather than inferred from the documentation:
+//
+// 1. GIT_PUSH_OPTION_COUNT / GIT_PUSH_OPTION_<n> are set for `pre-receive`
+// and `post-receive` only. githooks(5) documents them under exactly those
+// two hooks, and `update` observably runs without them. So `update` alone
+// cannot see --push-option=skip-validation.
+// 2. During `pre-receive` the pushed objects are still in receive-pack's
+// quarantine directory (GIT_QUARANTINE_PATH), reachable only through the
+// hook process's own GIT_OBJECT_DIRECTORY. A separate process that opens
+// the bare repository — the daemon — cannot read them. git migrates the
+// quarantine into the real object store immediately after `pre-receive`
+// succeeds and before the first `update` runs, so `update` is the earliest
+// hook at which the daemon can read what is being pushed.
+//
+// Neither hook can do the whole job, so the work is split along that seam:
+//
+// - pre-receive — the only place push options exist. It forwards them, with
+// the full list of proposed ref updates, to the daemon, which records them
+// for the duration of this push. It validates nothing (it cannot: the
+// objects are invisible to the daemon) but it is still a rejecting hook,
+// because a daemon that cannot be reached must stop the push here rather
+// than one hook later.
+// - update — per ref, before the ref moves, and the only hook whose refusal
+// is scoped to a single ref. This is where the refs rule and frontmatter /
+// document-id validation run, via [service.Service.ValidatePush].
+// - post-receive — after the fact, cannot reject. It tells the daemon the
+// push landed so the space can be reindexed and its index rev stamp moved.
+//
+// The two phases of one push are correlated by (repository, receive-pack pid):
+// every hook of a single push is a direct child of one receive-pack process,
+// so os.Getppid() is stable across them, and the daemon additionally requires
+// the ref update `update` presents to appear in the list `pre-receive` sent.
+// An `update` call with no recorded pre-receive phase is refused rather than
+// assumed unskippable — that combination means either a broken install or a
+// daemon restart mid-push, and both deserve a message instead of a guess.
+//
+// # Push options
+//
+// The one recognised option is `skip-validation`. It waives frontmatter and
+// document-id validation and nothing else; the refs rule is never skippable.
+// An unrecognised push option is a rejection, not a no-op: with exactly one
+// option in the vocabulary, a silently ignored `--push-option=skip-validaton`
+// would present as an inexplicable rejection of a push the human believed they
+// had waived.
+//
+// Push options only reach a hook when the repository advertises them, so
+// [Install] sets receive.advertisePushOptions on every repository it installs
+// into. Without it `git push --push-option=...` fails client-side with
+// "the receiving end does not support push options".
+//
+// # Identity
+//
+// The hook does not decide who is pushing; it forwards a credential and the
+// daemon resolves it:
+//
+// SPECSRHT_PRINCIPAL "owner" or "agent" (required)
+// SPECSRHT_AGENT_TOKEN agent secret (required when kind=agent)
+// SPECSRHT_AGENT agent identity string (provenance, optional)
+// SPECSRHT_AGENT_SESSION agent session id (provenance, optional)
+//
+// An agent's token is validated against the database on every push, so an
+// agent credential asserts nothing by itself. `owner` is different: it is an
+// assertion, trusted because sshd already authenticated the SSH key and the
+// forced-command wrapper — which owns this environment — is what sets it.
+// That wrapper is therefore part of the trust boundary: sshd must not
+// AcceptEnv any SPECSRHT_* name, or a client could name its own principal.
+// A missing or unrecognised SPECSRHT_PRINCIPAL is refused; there is no
+// anonymous write path and nothing to default to.
+//
+// # Addressing
+//
+// A hook knows which repository it is running in (git chdirs into it and sets
+// GIT_DIR) and nothing else, so both the socket and the space are derived from
+// that one fact:
+//
+// - the socket is <repos>/.specsrht/hook.sock, where <repos> is the parent of
+// the parent of the repository directory — the layout gitx.DiskPath
+// defines. It lives under `repos` rather than `cache` because `cache` is
+// documented as safe to delete at any time, and deleting the socket would
+// take the push path down until the daemon restarted. SPECSRHT_HOOK_SOCKET
+// overrides it for a non-standard deployment or a test.
+// - the space is not sent as a name at all. The hook sends the absolute
+// repository path and the daemon resolves it against its own configured
+// repos root, refusing anything that is not exactly
+// <repos>/~<owner>/<name>. A hook can therefore only ever address a
+// repository the daemon already owns.
+//
+// # Installation
+//
+// [Install] writes each hook as a symlink to the specsrht binary, which
+// dispatches on the name it was invoked as ([ModeFromArgs]). There is no shell
+// stub, no generated script, and nothing to regenerate when the socket path or
+// the protocol changes; refreshing a repository's hooks after an upgrade is
+// running [Install] again, which the daemon does for every space at startup.
+package hooks
A hooks/e2e_test.go => hooks/e2e_test.go +331 -0
@@ 0,0 1,331 @@
+package hooks
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/go-git/go-git/v5/plumbing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// TestMain makes this test binary double as the specsrht binary.
+//
+// Install writes each hook as a symlink to whatever binary it is given and the
+// hook dispatches on the name git invoked it as, so pointing those symlinks at
+// the test binary is enough to make a real `git push` run this package's code
+// through git's real receive path. That is the production dispatch mechanism,
+// unchanged and unmocked — not a stand-in for it.
+func TestMain(m *testing.M) {
+ if _, _, isHook := ModeFromArgs(os.Args); isHook {
+ os.Exit(Run(Runtime{Args: os.Args}))
+ }
+ os.Exit(m.Run())
+}
+
+// git runs the real git binary. Shelling out is confined to tests: nothing in
+// this package's non-test code executes a subprocess.
+func gitMust(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ out, err := runGit(t, dir, nil, args...)
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return out
+}
+
+func runGit(t *testing.T, dir string, env map[string]string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_CONFIG_SYSTEM=/dev/null",
+ "GIT_AUTHOR_NAME=bigbes", "GIT_AUTHOR_EMAIL=bigbes@example.invalid",
+ "GIT_COMMITTER_NAME=bigbes", "GIT_COMMITTER_EMAIL=bigbes@example.invalid",
+ )
+ for k, v := range env {
+ cmd.Env = append(cmd.Env, k+"="+v)
+ }
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+// pushEnv is what the forced-command wrapper exports for a push by the owner.
+// The local transport spawns receive-pack as a child of the client, so the
+// client's environment is what the hooks see.
+func pushEnv() map[string]string { return map[string]string{EnvPrincipal: string(PrincipalOwner)} }
+
+// realish is a Backend that answers with the actual rules where they need no
+// database: gitx.CheckRefUpdate for the refs rule, and core.ParseDocument plus
+// the space's schema for frontmatter.
+//
+// The document-id registry needs Postgres and is therefore not covered here;
+// service/'s own tests cover it. What this proves is the receive path itself —
+// that git runs our hooks, that they reach the daemon, that a refusal comes
+// back as a readable message and a non-zero exit, and that skip-validation
+// reaches the right half of the decision.
+func realish(t *testing.T, root string) func(context.Context, service.PushRequest) error {
+ t.Helper()
+ return func(ctx context.Context, req service.PushRequest) error {
+ repo, err := gitx.Open(root, req.Space)
+ if err != nil {
+ return fmt.Errorf("open %s: %w", req.Space, err)
+ }
+
+ old, new := plumbing.NewHash(req.Old), plumbing.NewHash(req.New)
+ fastForward := old.IsZero()
+ if !old.IsZero() && !new.IsZero() {
+ ff, err := repo.IsAncestor(ctx, old, new)
+ if err != nil {
+ return fmt.Errorf("ancestry of %s..%s: %w", req.Old, req.New, err)
+ }
+ fastForward = ff
+ }
+ kind := gitx.PrincipalHuman
+ if req.Principal.IsAgent() {
+ kind = gitx.PrincipalAgent
+ }
+ if err := gitx.CheckRefUpdate(kind, repo.ApprovedBranch(), gitx.RefUpdate{
+ Ref: req.Ref, Old: old, New: new, FastForward: fastForward,
+ }); err != nil {
+ return &service.PushRejection{
+ Space: req.Space, Ref: req.Ref, Skippable: false,
+ Problems: []service.PushProblem{{Kind: service.ProblemRefsRule, Detail: err.Error()}},
+ }
+ }
+
+ if req.SkipValidation || new.IsZero() {
+ return nil
+ }
+
+ docs, err := repo.ListDocuments(ctx, req.New)
+ if err != nil {
+ return fmt.Errorf("list documents at %s: %w", req.New, err)
+ }
+ schema := core.DefaultSchema()
+ var problems []service.PushProblem
+ for _, d := range docs {
+ fm, _, err := core.ParseDocument(d.Data)
+ if err == nil {
+ err = schema.ValidateFrontmatter(fm)
+ }
+ if err != nil {
+ problems = append(problems, service.PushProblem{
+ Kind: service.ProblemFrontmatter, Path: d.Path, Detail: err.Error(),
+ })
+ }
+ }
+ if len(problems) > 0 {
+ return &service.PushRejection{
+ Space: req.Space, Ref: req.Ref, Problems: problems, Skippable: true,
+ }
+ }
+ return nil
+ }
+}
+
+// e2e is a repos root with one hooked space and a working clone.
+type e2e struct {
+ root string
+ repo string
+ work string
+ server *Server
+ back *fakeBackend
+ landed *[]core.SpaceRef
+}
+
+func newE2E(t *testing.T) *e2e {
+ t.Helper()
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skipf("git is not on PATH: %v", err)
+ }
+ binary, err := os.Executable()
+ if err != nil {
+ t.Fatalf("os.Executable: %v", err)
+ }
+
+ root := shortTempDir(t)
+ repo := bareRepo(t, root, testSpace)
+ if err := InstallSpace(root, testSpace, InstallOptions{Binary: binary}); err != nil {
+ t.Fatalf("InstallSpace: %v", err)
+ }
+
+ back := newFakeBackend(t, root, nil)
+ back.validate = realish(t, root)
+ srv, landed := startServer(t, back)
+
+ work := filepath.Join(shortTempDir(t), "work")
+ gitMust(t, "", "init", "--quiet", "--initial-branch=main", work)
+
+ return &e2e{root: root, repo: repo, work: work, server: srv, back: back, landed: landed}
+}
+
+// write stages a file in the working clone.
+func (e *e2e) write(t *testing.T, path, body string) {
+ t.Helper()
+ full := filepath.Join(e.work, path)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ gitMust(t, e.work, "add", path)
+}
+
+func (e *e2e) commit(t *testing.T, message string) {
+ t.Helper()
+ gitMust(t, e.work, "commit", "--quiet", "-m", message)
+}
+
+// push runs a real `git push` and returns its combined output plus whether it
+// succeeded.
+func (e *e2e) push(t *testing.T, args ...string) (string, bool) {
+ t.Helper()
+ out, err := runGit(t, e.work, pushEnv(), append([]string{"push", e.repo}, args...)...)
+ return out, err == nil
+}
+
+const goodDoc = `---
+id: SPEC-0001
+title: Storage
+status: draft
+---
+
+# Storage
+
+One storage tier.
+`
+
+const badDoc = `---
+title: No identity
+status: draft
+---
+
+This document has no id.
+`
+
+// TestEndToEndPush drives the whole receive path with a real git push against
+// a real bare repository with our hooks installed.
+func TestEndToEndPush(t *testing.T) {
+ e := newE2E(t)
+
+ t.Run("a valid push is accepted", func(t *testing.T) {
+ e.write(t, "specs/0001-storage.md", goodDoc)
+ e.commit(t, "add the storage spec")
+ out, ok := e.push(t, "main:refs/heads/main")
+ if !ok {
+ t.Fatalf("a valid push was rejected:\n%s", out)
+ }
+ if got := len(*e.landed); got == 0 {
+ t.Errorf("post-receive did not notify the daemon (landed=%d)", got)
+ }
+ if head := strings.TrimSpace(gitMust(t, e.repo, "rev-parse", "refs/heads/main")); head == "" {
+ t.Error("main was not updated")
+ }
+ })
+
+ t.Run("bad frontmatter is rejected with a message naming the document", func(t *testing.T) {
+ e.write(t, "specs/0002-broken.md", badDoc)
+ e.commit(t, "add a document with no id")
+ out, ok := e.push(t, "main:refs/heads/main")
+ if ok {
+ t.Fatalf("a push with malformed frontmatter was accepted:\n%s", out)
+ }
+ mentions(t, "the rejection", out,
+ "spec.sr.ht rejected this push",
+ "specs/0002-broken.md",
+ "--push-option=skip-validation",
+ )
+ if head := strings.TrimSpace(gitMust(t, e.repo, "log", "--oneline", "-1", "refs/heads/main")); strings.Contains(head, "no id") {
+ t.Error("the rejected commit landed anyway")
+ }
+ })
+
+ t.Run("skip-validation lets the same push through", func(t *testing.T) {
+ out, ok := e.push(t, "--push-option=skip-validation", "main:refs/heads/main")
+ if !ok {
+ t.Fatalf("skip-validation did not waive frontmatter validation:\n%s", out)
+ }
+ })
+
+ t.Run("an unknown push option is refused rather than ignored", func(t *testing.T) {
+ e.write(t, "specs/0003-note.md", goodDoc)
+ e.commit(t, "another document")
+ out, ok := e.push(t, "--push-option=skip-validaton", "main:refs/heads/main")
+ if ok {
+ t.Fatalf("a mistyped push option was ignored:\n%s", out)
+ }
+ mentions(t, "the rejection", out, "skip-validaton", "is not a push option")
+ })
+
+ t.Run("a force-push to the approved branch is refused", func(t *testing.T) {
+ // Rewrite history so the update is not a fast-forward.
+ gitMust(t, e.work, "reset", "--quiet", "--hard", "HEAD~2")
+ e.write(t, "specs/0009-rewritten.md", goodDoc)
+ e.commit(t, "rewrite history")
+
+ out, ok := e.push(t, "--force", "main:refs/heads/main")
+ if ok {
+ t.Fatalf("a force-push to the approved branch was accepted:\n%s", out)
+ }
+ mentions(t, "the rejection", out,
+ "spec.sr.ht rejected this push",
+ "The refs rule cannot be bypassed",
+ )
+ })
+
+ t.Run("the refs rule is not skippable", func(t *testing.T) {
+ out, ok := e.push(t, "--push-option=skip-validation", "--force", "main:refs/heads/main")
+ if ok {
+ t.Fatalf("skip-validation waived the refs rule:\n%s", out)
+ }
+ mentions(t, "the rejection", out, "The refs rule cannot be bypassed")
+ })
+
+ t.Run("a proposal branch may be force-updated", func(t *testing.T) {
+ out, ok := e.push(t, "--force", "main:refs/heads/proposals/1")
+ if !ok {
+ t.Fatalf("a proposal branch was refused:\n%s", out)
+ }
+ })
+}
+
+// TestEndToEndFailsClosed is the fail-closed rule under a real push: with the
+// daemon gone, the push is refused rather than silently accepted unvalidated.
+func TestEndToEndFailsClosed(t *testing.T) {
+ e := newE2E(t)
+ e.write(t, "specs/0001-storage.md", goodDoc)
+ e.commit(t, "add the storage spec")
+
+ // Take the daemon down exactly as a crash would: stop serving and unlink
+ // the socket.
+ if err := e.server.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ out, ok := e.push(t, "main:refs/heads/main")
+ if ok {
+ t.Fatalf("a push was accepted with no daemon to validate it:\n%s", out)
+ }
+ mentions(t, "the fail-closed message", out,
+ "could not validate this push",
+ SocketPath(e.root),
+ "Start the spec.sr.ht daemon and push again",
+ )
+ // The escape hatch must not be advertised here: it waives frontmatter
+ // checks, not the daemon that performs them.
+ mentionsNot(t, "the fail-closed message", out, "Re-push with --push-option=skip-validation")
+
+ if out, err := runGit(t, e.repo, nil, "rev-parse", "--verify", "refs/heads/main"); err == nil {
+ t.Errorf("the ref moved despite the refusal: %s", out)
+ }
+}
A hooks/env.go => hooks/env.go +211 -0
@@ 0,0 1,211 @@
+package hooks
+
+import (
+ "fmt"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// The environment the forced-command wrapper sets. See the package
+// documentation for the trust boundary: `owner` is an assertion the wrapper
+// makes after sshd authenticated an SSH key, so sshd must not AcceptEnv any of
+// these names.
+const (
+ // EnvPrincipal is "owner" or "agent". There is no third value and no
+ // default: a push with no principal has nobody to authorize it.
+ EnvPrincipal = "SPECSRHT_PRINCIPAL"
+
+ // EnvAgentToken is the agent's secret, required when EnvPrincipal is
+ // "agent". It is forwarded to the daemon, which validates it; it is never
+ // logged and never appears in a message sent back to the client.
+ EnvAgentToken = "SPECSRHT_AGENT_TOKEN"
+
+ // EnvAgent and EnvAgentSession are the agent's provenance fields.
+ EnvAgent = "SPECSRHT_AGENT"
+ EnvAgentSession = "SPECSRHT_AGENT_SESSION"
+
+ // EnvSocket overrides the derived socket path. It exists for a deployment
+ // whose repos root is not where the daemon's socket lives, and for tests.
+ EnvSocket = "SPECSRHT_HOOK_SOCKET"
+)
+
+// The environment git sets. Push options reach `pre-receive` and
+// `post-receive` only; `update` runs without them, which is why pre-receive
+// exists at all in this package.
+const (
+ envPushOptionCount = "GIT_PUSH_OPTION_COUNT"
+ envPushOptionPrefix = "GIT_PUSH_OPTION_"
+ envGitDir = "GIT_DIR"
+)
+
+// OptionSkipValidation waives frontmatter and document-id validation. It does
+// not and cannot waive the refs rule: the escape hatch exists so a hook bug or
+// a bad schema can never lock the owner out of their own repository, not so an
+// agent can reach the approved branch.
+const OptionSkipValidation = "skip-validation"
+
+// KnownOptions is every push option this service understands. An option
+// outside this set is a rejection; see the package documentation.
+func KnownOptions() []string { return []string{OptionSkipValidation} }
+
+// socketDir is the directory under the repos root that holds the hook socket,
+// and hookSocketName the socket in it. The repos root is the right home for it
+// because it is the one configured directory that is not documented as safe to
+// delete — `cache` is — and because every repository whose hooks need to find
+// it is already underneath it.
+const (
+ socketDir = ".specsrht"
+ hookSocketName = "hook.sock"
+)
+
+// Lookup is os.LookupEnv, injectable so the environment protocol can be tested
+// without mutating the process environment.
+type Lookup func(string) (string, bool)
+
+// SocketPath is the daemon's hook socket for a given repos root.
+//
+// ".specsrht" cannot collide with a space: every real entry under the repos
+// root is "~<owner>", and core.ValidateOwner does not admit a name starting
+// with a dot.
+func SocketPath(reposRoot string) string {
+ return filepath.Join(reposRoot, socketDir, hookSocketName)
+}
+
+// SocketForRepo is the socket a hook running in repoDir should call, derived
+// from the layout gitx.DiskPath defines: <repos>/~<owner>/<name>.
+func SocketForRepo(repoDir string) string {
+ return SocketPath(filepath.Dir(filepath.Dir(filepath.Clean(repoDir))))
+}
+
+// ResolveSocket picks the socket a hook will call: the explicit override if
+// one is set, otherwise the path derived from the repository's location.
+func ResolveSocket(env Lookup, repoDir string) string {
+ if v, ok := env(EnvSocket); ok {
+ if v = strings.TrimSpace(v); v != "" {
+ return v
+ }
+ }
+ return SocketForRepo(repoDir)
+}
+
+// PushOptions reads the push options git passed to this hook.
+//
+// A nil result means the push-options phase was not negotiated — the client did
+// not ask for it, or the repository does not advertise it — which is distinct
+// from a client that asked and sent none. Neither carries an option, so no
+// caller has to tell them apart, but an inconsistent environment does not
+// silently become either: a count that will not parse, or a count larger than
+// the variables actually present, is an error and the push is rejected.
+func PushOptions(env Lookup) ([]string, error) {
+ raw, ok := env(envPushOptionCount)
+ if !ok {
+ return nil, nil
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(raw))
+ if err != nil {
+ return nil, fmt.Errorf("%s=%q is not a number: %w", envPushOptionCount, raw, err)
+ }
+ if n < 0 {
+ return nil, fmt.Errorf("%s=%d is negative", envPushOptionCount, n)
+ }
+ opts := make([]string, 0, n)
+ for i := range n {
+ name := envPushOptionPrefix + strconv.Itoa(i)
+ v, ok := env(name)
+ if !ok {
+ return nil, fmt.Errorf("%s=%d but %s is not set", envPushOptionCount, n, name)
+ }
+ opts = append(opts, v)
+ }
+ return opts, nil
+}
+
+// SkipValidation reports whether the push asked to waive content validation.
+// The comparison is exact: an option is a token git passes through verbatim,
+// and accepting "skip-validation=yes" or "Skip-Validation" would be inventing
+// a grammar the daemon and the documentation do not share.
+func SkipValidation(opts []string) bool {
+ for _, o := range opts {
+ if o == OptionSkipValidation {
+ return true
+ }
+ }
+ return false
+}
+
+// UnknownOptions returns the push options this service does not understand.
+func UnknownOptions(opts []string) []string {
+ var unknown []string
+ for _, o := range opts {
+ if o != OptionSkipValidation {
+ unknown = append(unknown, o)
+ }
+ }
+ return unknown
+}
+
+// CredentialFromEnv reads who the forced-command wrapper says is pushing.
+//
+// An absent or unrecognised principal is an error, not an anonymous
+// credential: there is no unauthenticated write path, so the only thing an
+// anonymous request could produce is a refusal one round trip later with a
+// worse message. The wording names the wrapper, because that is what is
+// actually broken when this fires.
+func CredentialFromEnv(env Lookup) (Credential, error) {
+ raw, _ := env(EnvPrincipal)
+ switch kind := PrincipalKind(strings.TrimSpace(raw)); kind {
+ case PrincipalOwner:
+ return Credential{Kind: PrincipalOwner}, nil
+ case PrincipalAgent:
+ token, _ := env(EnvAgentToken)
+ if strings.TrimSpace(token) == "" {
+ return Credential{}, fmt.Errorf("%s=%s but %s is empty; an agent must present its token",
+ EnvPrincipal, PrincipalAgent, EnvAgentToken)
+ }
+ agent, _ := env(EnvAgent)
+ session, _ := env(EnvAgentSession)
+ return Credential{
+ Kind: PrincipalAgent,
+ Token: strings.TrimSpace(token),
+ Agent: strings.TrimSpace(agent),
+ Session: strings.TrimSpace(session),
+ }, nil
+ case "":
+ return Credential{}, fmt.Errorf("%s is not set; the forced-command wrapper must export it as %q or %q",
+ EnvPrincipal, PrincipalOwner, PrincipalAgent)
+ default:
+ return Credential{}, fmt.Errorf("%s=%q is not a principal; want %q or %q",
+ EnvPrincipal, kind, PrincipalOwner, PrincipalAgent)
+ }
+}
+
+// RepoDir resolves the bare repository the hook is running in.
+//
+// git chdirs into the repository and sets GIT_DIR (observably to "."), so
+// either source alone would do; both are used because GIT_DIR is the one git
+// documents and the working directory is the one that is always right.
+// Symlinks are resolved here so the path the daemon receives can be compared
+// against its own repos root by string equality.
+func RepoDir(env Lookup, getwd func() (string, error), evalSymlinks func(string) (string, error)) (string, error) {
+ wd, err := getwd()
+ if err != nil {
+ return "", fmt.Errorf("locate the repository: %w", err)
+ }
+ dir := wd
+ if v, ok := env(envGitDir); ok && strings.TrimSpace(v) != "" {
+ dir = strings.TrimSpace(v)
+ if !filepath.IsAbs(dir) {
+ dir = filepath.Join(wd, dir)
+ }
+ }
+ resolved, err := evalSymlinks(dir)
+ if err != nil {
+ return "", fmt.Errorf("resolve the repository path %q: %w", dir, err)
+ }
+ abs, err := filepath.Abs(resolved)
+ if err != nil {
+ return "", fmt.Errorf("resolve the repository path %q: %w", resolved, err)
+ }
+ return abs, nil
+}
A hooks/env_test.go => hooks/env_test.go +252 -0
@@ 0,0 1,252 @@
+package hooks
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+// TestPushOptions covers the environment protocol git actually uses. The
+// count/index pair is only set for pre-receive and post-receive — `update`
+// observably runs without it — which is why pre-receive exists in this
+// package at all.
+func TestPushOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ env map[string]string
+ want []string
+ err string
+ }{
+ {
+ name: "no push-options phase",
+ env: map[string]string{},
+ want: nil,
+ },
+ {
+ name: "negotiated but empty",
+ env: map[string]string{"GIT_PUSH_OPTION_COUNT": "0"},
+ want: []string{},
+ },
+ {
+ name: "one option",
+ env: map[string]string{
+ "GIT_PUSH_OPTION_COUNT": "1",
+ "GIT_PUSH_OPTION_0": "skip-validation",
+ },
+ want: []string{"skip-validation"},
+ },
+ {
+ name: "several options keep their order",
+ env: map[string]string{
+ "GIT_PUSH_OPTION_COUNT": "3",
+ "GIT_PUSH_OPTION_0": "a",
+ "GIT_PUSH_OPTION_1": "skip-validation",
+ "GIT_PUSH_OPTION_2": "c",
+ },
+ want: []string{"a", "skip-validation", "c"},
+ },
+ {
+ name: "an unparseable count is refused, not treated as none",
+ env: map[string]string{"GIT_PUSH_OPTION_COUNT": "many"},
+ err: "is not a number",
+ },
+ {
+ name: "a negative count is refused",
+ env: map[string]string{"GIT_PUSH_OPTION_COUNT": "-1"},
+ err: "is negative",
+ },
+ {
+ name: "a count larger than the variables present is refused",
+ env: map[string]string{
+ "GIT_PUSH_OPTION_COUNT": "2",
+ "GIT_PUSH_OPTION_0": "skip-validation",
+ },
+ err: "GIT_PUSH_OPTION_1 is not set",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := PushOptions(envOf(tt.env))
+ if tt.err != "" {
+ if err == nil {
+ t.Fatalf("PushOptions accepted %v", tt.env)
+ }
+ mentions(t, "the error", err.Error(), tt.err)
+ return
+ }
+ if err != nil {
+ t.Fatalf("PushOptions: %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("got %#v want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSkipValidationIsMatchedExactly(t *testing.T) {
+ tests := []struct {
+ opts []string
+ want bool
+ }{
+ {nil, false},
+ {[]string{}, false},
+ {[]string{"skip-validation"}, true},
+ {[]string{"other", "skip-validation"}, true},
+ {[]string{"skip-validaton"}, false},
+ {[]string{"Skip-Validation"}, false},
+ {[]string{"skip-validation=yes"}, false},
+ {[]string{" skip-validation"}, false},
+ }
+ for _, tt := range tests {
+ if got := SkipValidation(tt.opts); got != tt.want {
+ t.Errorf("SkipValidation(%q) = %v, want %v", tt.opts, got, tt.want)
+ }
+ }
+}
+
+func TestUnknownOptions(t *testing.T) {
+ got := UnknownOptions([]string{"skip-validation", "skip-validaton", "reindex"})
+ want := []string{"skip-validaton", "reindex"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("got %q want %q", got, want)
+ }
+ if got := UnknownOptions([]string{"skip-validation"}); got != nil {
+ t.Errorf("got %q, want none", got)
+ }
+}
+
+func TestCredentialFromEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ env map[string]string
+ want Credential
+ err string
+ }{
+ {
+ name: "owner",
+ env: map[string]string{EnvPrincipal: "owner"},
+ want: Credential{Kind: PrincipalOwner},
+ },
+ {
+ name: "agent with provenance",
+ env: map[string]string{
+ EnvPrincipal: "agent",
+ EnvAgentToken: " s3cret ",
+ EnvAgent: "claude-code/spec-writer",
+ EnvAgentSession: "6f1c",
+ },
+ want: Credential{
+ Kind: PrincipalAgent, Token: "s3cret",
+ Agent: "claude-code/spec-writer", Session: "6f1c",
+ },
+ },
+ {
+ name: "no principal is refused rather than defaulted",
+ env: map[string]string{},
+ err: "is not set",
+ },
+ {
+ name: "a blank principal is refused",
+ env: map[string]string{EnvPrincipal: " "},
+ err: "is not set",
+ },
+ {
+ name: "an unknown principal is refused",
+ env: map[string]string{EnvPrincipal: "anonymous"},
+ err: "is not a principal",
+ },
+ {
+ name: "an agent with no token is refused",
+ env: map[string]string{EnvPrincipal: "agent"},
+ err: "must present its token",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := CredentialFromEnv(envOf(tt.env))
+ if tt.err != "" {
+ if err == nil {
+ t.Fatalf("CredentialFromEnv accepted %v", tt.env)
+ }
+ mentions(t, "the error", err.Error(), tt.err)
+ return
+ }
+ if err != nil {
+ t.Fatalf("CredentialFromEnv: %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("got %+v want %+v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSocketDerivation(t *testing.T) {
+ root := "/var/lib/spec"
+ if got, want := SocketPath(root), "/var/lib/spec/.specsrht/hook.sock"; got != want {
+ t.Errorf("SocketPath = %q want %q", got, want)
+ }
+ // A hook knows only its repository; the socket must fall out of that.
+ repo := filepath.Join(root, "~bigbes", "rfcs")
+ if got, want := SocketForRepo(repo), SocketPath(root); got != want {
+ t.Errorf("SocketForRepo(%q) = %q want %q", repo, got, want)
+ }
+ if got, want := ResolveSocket(envOf(nil), repo), SocketPath(root); got != want {
+ t.Errorf("ResolveSocket without an override = %q want %q", got, want)
+ }
+ override := "/run/specsrht/hook.sock"
+ if got := ResolveSocket(envOf(map[string]string{EnvSocket: override}), repo); got != override {
+ t.Errorf("ResolveSocket ignored the override: %q", got)
+ }
+ // A blank override is not an override; it must not resolve to "".
+ if got, want := ResolveSocket(envOf(map[string]string{EnvSocket: " "}), repo), SocketPath(root); got != want {
+ t.Errorf("a blank override produced %q want %q", got, want)
+ }
+}
+
+// TestRepoDir mirrors what git actually does: it chdirs into the repository
+// and sets GIT_DIR to ".".
+func TestRepoDir(t *testing.T) {
+ dir := shortTempDir(t)
+ getwd := func() (string, error) { return dir, nil }
+
+ got, err := RepoDir(envOf(map[string]string{envGitDir: "."}), getwd, filepath.EvalSymlinks)
+ if err != nil {
+ t.Fatalf("RepoDir: %v", err)
+ }
+ if got != dir {
+ t.Errorf("GIT_DIR=. resolved to %q want %q", got, dir)
+ }
+
+ got, err = RepoDir(envOf(nil), getwd, filepath.EvalSymlinks)
+ if err != nil {
+ t.Fatalf("RepoDir with no GIT_DIR: %v", err)
+ }
+ if got != dir {
+ t.Errorf("no GIT_DIR resolved to %q want %q", got, dir)
+ }
+
+ abs := filepath.Join(dir, "sub")
+ if err := os.Mkdir(abs, 0o755); err != nil {
+ t.Fatalf("Mkdir: %v", err)
+ }
+ got, err = RepoDir(envOf(map[string]string{envGitDir: abs}), getwd, filepath.EvalSymlinks)
+ if err != nil {
+ t.Fatalf("RepoDir with an absolute GIT_DIR: %v", err)
+ }
+ if got != abs {
+ t.Errorf("absolute GIT_DIR resolved to %q want %q", got, abs)
+ }
+
+ if _, err := RepoDir(envOf(nil), func() (string, error) { return "", errors.New("no cwd") },
+ filepath.EvalSymlinks); err == nil {
+ t.Error("RepoDir invented a repository when the working directory was unreadable")
+ }
+ if _, err := RepoDir(envOf(map[string]string{envGitDir: filepath.Join(dir, "gone")}),
+ getwd, filepath.EvalSymlinks); err == nil {
+ t.Error("RepoDir accepted a repository that does not exist")
+ }
+}
A hooks/fixture_test.go => hooks/fixture_test.go +237 -0
@@ 0,0 1,237 @@
+package hooks
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/db"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+const testOwner = "bigbes"
+
+var testSpace = core.SpaceRef{Owner: testOwner, Name: "rfcs"}
+
+// fakeLookup is service.AgentTokenLookup over a map, so the agent path can be
+// exercised without Postgres. *service.TokenStore is what turns db/'s
+// ErrNotFound into authn's ErrUnknownToken, and that mapping is part of what
+// the server relies on, so the real adapter is used over this fake rather than
+// a fake authn.TokenStore.
+type fakeLookup struct {
+ byHash map[string]*db.AgentToken
+ err error
+}
+
+func (f *fakeLookup) AgentTokenByHash(_ context.Context, hash []byte) (*db.AgentToken, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+ tok, ok := f.byHash[string(hash)]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ return tok, nil
+}
+
+// fakeBackend is a Backend that answers from a script instead of a database.
+// It exists so the receive path — hooks, socket, protocol, message rendering —
+// can be driven by a real `git push` on a machine with no Postgres.
+type fakeBackend struct {
+ root string
+ resolver *authn.Resolver
+ tokens *service.TokenStore
+
+ // validate is the scripted answer, and the recorder: every request reaches
+ // it. A nil validate accepts everything.
+ validate func(context.Context, service.PushRequest) error
+
+ seen []service.PushRequest
+}
+
+func newFakeBackend(t *testing.T, root string, tokens map[string]*db.AgentToken) *fakeBackend {
+ t.Helper()
+ lookup := &fakeLookup{byHash: map[string]*db.AgentToken{}}
+ for secret, row := range tokens {
+ lookup.byHash[string(authn.HashToken(secret))] = row
+ }
+ store := service.NewTokenStore(lookup)
+ resolver, err := authn.NewResolver(testOwner, store)
+ if err != nil {
+ t.Fatalf("NewResolver: %v", err)
+ }
+ return &fakeBackend{root: root, resolver: resolver, tokens: store}
+}
+
+func (b *fakeBackend) ReposRoot() string { return b.root }
+func (b *fakeBackend) Resolver() *authn.Resolver { return b.resolver }
+func (b *fakeBackend) TokenStore() *service.TokenStore { return b.tokens }
+
+func (b *fakeBackend) ValidatePush(ctx context.Context, req service.PushRequest) error {
+ b.seen = append(b.seen, req)
+ if b.validate == nil {
+ return nil
+ }
+ return b.validate(ctx, req)
+}
+
+// compile-time proof that the real service satisfies the same interface the
+// tests fake. If service/ ever changes one of these signatures, this fails
+// here rather than in the daemon.
+var _ Backend = (*service.Service)(nil)
+
+// discardLogger keeps test output readable; the server logs every request.
+func discardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
+}
+
+// shortTempDir makes a directory outside t.TempDir().
+//
+// A unix socket path is capped at ~104 bytes on darwin and 108 on Linux, and
+// t.TempDir() embeds the test's name, which is long enough to blow that cap.
+func shortTempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "sh")
+ if err != nil {
+ t.Fatalf("MkdirTemp: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := os.RemoveAll(dir); err != nil {
+ t.Errorf("clean up %s: %v", dir, err)
+ }
+ })
+ resolved, err := filepath.EvalSymlinks(dir)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%s): %v", dir, err)
+ }
+ return resolved
+}
+
+// startServer runs a Server over a fake backend and returns it plus the
+// notifications it received.
+func startServer(t *testing.T, backend *fakeBackend, opts ...func(*Options)) (*Server, *[]core.SpaceRef) {
+ t.Helper()
+
+ var landed []core.SpaceRef
+ o := Options{
+ Backend: backend,
+ Socket: SocketPath(backend.root),
+ Log: discardLogger(),
+ OnPush: func(_ context.Context, space core.SpaceRef, _ []RefUpdate) error {
+ landed = append(landed, space)
+ return nil
+ },
+ Timeout: 20 * time.Second,
+ }
+ for _, fn := range opts {
+ fn(&o)
+ }
+
+ srv, err := NewServer(o)
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+ if err := srv.Listen(); err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- srv.Serve(ctx) }()
+ t.Cleanup(func() {
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Errorf("Serve: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Error("Serve did not stop")
+ }
+ if err := srv.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+ })
+ return srv, &landed
+}
+
+// envOf turns a map into a Lookup.
+func envOf(kv map[string]string) Lookup {
+ return func(k string) (string, bool) {
+ v, ok := kv[k]
+ return v, ok
+ }
+}
+
+// ownerEnv is the environment the forced-command wrapper sets for a push by
+// the instance owner.
+func ownerEnv(extra map[string]string) map[string]string {
+ env := map[string]string{EnvPrincipal: string(PrincipalOwner)}
+ for k, v := range extra {
+ env[k] = v
+ }
+ return env
+}
+
+// rejection builds the structured refusal service.ValidatePush returns, so a
+// test can assert on the text a human actually reads.
+func rejection(ref string, skippable bool, problems ...service.PushProblem) *service.PushRejection {
+ return &service.PushRejection{
+ Space: testSpace,
+ Ref: ref,
+ Problems: problems,
+ Skippable: skippable,
+ }
+}
+
+// bareRepo makes a bare repository at <root>/~owner/name using the git binary,
+// so the layout under test is the real one.
+func bareRepo(t *testing.T, root string, ref core.SpaceRef) string {
+ t.Helper()
+ dir := filepath.Join(root, "~"+ref.Owner, ref.Name)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("MkdirAll %s: %v", dir, err)
+ }
+ gitMust(t, "", "init", "--quiet", "--bare", "--initial-branch=main", dir)
+ return dir
+}
+
+// errFakeStore is a store outage: not a bad credential, and must never be
+// reported as one.
+var errFakeStore = errors.New("fake store is down")
+
+// mentions asserts that text a human will read says a particular thing. The
+// rejection text is the entire user interface of a failed push, so several
+// tests assert on its content rather than only on the exit code.
+func mentions(t *testing.T, what, haystack string, needles ...string) {
+ t.Helper()
+ for _, needle := range needles {
+ if !strings.Contains(haystack, needle) {
+ t.Errorf("%s does not mention %q:\n%s", what, needle, haystack)
+ }
+ }
+}
+
+func mentionsNot(t *testing.T, what, haystack string, needles ...string) {
+ t.Helper()
+ for _, needle := range needles {
+ if strings.Contains(haystack, needle) {
+ t.Errorf("%s must not mention %q:\n%s", what, needle, haystack)
+ }
+ }
+}
+
+// Small wrappers so tests read as prose rather than as os calls.
+func mkdirAll(dir string) error { return os.MkdirAll(dir, 0o755) }
+func writeFile(path, body string) error { return os.WriteFile(path, []byte(body), 0o644) }
+func dialUnix(socket string) (net.Conn, error) {
+ return net.DialTimeout("unix", socket, 5*time.Second)
+}
A hooks/hook.go => hooks/hook.go +385 -0
@@ 0,0 1,385 @@
+package hooks
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Mode is which git hook this process is acting as.
+type Mode string
+
+const (
+ ModePreReceive Mode = "pre-receive"
+ ModeUpdate Mode = "update"
+ ModePostReceive Mode = "post-receive"
+)
+
+// Modes is every hook this package installs, in the order git runs them.
+func Modes() []Mode { return []Mode{ModePreReceive, ModeUpdate, ModePostReceive} }
+
+// Exit codes. git treats any non-zero exit from pre-receive or update as a
+// refusal; it ignores post-receive's entirely.
+const (
+ exitOK = 0
+ exitRefused = 1
+ exitUsage = 2
+)
+
+// ModeFromArgs decides whether this process is a git hook, and which one.
+//
+// Two spellings are accepted, and they are the same mechanism seen from two
+// sides. Install writes each hook as a symlink to the specsrht binary, so git
+// execs it with argv[0] naming the hook — that is the production path, and it
+// is why there is no generated shell stub to keep in sync with this package.
+// The explicit "specsrht hook <name>" form exists so an operator can run the
+// same code by hand against a repository, which is otherwise impossible to do
+// without creating a symlink.
+//
+// It returns the mode, the hook's own arguments, and whether this is a hook
+// invocation at all.
+func ModeFromArgs(args []string) (Mode, []string, bool) {
+ if len(args) == 0 {
+ return "", nil, false
+ }
+ if m, ok := modeNamed(filepath.Base(args[0])); ok {
+ return m, args[1:], true
+ }
+ if len(args) >= 3 && args[1] == "hook" {
+ if m, ok := modeNamed(args[2]); ok {
+ return m, args[3:], true
+ }
+ }
+ return "", nil, false
+}
+
+func modeNamed(s string) (Mode, bool) {
+ for _, m := range Modes() {
+ if string(m) == s {
+ return m, true
+ }
+ }
+ return "", false
+}
+
+// Runtime is everything Run touches outside its own package, so a test can
+// drive a hook without a real push, a real environment or a real repository.
+type Runtime struct {
+ // Args is the full argument vector, argv[0] included.
+ Args []string
+
+ // Env, Stdin, Stderr, Getwd and EvalSymlinks default to the process's own
+ // when nil.
+ Env Lookup
+ Stdin io.Reader
+ Stderr io.Writer
+ Getwd func() (string, error)
+ EvalSymlinks func(string) (string, error)
+
+ // PushID correlates the hooks of one push. It defaults to the pid of the
+ // receive-pack process this hook is a child of.
+ PushID func() string
+
+ // DialTimeout and Timeout override the client's defaults.
+ DialTimeout time.Duration
+ Timeout time.Duration
+}
+
+func (rt Runtime) withDefaults() Runtime {
+ if rt.Env == nil {
+ rt.Env = os.LookupEnv
+ }
+ if rt.Stdin == nil {
+ rt.Stdin = os.Stdin
+ }
+ if rt.Stderr == nil {
+ rt.Stderr = os.Stderr
+ }
+ if rt.Getwd == nil {
+ rt.Getwd = os.Getwd
+ }
+ if rt.EvalSymlinks == nil {
+ rt.EvalSymlinks = filepath.EvalSymlinks
+ }
+ if rt.PushID == nil {
+ rt.PushID = func() string { return strconv.Itoa(os.Getppid()) }
+ }
+ return rt
+}
+
+// Run executes this process as a git hook and returns the exit code.
+//
+// It never returns an error: a hook communicates by writing to standard error
+// — which git forwards to the pushing client, prefixed with "remote: " — and
+// by its exit status. Everything it has to say is therefore said here, in full
+// sentences, because this text is the entire user interface of a failed push.
+func Run(rt Runtime) int {
+ rt = rt.withDefaults()
+
+ mode, args, ok := ModeFromArgs(rt.Args)
+ if !ok {
+ fmt.Fprintf(rt.Stderr, "not a git hook invocation; run this binary as %s, %s or %s\n",
+ ModePreReceive, ModeUpdate, ModePostReceive)
+ return exitUsage
+ }
+
+ ctx := context.Background()
+ switch mode {
+ case ModePreReceive:
+ return runPreReceive(ctx, rt)
+ case ModeUpdate:
+ return runUpdate(ctx, rt, args)
+ case ModePostReceive:
+ return runPostReceive(ctx, rt)
+ default:
+ fmt.Fprintf(rt.Stderr, "unhandled hook %q\n", mode)
+ return exitUsage
+ }
+}
+
+// context assembles the facts every call needs: which repository, which
+// socket, which credential, which push.
+type hookContext struct {
+ repo string
+ socket string
+ cred Credential
+ push string
+ client Client
+}
+
+func (rt Runtime) hookContext() (hookContext, error) {
+ repo, err := RepoDir(rt.Env, rt.Getwd, rt.EvalSymlinks)
+ if err != nil {
+ return hookContext{}, err
+ }
+ cred, err := CredentialFromEnv(rt.Env)
+ if err != nil {
+ return hookContext{}, err
+ }
+ socket := ResolveSocket(rt.Env, repo)
+ return hookContext{
+ repo: repo,
+ socket: socket,
+ cred: cred,
+ push: rt.PushID(),
+ client: Client{Socket: socket, DialTimeout: rt.DialTimeout, Timeout: rt.Timeout},
+ }, nil
+}
+
+// runPreReceive forwards the push options — the only hook git gives them to —
+// and the full list of proposed updates, so the update calls that follow know
+// whether validation was waived. It rejects nothing on content; it rejects on
+// not being able to talk to the daemon, because failing here costs the pusher
+// one message instead of one per ref.
+func runPreReceive(ctx context.Context, rt Runtime) int {
+ updates, err := readRefUpdates(rt.Stdin)
+ if err != nil {
+ writeMisconfigured(rt.Stderr, err)
+ return exitRefused
+ }
+ if len(updates) == 0 {
+ // git does not run pre-receive with an empty command list; if it ever
+ // does, there is nothing to record and nothing to refuse.
+ return exitOK
+ }
+
+ opts, err := PushOptions(rt.Env)
+ if err != nil {
+ writeMisconfigured(rt.Stderr, err)
+ return exitRefused
+ }
+
+ hc, err := rt.hookContext()
+ if err != nil {
+ writeMisconfigured(rt.Stderr, err)
+ return exitRefused
+ }
+
+ resp, err := hc.client.Call(ctx, Request{
+ Version: ProtocolVersion,
+ Method: MethodPushOptions,
+ Repo: hc.repo,
+ Push: hc.push,
+ Credential: hc.cred,
+ Options: opts,
+ Updates: updates,
+ })
+ if err != nil {
+ writeUnreachable(rt.Stderr, hc, "", err)
+ return exitRefused
+ }
+ return report(rt.Stderr, hc, "", resp)
+}
+
+// runUpdate is the rejecting hook: the refs rule and content validation for
+// one ref, before that ref moves. It is the earliest point at which the daemon
+// can read what is being pushed — during pre-receive the objects are still in
+// receive-pack's quarantine and invisible to any other process.
+func runUpdate(ctx context.Context, rt Runtime, args []string) int {
+ if len(args) != 3 {
+ writeMisconfigured(rt.Stderr, fmt.Errorf(
+ "the update hook takes <ref> <old> <new>, got %d argument(s)", len(args)))
+ return exitRefused
+ }
+ update := RefUpdate{Ref: args[0], Old: args[1], New: args[2]}
+
+ hc, err := rt.hookContext()
+ if err != nil {
+ writeMisconfigured(rt.Stderr, err)
+ return exitRefused
+ }
+
+ resp, err := hc.client.Call(ctx, Request{
+ Version: ProtocolVersion,
+ Method: MethodValidateRef,
+ Repo: hc.repo,
+ Push: hc.push,
+ Credential: hc.cred,
+ Updates: []RefUpdate{update},
+ })
+ if err != nil {
+ writeUnreachable(rt.Stderr, hc, update.Ref, err)
+ return exitRefused
+ }
+ return report(rt.Stderr, hc, update.Ref, resp)
+}
+
+// runPostReceive tells the daemon the push landed. It cannot reject anything —
+// git has already moved the refs and ignores this exit status — so a failure
+// is a warning, and the reconciler is the backstop that repairs the index rev
+// stamp this call was supposed to advance.
+func runPostReceive(ctx context.Context, rt Runtime) int {
+ updates, err := readRefUpdates(rt.Stdin)
+ if err != nil {
+ writeNotNotified(rt.Stderr, err)
+ return exitOK
+ }
+ if len(updates) == 0 {
+ return exitOK
+ }
+
+ hc, err := rt.hookContext()
+ if err != nil {
+ writeNotNotified(rt.Stderr, err)
+ return exitOK
+ }
+
+ resp, err := hc.client.Call(ctx, Request{
+ Version: ProtocolVersion,
+ Method: MethodPushed,
+ Repo: hc.repo,
+ Push: hc.push,
+ Credential: hc.cred,
+ Updates: updates,
+ })
+ switch {
+ case err != nil:
+ writeNotNotified(rt.Stderr, err)
+ case resp.Rejected:
+ writeNotNotified(rt.Stderr, fmt.Errorf("the daemon refused the notification: %s",
+ strings.TrimSpace(resp.Message)))
+ case resp.Error != "":
+ writeNotNotified(rt.Stderr, fmt.Errorf("the daemon failed to record the push: %s", resp.Error))
+ }
+ return exitOK
+}
+
+// report turns a well-formed response into an exit code and, when it is not an
+// acceptance, the text the pusher reads.
+func report(w io.Writer, hc hookContext, ref string, resp Response) int {
+ switch {
+ case resp.OK:
+ return exitOK
+ case resp.Rejected:
+ // The daemon composed this for a terminal; print it as written rather
+ // than wrapping it in a second frame.
+ msg := strings.TrimRight(resp.Message, "\n")
+ fmt.Fprintf(w, "%s\n", msg)
+ return exitRefused
+ default:
+ writeUnreachable(w, hc, ref, fmt.Errorf("%s", resp.Error))
+ return exitRefused
+ }
+}
+
+// readRefUpdates parses the "<old> <new> <ref>" lines git feeds pre-receive and
+// post-receive on standard input.
+//
+// Standard input is read to the end even on a malformed line: git writes the
+// whole list before waiting, and a hook that exits early enough leaves it
+// writing into a closed pipe.
+func readRefUpdates(r io.Reader) ([]RefUpdate, error) {
+ var (
+ updates []RefUpdate
+ bad error
+ )
+ sc := bufio.NewScanner(io.LimitReader(r, maxMessageBytes))
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) != 3 {
+ if bad == nil {
+ bad = fmt.Errorf("git sent %q, which is not \"<old> <new> <ref>\"", line)
+ }
+ continue
+ }
+ updates = append(updates, RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]})
+ }
+ if err := sc.Err(); err != nil {
+ return nil, fmt.Errorf("read the ref list git sent on standard input: %w", err)
+ }
+ if bad != nil {
+ return nil, bad
+ }
+ return updates, nil
+}
+
+// writeMisconfigured reports a problem with how the hook itself is wired: no
+// principal in the environment, an unreadable repository path, a nonsensical
+// argument vector. None of it is the pusher's fault and none of it is fixed by
+// changing what they pushed, so the message says so.
+func writeMisconfigured(w io.Writer, err error) {
+ fmt.Fprintf(w, "spec.sr.ht refused this push: its receive hook is misconfigured.\n\n")
+ fmt.Fprintf(w, " %v\n\n", err)
+ fmt.Fprintf(w, "This is a server-side wiring problem, not a problem with what you\n")
+ fmt.Fprintf(w, "pushed. Nothing was written.\n")
+}
+
+// writeUnreachable is the fail-closed message: the daemon could not be reached,
+// or could not answer, so the push is refused unvalidated rather than accepted
+// unvalidated.
+func writeUnreachable(w io.Writer, hc hookContext, ref string, cause error) {
+ fmt.Fprintf(w, "spec.sr.ht could not validate this push, so it was refused.\n\n")
+ fmt.Fprintf(w, " repository: %s\n", hc.repo)
+ if ref != "" {
+ fmt.Fprintf(w, " ref: %s\n", ref)
+ }
+ fmt.Fprintf(w, " daemon: %s\n\n", hc.socket)
+ fmt.Fprintf(w, " %v\n\n", cause)
+ fmt.Fprintf(w, "Nothing was written; the ref still points where it did.\n\n")
+ fmt.Fprintf(w, "spec.sr.ht refuses a push it cannot validate rather than accepting it\n")
+ fmt.Fprintf(w, "unchecked: a refused push costs you one command, an unvalidated one\n")
+ fmt.Fprintf(w, "corrupts the document registry silently and surfaces weeks later.\n")
+ fmt.Fprintf(w, "--push-option=%s does not help here — it waives frontmatter\n", OptionSkipValidation)
+ fmt.Fprintf(w, "and document-id checks, not the daemon that performs them.\n\n")
+ fmt.Fprintf(w, "Start the spec.sr.ht daemon and push again.\n")
+}
+
+// writeNotNotified is post-receive's only failure mode. The refs are already
+// updated and cannot be taken back, so this warns and names the backstop.
+func writeNotNotified(w io.Writer, cause error) {
+ fmt.Fprintf(w, "warning: spec.sr.ht was not told that this push landed.\n")
+ fmt.Fprintf(w, "warning: %v\n", cause)
+ fmt.Fprintf(w, "warning: the refs are updated and your content is safe, but this space's\n")
+ fmt.Fprintf(w, "warning: search index is now stale. The reconciler repairs it at the next\n")
+ fmt.Fprintf(w, "warning: daemon start and on its periodic pass.\n")
+}
A hooks/hook_test.go => hooks/hook_test.go +278 -0
@@ 0,0 1,278 @@
+package hooks
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+func TestModeFromArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ mode Mode
+ rest []string
+ ok bool
+ }{
+ {
+ name: "git execs the symlink by name",
+ args: []string{"hooks/update", "refs/heads/main", zeroOID, oneOID},
+ mode: ModeUpdate,
+ rest: []string{"refs/heads/main", zeroOID, oneOID},
+ ok: true,
+ },
+ {
+ name: "an absolute hook path still dispatches",
+ args: []string{"/var/lib/spec/~bigbes/rfcs/hooks/pre-receive"},
+ mode: ModePreReceive,
+ rest: []string{},
+ ok: true,
+ },
+ {
+ name: "post-receive",
+ args: []string{"./post-receive"},
+ mode: ModePostReceive,
+ rest: []string{},
+ ok: true,
+ },
+ {
+ name: "the explicit form an operator can type",
+ args: []string{"/usr/local/bin/specsrht", "hook", "update", "refs/heads/main", zeroOID, oneOID},
+ mode: ModeUpdate,
+ rest: []string{"refs/heads/main", zeroOID, oneOID},
+ ok: true,
+ },
+ {
+ name: "the daemon is not a hook",
+ args: []string{"/usr/local/bin/specsrht", "-b", "localhost:5091"},
+ ok: false,
+ },
+ {
+ name: "an unknown hook name is not ours",
+ args: []string{"hooks/post-update"},
+ ok: false,
+ },
+ {
+ name: "hook with no name",
+ args: []string{"specsrht", "hook"},
+ ok: false,
+ },
+ {
+ name: "no argv at all",
+ args: nil,
+ ok: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mode, rest, ok := ModeFromArgs(tt.args)
+ if ok != tt.ok {
+ t.Fatalf("ok = %v want %v", ok, tt.ok)
+ }
+ if !ok {
+ return
+ }
+ if mode != tt.mode {
+ t.Errorf("mode = %q want %q", mode, tt.mode)
+ }
+ if strings.Join(rest, " ") != strings.Join(tt.rest, " ") {
+ t.Errorf("args = %q want %q", rest, tt.rest)
+ }
+ })
+ }
+}
+
+// hookRuntime drives a hook against a fixture's repository without a real push.
+func (f *serverFixture) hookRuntime(t *testing.T, args []string, stdin string, env map[string]string) (Runtime, *bytes.Buffer) {
+ t.Helper()
+ stderr := &bytes.Buffer{}
+ full := map[string]string{EnvPrincipal: string(PrincipalOwner), envGitDir: "."}
+ for k, v := range env {
+ full[k] = v
+ }
+ return Runtime{
+ Args: args,
+ Env: envOf(full),
+ Stdin: strings.NewReader(stdin),
+ Stderr: stderr,
+ Getwd: func() (string, error) { return f.repo, nil },
+ EvalSymlinks: filepath.EvalSymlinks,
+ PushID: func() string { return "4711" },
+ DialTimeout: 2 * time.Second,
+ Timeout: 10 * time.Second,
+ }, stderr
+}
+
+func refLine(u RefUpdate) string { return u.Old + " " + u.New + " " + u.Ref + "\n" }
+
+// TestUpdateHookAcceptsAValidRef walks the two hooks of the rejecting path in
+// the order git runs them.
+func TestUpdateHookAcceptsAValidRef(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ rt, stderr := f.hookRuntime(t, []string{"hooks/pre-receive"}, refLine(mainUpdate), nil)
+ if code := Run(rt); code != 0 {
+ t.Fatalf("pre-receive exited %d:\n%s", code, stderr)
+ }
+
+ rt, stderr = f.hookRuntime(t,
+ []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", nil)
+ if code := Run(rt); code != 0 {
+ t.Fatalf("update exited %d:\n%s", code, stderr)
+ }
+ if stderr.Len() != 0 {
+ t.Errorf("an accepted push said something to the client:\n%s", stderr)
+ }
+}
+
+// TestUpdateHookPrintsTheRejection: this text is the whole user interface of a
+// failed push, so the hook must print what the daemon wrote and exit non-zero.
+func TestUpdateHookPrintsTheRejection(t *testing.T) {
+ f := newServerFixture(t, nil)
+ want := rejection("refs/heads/main", true, service.PushProblem{
+ Kind: service.ProblemFrontmatter,
+ Path: "specs/0002-broken.md",
+ Detail: "frontmatter is missing the required key `id`",
+ })
+ f.back.validate = func(_ context.Context, _ service.PushRequest) error { return want }
+
+ rt, _ := f.hookRuntime(t, []string{"hooks/pre-receive"}, refLine(mainUpdate), nil)
+ if code := Run(rt); code != 0 {
+ t.Fatalf("pre-receive exited %d", code)
+ }
+
+ rt, stderr := f.hookRuntime(t,
+ []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", nil)
+ if code := Run(rt); code == 0 {
+ t.Fatal("update accepted a rejected ref")
+ }
+ mentions(t, "the rejection", stderr.String(),
+ "specs/0002-broken.md",
+ "missing the required key",
+ "--push-option=skip-validation",
+ )
+}
+
+// TestHooksFailClosed is the rule the whole design rests on: with no daemon
+// answering, a push is refused rather than accepted unvalidated.
+func TestHooksFailClosed(t *testing.T) {
+ f := newServerFixture(t, nil)
+ dead := filepath.Join(f.root, "gone", "hook.sock")
+
+ for _, tt := range []struct {
+ name string
+ args []string
+ stdin string
+ }{
+ {"pre-receive", []string{"hooks/pre-receive"}, refLine(mainUpdate)},
+ {"update", []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, ""},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ rt, stderr := f.hookRuntime(t, tt.args, tt.stdin, map[string]string{EnvSocket: dead})
+ if code := Run(rt); code == 0 {
+ t.Fatalf("%s accepted a push with no daemon to validate it", tt.name)
+ }
+ out := stderr.String()
+ mentions(t, "the fail-closed message", out,
+ "could not validate this push",
+ dead,
+ "Start the spec.sr.ht daemon",
+ )
+ // Suggesting the escape hatch here would be a lie: it waives
+ // frontmatter checks, not the daemon that performs them.
+ mentionsNot(t, "the fail-closed message", out, "Re-push with --push-option")
+ })
+ }
+}
+
+// TestPostReceiveCannotReject: git ignores its exit status, so pretending
+// otherwise would only produce noise. It warns and names the backstop.
+func TestPostReceiveCannotReject(t *testing.T) {
+ f := newServerFixture(t, nil)
+ dead := filepath.Join(f.root, "gone", "hook.sock")
+
+ rt, stderr := f.hookRuntime(t, []string{"hooks/post-receive"}, refLine(mainUpdate),
+ map[string]string{EnvSocket: dead})
+ if code := Run(rt); code != 0 {
+ t.Fatalf("post-receive exited %d; it cannot reject anything", code)
+ }
+ mentions(t, "the warning", stderr.String(),
+ "warning:", "was not told that this push landed", "reconciler")
+}
+
+// TestHookRefusesAMisconfiguredEnvironment: no principal means nobody
+// authorized the push, and there is nothing to default to.
+func TestHookRefusesAMisconfiguredEnvironment(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ rt, stderr := f.hookRuntime(t,
+ []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "",
+ map[string]string{EnvPrincipal: ""})
+ if code := Run(rt); code == 0 {
+ t.Fatal("update accepted a push with no principal in its environment")
+ }
+ mentions(t, "the message", stderr.String(),
+ "receive hook is misconfigured", EnvPrincipal, "server-side wiring problem")
+ if len(f.back.seen) != 0 {
+ t.Error("an unauthorized push reached the backend")
+ }
+}
+
+func TestUpdateHookNeedsThreeArguments(t *testing.T) {
+ f := newServerFixture(t, nil)
+ rt, stderr := f.hookRuntime(t, []string{"hooks/update", "refs/heads/main"}, "", nil)
+ if code := Run(rt); code == 0 {
+ t.Fatal("update ran with the wrong number of arguments")
+ }
+ mentions(t, "the message", stderr.String(), "<ref> <old> <new>")
+}
+
+// TestPreReceiveForwardsPushOptions: the update hook never sees them, so
+// whether skip-validation works at all depends on this handoff.
+func TestPreReceiveForwardsPushOptions(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ rt, stderr := f.hookRuntime(t, []string{"hooks/pre-receive"}, refLine(mainUpdate),
+ map[string]string{
+ "GIT_PUSH_OPTION_COUNT": "1",
+ "GIT_PUSH_OPTION_0": OptionSkipValidation,
+ })
+ if code := Run(rt); code != 0 {
+ t.Fatalf("pre-receive exited %d:\n%s", code, stderr)
+ }
+
+ rt, stderr = f.hookRuntime(t,
+ []string{"hooks/update", mainUpdate.Ref, mainUpdate.Old, mainUpdate.New}, "", nil)
+ if code := Run(rt); code != 0 {
+ t.Fatalf("update exited %d:\n%s", code, stderr)
+ }
+ if len(f.back.seen) != 1 {
+ t.Fatalf("the backend saw %d requests", len(f.back.seen))
+ }
+ if !f.back.seen[0].SkipValidation {
+ t.Error("the push option pre-receive read never reached ValidatePush")
+ }
+}
+
+func TestPreReceiveRefusesAMalformedRefList(t *testing.T) {
+ f := newServerFixture(t, nil)
+ rt, stderr := f.hookRuntime(t, []string{"hooks/pre-receive"}, "not a ref line\n", nil)
+ if code := Run(rt); code == 0 {
+ t.Fatal("pre-receive accepted a ref list it could not parse")
+ }
+ mentions(t, "the message", stderr.String(), "<old> <new> <ref>")
+}
+
+func TestRunRefusesToActAsAnythingButAHook(t *testing.T) {
+ stderr := &bytes.Buffer{}
+ code := Run(Runtime{Args: []string{"specsrht", "-b", "localhost:5091"}, Stderr: stderr})
+ if code == 0 {
+ t.Fatal("Run pretended a daemon invocation was a hook")
+ }
+ mentions(t, "the message", stderr.String(), "not a git hook invocation")
+}
A hooks/install.go => hooks/install.go +155 -0
@@ 0,0 1,155 @@
+package hooks
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/go-git/go-git/v5"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
+)
+
+const (
+ // hooksDirMode and the hook symlinks themselves are owned by the service
+ // user; every repository under the repos root is.
+ hooksDirMode = 0o755
+
+ // installSuffix names the temporary link Install renames into place, so a
+ // refresh is atomic and a push arriving mid-upgrade sees either the old
+ // hook or the new one, never a missing one.
+ installSuffix = ".specsrht-new"
+)
+
+// InstallOptions configures Install.
+type InstallOptions struct {
+ // Binary is the absolute path of the specsrht binary every hook symlinks
+ // to. The daemon passes os.Executable().
+ Binary string
+}
+
+// InstallSpace installs the receive hooks into a space's repository.
+//
+// The path comes from gitx.DiskPath rather than from a second copy of the
+// layout rule, for the same reason the server re-derives it there: one source
+// of truth for where a space lives.
+func InstallSpace(reposRoot string, ref core.SpaceRef, opts InstallOptions) error {
+ if err := core.ValidateOwner(ref.Owner); err != nil {
+ return fmt.Errorf("hooks: install into %s: %w", ref, err)
+ }
+ if err := core.ValidateSpaceName(ref.Name); err != nil {
+ return fmt.Errorf("hooks: install into %s: %w", ref, err)
+ }
+ return Install(gitx.DiskPath(reposRoot, ref), opts)
+}
+
+// Install writes — or refreshes — the receive hooks in a bare repository.
+//
+// Each hook is a symlink to the specsrht binary, which dispatches on the name
+// git invoked it as. Nothing is generated, so there is no stale script to find
+// after an upgrade: reinstalling is idempotent, and the daemon does it for
+// every space at startup.
+//
+// It also sets receive.advertisePushOptions. Without it git refuses
+// `--push-option=...` client-side with "the receiving end does not support
+// push options", and the documented escape hatch would not exist.
+//
+// Any file already occupying a hook's name is replaced. A repository under the
+// service's repos root has no hooks but ours, and silently leaving somebody
+// else's `update` in place would mean pushes that are never validated — the
+// exact failure this whole path exists to prevent.
+func Install(repoDir string, opts InstallOptions) error {
+ if opts.Binary == "" {
+ return errors.New("hooks: no binary to install hooks from")
+ }
+ if !filepath.IsAbs(opts.Binary) {
+ return fmt.Errorf("hooks: %q is not an absolute path; git runs a hook with the "+
+ "repository as its working directory, so a relative target would not resolve", opts.Binary)
+ }
+ info, err := os.Stat(opts.Binary)
+ if err != nil {
+ return fmt.Errorf("hooks: %s: %w", opts.Binary, err)
+ }
+ if info.IsDir() || info.Mode().Perm()&0o111 == 0 {
+ return fmt.Errorf("hooks: %s is not an executable file", opts.Binary)
+ }
+
+ if err := checkBareRepo(repoDir); err != nil {
+ return err
+ }
+
+ dir := filepath.Join(repoDir, "hooks")
+ if err := os.MkdirAll(dir, hooksDirMode); err != nil {
+ return fmt.Errorf("hooks: create %s: %w", dir, err)
+ }
+ for _, mode := range Modes() {
+ if err := linkHook(dir, string(mode), opts.Binary); err != nil {
+ return err
+ }
+ }
+ return advertisePushOptions(repoDir)
+}
+
+// checkBareRepo refuses to scatter symlinks into a directory that is not one of
+// our bare repositories. A wrong path here would install hooks nothing runs and
+// report success.
+func checkBareRepo(repoDir string) error {
+ if repoDir == "" {
+ return errors.New("hooks: no repository directory")
+ }
+ if !filepath.IsAbs(repoDir) {
+ return fmt.Errorf("hooks: repository path %q is not absolute", repoDir)
+ }
+ for _, name := range []string{"HEAD", "objects", "refs"} {
+ if _, err := os.Stat(filepath.Join(repoDir, name)); err != nil {
+ return fmt.Errorf("hooks: %s does not look like a bare repository (%s): %w",
+ repoDir, name, err)
+ }
+ }
+ return nil
+}
+
+// linkHook points one hook at the binary, atomically.
+func linkHook(dir, name, binary string) error {
+ final := filepath.Join(dir, name)
+ tmp := final + installSuffix
+
+ if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("hooks: clear %s: %w", tmp, err)
+ }
+ if err := os.Symlink(binary, tmp); err != nil {
+ return fmt.Errorf("hooks: link %s -> %s: %w", tmp, binary, err)
+ }
+ if err := os.Rename(tmp, final); err != nil {
+ _ = os.Remove(tmp)
+ return fmt.Errorf("hooks: install %s: %w", final, err)
+ }
+ return nil
+}
+
+// advertisePushOptions turns on receive.advertisePushOptions.
+//
+// go-git writes the config file rather than this package shelling out to `git
+// config`: library code must not depend on a git binary being on the daemon's
+// PATH, and this runs on the daemon's side of the socket.
+func advertisePushOptions(repoDir string) error {
+ repo, err := git.PlainOpen(repoDir)
+ if err != nil {
+ return fmt.Errorf("hooks: open %s: %w", repoDir, err)
+ }
+ cfg, err := repo.Config()
+ if err != nil {
+ return fmt.Errorf("hooks: read the config of %s: %w", repoDir, err)
+ }
+ section := cfg.Raw.Section("receive")
+ if section.Option("advertisePushOptions") == "true" {
+ return nil
+ }
+ section.SetOption("advertisePushOptions", "true")
+ if err := repo.SetConfig(cfg); err != nil {
+ return fmt.Errorf("hooks: enable push options on %s: %w", repoDir, err)
+ }
+ return nil
+}
A hooks/install_test.go => hooks/install_test.go +159 -0
@@ 0,0 1,159 @@
+package hooks
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// fakeBinary is something Install will accept as the specsrht binary: it only
+// has to exist and be executable.
+func fakeBinary(t *testing.T, dir string) string {
+ t.Helper()
+ path := filepath.Join(dir, "specsrht")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write a stand-in binary: %v", err)
+ }
+ return path
+}
+
+func TestInstall(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skipf("git is not on PATH: %v", err)
+ }
+ root := shortTempDir(t)
+ repo := bareRepo(t, root, testSpace)
+ binary := fakeBinary(t, root)
+
+ if err := InstallSpace(root, testSpace, InstallOptions{Binary: binary}); err != nil {
+ t.Fatalf("InstallSpace: %v", err)
+ }
+
+ for _, mode := range Modes() {
+ path := filepath.Join(repo, "hooks", string(mode))
+ target, err := os.Readlink(path)
+ if err != nil {
+ t.Fatalf("%s is not a symlink: %v", mode, err)
+ }
+ if target != binary {
+ t.Errorf("%s points at %q, want %q", mode, target, binary)
+ }
+ // git only runs a hook it can execute; the symlink must resolve.
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat %s: %v", mode, err)
+ }
+ if info.Mode().Perm()&0o111 == 0 {
+ t.Errorf("%s is not executable", mode)
+ }
+ }
+
+ // Without this, `git push --push-option=...` fails client-side with "the
+ // receiving end does not support push options" and the documented escape
+ // hatch would not exist at all.
+ out := gitMust(t, repo, "config", "--get", "receive.advertisePushOptions")
+ if strings.TrimSpace(out) != "true" {
+ t.Errorf("receive.advertisePushOptions = %q, want true", strings.TrimSpace(out))
+ }
+
+ // The repository must still be usable afterwards: writing the config back
+ // through go-git must not lose what git init put there.
+ if got := strings.TrimSpace(gitMust(t, repo, "config", "--get", "core.bare")); got != "true" {
+ t.Errorf("core.bare = %q after installing hooks; the repository is no longer bare", got)
+ }
+ if got := strings.TrimSpace(gitMust(t, repo, "symbolic-ref", "HEAD")); got != "refs/heads/main" {
+ t.Errorf("HEAD = %q after installing hooks", got)
+ }
+}
+
+// TestInstallIsIdempotent: refreshing is how an upgrade repairs every
+// repository, so it has to be safe to run over and over, and over whatever was
+// there before.
+func TestInstallIsIdempotent(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skipf("git is not on PATH: %v", err)
+ }
+ root := shortTempDir(t)
+ repo := bareRepo(t, root, testSpace)
+ binary := fakeBinary(t, root)
+
+ // Something else got there first: git's own sample hook, and a plain file.
+ stale := filepath.Join(repo, "hooks", "update")
+ if err := os.WriteFile(stale, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("write a stale hook: %v", err)
+ }
+
+ for i := range 3 {
+ if err := Install(repo, InstallOptions{Binary: binary}); err != nil {
+ t.Fatalf("Install (pass %d): %v", i, err)
+ }
+ }
+ target, err := os.Readlink(stale)
+ if err != nil {
+ t.Fatalf("the pre-existing hook was not replaced: %v", err)
+ }
+ if target != binary {
+ t.Errorf("update points at %q, want %q", target, binary)
+ }
+ if _, err := os.Stat(stale + installSuffix); !os.IsNotExist(err) {
+ t.Errorf("the temporary link was left behind: %v", err)
+ }
+}
+
+func TestInstallRefusesBadInput(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skipf("git is not on PATH: %v", err)
+ }
+ root := shortTempDir(t)
+ repo := bareRepo(t, root, testSpace)
+ binary := fakeBinary(t, root)
+
+ notExecutable := filepath.Join(root, "notexec")
+ if err := os.WriteFile(notExecutable, []byte("x"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ repo string
+ opts InstallOptions
+ want string
+ }{
+ {"no binary", repo, InstallOptions{}, "no binary"},
+ {"relative binary", repo, InstallOptions{Binary: "specsrht"}, "not an absolute path"},
+ {"missing binary", repo, InstallOptions{Binary: filepath.Join(root, "gone")}, "no such file"},
+ {"non-executable binary", repo, InstallOptions{Binary: notExecutable}, "not an executable file"},
+ {"non-executable binary is a directory", repo, InstallOptions{Binary: root}, "not an executable file"},
+ {"no repository", "", InstallOptions{Binary: binary}, "no repository directory"},
+ {"relative repository", "rfcs", InstallOptions{Binary: binary}, "not absolute"},
+ {"not a bare repository", root, InstallOptions{Binary: binary}, "does not look like a bare repository"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := Install(tt.repo, tt.opts)
+ if err == nil {
+ t.Fatalf("Install accepted %s", tt.name)
+ }
+ mentions(t, "the error", err.Error(), tt.want)
+ })
+ }
+}
+
+func TestInstallSpaceValidatesTheSpace(t *testing.T) {
+ root := shortTempDir(t)
+ binary := fakeBinary(t, root)
+ for _, ref := range []core.SpaceRef{
+ {Owner: "", Name: "rfcs"},
+ {Owner: "bigbes", Name: ""},
+ {Owner: "../etc", Name: "rfcs"},
+ {Owner: "bigbes", Name: "../../etc"},
+ } {
+ if err := InstallSpace(root, ref, InstallOptions{Binary: binary}); err == nil {
+ t.Errorf("InstallSpace accepted %+v", ref)
+ }
+ }
+}
A hooks/proto.go => hooks/proto.go +293 -0
@@ 0,0 1,293 @@
+package hooks
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+)
+
+// ProtocolVersion is the wire version. The hook and the daemon are the same
+// binary in every supported deployment, so a mismatch means a repository's
+// hook symlinks point at a different build than the running daemon — a
+// half-finished upgrade. It is refused loudly rather than negotiated: there is
+// no old version to be compatible with, and guessing at an unknown peer's
+// semantics on the write path is exactly the wrong trade.
+const ProtocolVersion = 1
+
+// maxMessageBytes caps one request or response. A push with thousands of refs
+// would exceed it and be rejected, which is the right answer — nothing in this
+// model pushes thousands of refs, and an unbounded read on a socket any local
+// process can connect to is a way to kill the daemon.
+const maxMessageBytes = 1 << 20
+
+// Method names one of the three calls the hooks make. Each corresponds to
+// exactly one git hook; see the package documentation for why the work splits
+// this way.
+type Method string
+
+const (
+ // MethodPushOptions is `pre-receive`: here are the push options and every
+ // ref this push proposes to update. The daemon records them for the
+ // `update` calls that follow. It validates nothing — during pre-receive
+ // the pushed objects are still in receive-pack's quarantine and are not
+ // readable by the daemon.
+ MethodPushOptions Method = "push-options"
+
+ // MethodValidateRef is `update`: may this one ref move, and is what it
+ // moves to valid? This is the call that rejects a push.
+ MethodValidateRef Method = "validate-ref"
+
+ // MethodPushed is `post-receive`: these refs moved. The daemon reindexes
+ // and advances the space's index rev stamp. Its answer cannot stop
+ // anything; git has already updated the refs.
+ MethodPushed Method = "pushed"
+)
+
+// PrincipalKind is who the forced-command wrapper says is pushing. It is
+// deliberately not gitx.PrincipalKind: this is a wire value whose spelling is
+// part of a compatibility contract, and the daemon maps it onto an
+// authn.Principal after resolving the credential rather than trusting it.
+type PrincipalKind string
+
+const (
+ // PrincipalOwner is the instance owner, authenticated by sshd against
+ // their SSH key before the forced command ran.
+ PrincipalOwner PrincipalKind = "owner"
+
+ // PrincipalAgent is an agent presenting a token, which the daemon
+ // validates against the database on every push.
+ PrincipalAgent PrincipalKind = "agent"
+)
+
+// Credential is the identity half of a request: what the hook's environment
+// claims, plus whatever secret backs the claim. Token is never logged.
+type Credential struct {
+ Kind PrincipalKind `json:"kind"`
+
+ // Token is the agent's secret, required for PrincipalAgent and empty
+ // otherwise. The daemon hashes and looks it up; the hook does not parse it.
+ Token string `json:"token,omitempty"`
+
+ // Agent and Session are the provenance fields an agent write must carry.
+ // They are forwarded unvalidated: the write plane is where they are
+ // demanded, and a push is not an agent write.
+ Agent string `json:"agent,omitempty"`
+ Session string `json:"session,omitempty"`
+}
+
+// RefUpdate is one proposed or completed ref move, exactly as git spells it on
+// the hook's command line or standard input. The object names stay hex strings
+// all the way to service.PushRequest, so an unparseable one is a rejection
+// rather than something that silently becomes the zero hash — which the refs
+// rule would read as a branch creation.
+type RefUpdate struct {
+ Ref string `json:"ref"`
+ Old string `json:"old"`
+ New string `json:"new"`
+}
+
+func (u RefUpdate) String() string {
+ return fmt.Sprintf("%s %s..%s", u.Ref, shortOID(u.Old), shortOID(u.New))
+}
+
+func shortOID(s string) string {
+ if len(s) > 8 {
+ return s[:8]
+ }
+ if s == "" {
+ return "-"
+ }
+ return s
+}
+
+// Request is one call from a hook to the daemon.
+type Request struct {
+ Version int `json:"version"`
+ Method Method `json:"method"`
+
+ // Repo is the absolute path of the bare repository the hook is running in,
+ // with symlinks resolved. The daemon turns it into a space by matching it
+ // against its own repos root, so a hook cannot name a repository the
+ // daemon does not own.
+ Repo string `json:"repo"`
+
+ // Push correlates the hooks of one push. It is the pid of the receive-pack
+ // process every hook of a push is a child of — stable across pre-receive,
+ // every update, and post-receive, and unique for as long as that process
+ // lives.
+ Push string `json:"push"`
+
+ Credential Credential `json:"credential"`
+
+ // Options carries the push options, MethodPushOptions only. A nil slice
+ // means the push-options phase was not negotiated at all, which is not the
+ // same as an empty one; neither carries skip-validation, so nothing
+ // downstream needs to tell them apart.
+ Options []string `json:"options,omitempty"`
+
+ // Updates is the ref updates this call is about: every ref of the push for
+ // MethodPushOptions and MethodPushed, exactly one for MethodValidateRef.
+ Updates []RefUpdate `json:"updates"`
+}
+
+// Validate reports whether a request is well formed, before anything acts on
+// it. Everything here is a bug in the caller rather than a policy question, so
+// the daemon answers these with Response.Error rather than a rejection.
+func (r Request) Validate() error {
+ if r.Version != ProtocolVersion {
+ return fmt.Errorf("unsupported protocol version %d (this daemon speaks %d); "+
+ "the repository's hooks and the running daemon are different builds",
+ r.Version, ProtocolVersion)
+ }
+ switch r.Method {
+ case MethodPushOptions, MethodValidateRef, MethodPushed:
+ default:
+ return fmt.Errorf("unknown method %q", r.Method)
+ }
+ if r.Repo == "" {
+ return errors.New("no repository path")
+ }
+ if !filepath.IsAbs(r.Repo) {
+ return fmt.Errorf("repository path %q is not absolute", r.Repo)
+ }
+ if r.Push == "" {
+ return errors.New("no push correlation id")
+ }
+ switch r.Credential.Kind {
+ case PrincipalOwner:
+ if r.Credential.Token != "" {
+ return errors.New("an owner credential must not carry a token")
+ }
+ case PrincipalAgent:
+ if r.Credential.Token == "" {
+ return errors.New("an agent credential must carry a token")
+ }
+ default:
+ return fmt.Errorf("unknown principal kind %q, want %q or %q",
+ r.Credential.Kind, PrincipalOwner, PrincipalAgent)
+ }
+ if len(r.Updates) == 0 {
+ return errors.New("no ref updates")
+ }
+ if r.Method == MethodValidateRef && len(r.Updates) != 1 {
+ return fmt.Errorf("%s carries %d ref updates, want exactly 1",
+ MethodValidateRef, len(r.Updates))
+ }
+ for i, u := range r.Updates {
+ if u.Ref == "" {
+ return fmt.Errorf("ref update %d has no ref name", i)
+ }
+ if u.Old == "" || u.New == "" {
+ return fmt.Errorf("ref update %d (%s) is missing an object name; "+
+ "git spells an absent one as forty zeroes, never as the empty string", i, u.Ref)
+ }
+ }
+ return nil
+}
+
+// Response is the daemon's answer.
+//
+// The three outcomes are kept apart because they mean different things to the
+// person pushing. OK is "proceed". Rejected is policy — the push broke a rule,
+// Message is written for their terminal, and re-pushing the same thing will
+// fail the same way. Error is infrastructure — Postgres down, a repository the
+// daemon does not own, a malformed request — and the same push may well
+// succeed once it is fixed. Both non-OK cases stop the push; only the wording
+// differs, and only because a rule you broke and a service that broke are not
+// the same problem.
+type Response struct {
+ Version int `json:"version"`
+ OK bool `json:"ok"`
+ Rejected bool `json:"rejected,omitempty"`
+ Message string `json:"message,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func okResponse() Response { return Response{Version: ProtocolVersion, OK: true} }
+
+func rejectedResponse(message string) Response {
+ return Response{Version: ProtocolVersion, Rejected: true, Message: message}
+}
+
+func errorResponse(format string, args ...any) Response {
+ return Response{Version: ProtocolVersion, Error: fmt.Sprintf(format, args...)}
+}
+
+// Validate reports whether a response can be acted on. A response that is
+// neither an acceptance nor a refusal with something to say is treated as an
+// unreachable daemon, which is to say: a rejection.
+func (r Response) Validate() error {
+ if r.Version != ProtocolVersion {
+ return fmt.Errorf("daemon answered protocol version %d, this hook speaks %d; "+
+ "the repository's hooks and the running daemon are different builds",
+ r.Version, ProtocolVersion)
+ }
+ switch {
+ case r.OK && (r.Rejected || r.Error != ""):
+ return errors.New("daemon answered ok and not-ok at once")
+ case r.OK:
+ return nil
+ case r.Rejected && strings.TrimSpace(r.Message) == "":
+ return errors.New("daemon rejected the push without saying why")
+ case r.Rejected:
+ return nil
+ case strings.TrimSpace(r.Error) == "":
+ return errors.New("daemon refused the push without saying why")
+ default:
+ return nil
+ }
+}
+
+// WriteRequest sends one request, newline terminated.
+func WriteRequest(w io.Writer, req Request) error { return writeJSON(w, req) }
+
+// ReadRequest reads one request. Anything past maxMessageBytes is an error, not
+// a truncation.
+func ReadRequest(r io.Reader) (Request, error) {
+ var req Request
+ err := readJSON(r, &req)
+ return req, err
+}
+
+// WriteResponse sends one response, newline terminated.
+func WriteResponse(w io.Writer, resp Response) error { return writeJSON(w, resp) }
+
+// ReadResponse reads one response.
+func ReadResponse(r io.Reader) (Response, error) {
+ var resp Response
+ err := readJSON(r, &resp)
+ return resp, err
+}
+
+func writeJSON(w io.Writer, v any) error {
+ buf, err := json.Marshal(v)
+ if err != nil {
+ return fmt.Errorf("hooks: encode %T: %w", v, err)
+ }
+ if len(buf)+1 > maxMessageBytes {
+ return fmt.Errorf("hooks: encoded %T is %d bytes, over the %d byte limit",
+ v, len(buf)+1, maxMessageBytes)
+ }
+ if _, err := w.Write(append(buf, '\n')); err != nil {
+ return fmt.Errorf("hooks: write %T: %w", v, err)
+ }
+ return nil
+}
+
+// readJSON decodes one message, tolerating fields it does not know.
+//
+// DisallowUnknownFields would be the stricter choice and it is deliberately not
+// used: a peer from a different build is caught by the version check, which
+// says so in one sentence, and refusing to decode it first would replace that
+// sentence with "json: unknown field". The version number is the compatibility
+// contract; the field set is not.
+func readJSON(r io.Reader, v any) error {
+ dec := json.NewDecoder(io.LimitReader(r, maxMessageBytes))
+ if err := dec.Decode(v); err != nil {
+ return fmt.Errorf("hooks: decode %T: %w", v, err)
+ }
+ return nil
+}
A hooks/proto_test.go => hooks/proto_test.go +157 -0
@@ 0,0 1,157 @@
+package hooks
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func validRequest() Request {
+ return Request{
+ Version: ProtocolVersion,
+ Method: MethodValidateRef,
+ Repo: "/var/lib/spec/~bigbes/rfcs",
+ Push: "4711",
+ Credential: Credential{Kind: PrincipalOwner},
+ Updates: []RefUpdate{{
+ Ref: "refs/heads/main",
+ Old: strings.Repeat("0", 40),
+ New: strings.Repeat("a", 40),
+ }},
+ }
+}
+
+func TestRequestRoundTrip(t *testing.T) {
+ want := validRequest()
+ want.Credential = Credential{Kind: PrincipalAgent, Token: "s3cret", Agent: "claude/spec", Session: "abc"}
+
+ var buf bytes.Buffer
+ if err := WriteRequest(&buf, want); err != nil {
+ t.Fatalf("WriteRequest: %v", err)
+ }
+ if !strings.HasSuffix(buf.String(), "\n") {
+ t.Error("a request must be newline terminated so a peer can frame it")
+ }
+ got, err := ReadRequest(&buf)
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+ if got.Method != want.Method || got.Repo != want.Repo || got.Push != want.Push {
+ t.Errorf("round trip lost fields: %+v", got)
+ }
+ if got.Credential != want.Credential {
+ t.Errorf("credential round trip: got %+v want %+v", got.Credential, want.Credential)
+ }
+ if len(got.Updates) != 1 || got.Updates[0] != want.Updates[0] {
+ t.Errorf("updates round trip: got %+v", got.Updates)
+ }
+}
+
+func TestRequestValidate(t *testing.T) {
+ zero := strings.Repeat("0", 40)
+ tests := []struct {
+ name string
+ mut func(*Request)
+ want string
+ }{
+ {"valid", func(*Request) {}, ""},
+ {"wrong version", func(r *Request) { r.Version = 99 }, "different builds"},
+ {"unknown method", func(r *Request) { r.Method = "reindex-everything" }, "unknown method"},
+ {"no repo", func(r *Request) { r.Repo = "" }, "no repository path"},
+ {"relative repo", func(r *Request) { r.Repo = "rfcs" }, "not absolute"},
+ {"no push id", func(r *Request) { r.Push = "" }, "no push correlation id"},
+ {"no principal", func(r *Request) { r.Credential.Kind = "" }, "unknown principal kind"},
+ {"agent with no token", func(r *Request) { r.Credential = Credential{Kind: PrincipalAgent} },
+ "must carry a token"},
+ {"owner with a token", func(r *Request) { r.Credential.Token = "x" }, "must not carry a token"},
+ {"no updates", func(r *Request) { r.Updates = nil }, "no ref updates"},
+ {"validate-ref with two updates", func(r *Request) {
+ r.Updates = append(r.Updates, RefUpdate{Ref: "refs/heads/x", Old: zero, New: zero})
+ }, "want exactly 1"},
+ {"empty object name", func(r *Request) { r.Updates[0].Old = "" }, "forty zeroes"},
+ {"no ref name", func(r *Request) { r.Updates[0].Ref = "" }, "no ref name"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := validRequest()
+ tt.mut(&req)
+ err := req.Validate()
+ switch {
+ case tt.want == "" && err != nil:
+ t.Fatalf("Validate: %v", err)
+ case tt.want == "":
+ case err == nil:
+ t.Fatalf("Validate accepted %s", tt.name)
+ default:
+ mentions(t, "the error", err.Error(), tt.want)
+ }
+ })
+ }
+}
+
+// TestResponseValidate covers the answers a hook must not act on. An
+// ambiguous response is treated as an unreachable daemon, which is a rejection
+// — never as permission to proceed.
+func TestResponseValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ resp Response
+ ok bool
+ }{
+ {"ok", Response{Version: ProtocolVersion, OK: true}, true},
+ {"rejected with a message", Response{Version: ProtocolVersion, Rejected: true, Message: "no"}, true},
+ {"error", Response{Version: ProtocolVersion, Error: "postgres is down"}, true},
+ {"wrong version", Response{Version: 2, OK: true}, false},
+ {"ok and rejected", Response{Version: ProtocolVersion, OK: true, Rejected: true, Message: "?"}, false},
+ {"ok and errored", Response{Version: ProtocolVersion, OK: true, Error: "?"}, false},
+ {"rejected with no message", Response{Version: ProtocolVersion, Rejected: true}, false},
+ {"rejected with a blank message", Response{Version: ProtocolVersion, Rejected: true, Message: " \n"}, false},
+ {"neither", Response{Version: ProtocolVersion}, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.resp.Validate()
+ if tt.ok && err != nil {
+ t.Fatalf("Validate: %v", err)
+ }
+ if !tt.ok && err == nil {
+ t.Fatal("Validate accepted an answer no hook can act on")
+ }
+ })
+ }
+}
+
+// TestReadRefusesOversizedMessage proves the socket cannot be used to exhaust
+// the daemon's memory: the read is capped, and hitting the cap is an error
+// rather than a truncation that would decode as a smaller, different request.
+func TestReadRefusesOversizedMessage(t *testing.T) {
+ huge := validRequest()
+ huge.Credential.Token = strings.Repeat("x", maxMessageBytes)
+
+ var buf bytes.Buffer
+ if err := WriteRequest(&buf, huge); err == nil {
+ t.Fatal("WriteRequest accepted a message over the limit")
+ }
+
+ // A peer that does not use WriteRequest still cannot get past the reader.
+ raw := `{"version":1,"method":"validate-ref","repo":"` + strings.Repeat("a", maxMessageBytes) + `"}`
+ if _, err := ReadRequest(strings.NewReader(raw)); err == nil {
+ t.Fatal("ReadRequest accepted a message over the limit")
+ }
+}
+
+// TestReadToleratesUnknownFields keeps the version number as the compatibility
+// contract: a peer from another build gets the sentence about mismatched
+// builds, not a json decoding error.
+func TestReadToleratesUnknownFields(t *testing.T) {
+ raw := `{"version":99,"method":"validate-ref","repo":"/x","push":"1","future_field":true}` + "\n"
+ req, err := ReadRequest(strings.NewReader(raw))
+ if err != nil {
+ t.Fatalf("ReadRequest: %v", err)
+ }
+ err = req.Validate()
+ if err == nil {
+ t.Fatal("Validate accepted version 99")
+ }
+ mentions(t, "the version mismatch", err.Error(), "different builds")
+}
A hooks/server.go => hooks/server.go +602 -0
@@ 0,0 1,602 @@
+package hooks
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// Backend is the daemon this package fronts. *service.Service satisfies it as
+// written; it is an interface so the receive path can be exercised end to end,
+// against a real `git push`, without a Postgres instance.
+//
+// Everything policy-shaped lives behind ValidatePush. This package decides
+// which space a repository is, who is pushing, and whether validation was
+// waived — and then asks. That split is the point of the whole design: the API
+// and the push path must not be able to disagree about what is valid.
+type Backend interface {
+ // ReposRoot is [spec.sr.ht] repos, the only directory a hook may address a
+ // repository under.
+ ReposRoot() string
+
+ // Resolver carries the instance owner username, the one identity an
+ // "owner" credential can resolve to.
+ Resolver() *authn.Resolver
+
+ // TokenStore is what an agent's token is validated against.
+ TokenStore() *service.TokenStore
+
+ // ValidatePush answers whether one ref may move. A *service.PushRejection
+ // is a policy refusal whose Error() is the text to print; anything else is
+ // an infrastructure failure and must fail the push closed.
+ ValidatePush(ctx context.Context, req service.PushRequest) error
+}
+
+// PushNotifier is what the daemon does when a push lands: reindex the changed
+// documents and advance the space's index rev stamp.
+//
+// It is injected rather than being a Backend method because Phase 1 has no
+// indexer — bleve and the index rev stamp belong to Phase 2 — and a stamp
+// advanced without a reindex behind it would be a lie the reconciler could not
+// detect. Until then the daemon supplies a notifier that records the push and
+// nothing else, and the reconciler reports the staleness.
+type PushNotifier func(ctx context.Context, space core.SpaceRef, updates []RefUpdate) error
+
+// Options configures a Server.
+type Options struct {
+ // Backend and Socket are required.
+ Backend Backend
+ Socket string
+
+ // OnPush is called for each landed push. Required: a server with no
+ // notifier would accept post-receive calls and drop them, which reads
+ // exactly like a working index that never updates.
+ OnPush PushNotifier
+
+ // Log defaults to slog.Default().
+ Log *slog.Logger
+
+ // Timeout bounds handling one request. Zero means DefaultTimeout.
+ Timeout time.Duration
+
+ // OptionTTL is how long a push's recorded options survive without the
+ // matching update calls. Zero means DefaultOptionTTL.
+ OptionTTL time.Duration
+}
+
+const (
+ // DefaultOptionTTL is how long the daemon remembers a push's options. The
+ // gap between pre-receive and the last update of one push is milliseconds;
+ // this is three orders of magnitude of slack, and the only cost of an
+ // entry outliving its push is a few hundred bytes until it is swept.
+ DefaultOptionTTL = 10 * time.Minute
+
+ // maxPendingPushes caps the option table. Any local process can connect to
+ // the socket, so the table must not be a way to exhaust memory; at the
+ // documented volume — one human, tens of documents a day — a thousand
+ // pushes in flight at once means something is wrong, and failing closed is
+ // the right answer to that.
+ maxPendingPushes = 1024
+
+ // socketDirMode keeps the socket's directory private to the service user.
+ // The socket is an unauthenticated write path to the daemon: anyone who
+ // can connect can assert the owner principal.
+ socketDirMode = 0o700
+ socketMode = 0o600
+)
+
+// Server is the daemon side of the receive path: a unix socket the hooks call.
+//
+// The socket is unix-domain and not a TCP port on localhost, for one reason
+// that decides it: filesystem permissions. An "owner" credential is an
+// assertion, so the ability to connect is the ability to push as the owner —
+// a 0700 directory holding a 0600 socket makes that reachable only by the
+// service user, which no localhost TCP port can do.
+type Server struct {
+ backend Backend
+ socket string
+ log *slog.Logger
+ onPush PushNotifier
+ timeout time.Duration
+ optionTTL time.Duration
+
+ listener net.Listener
+ wg sync.WaitGroup
+
+ // closing distinguishes a listener we shut down from one that failed. Both
+ // surface as net.ErrClosed out of Accept, and only one of them is an error.
+ closing atomic.Bool
+
+ mu sync.Mutex
+ pending map[string]*pendingPush
+}
+
+// pendingPush is what pre-receive told us about a push, waiting for the update
+// calls it belongs to.
+type pendingPush struct {
+ updates []RefUpdate
+ skip bool
+ expires time.Time
+}
+
+// NewServer builds a server. It does not listen; call Listen.
+func NewServer(opts Options) (*Server, error) {
+ if opts.Backend == nil {
+ return nil, errors.New("hooks: no backend")
+ }
+ if opts.Socket == "" {
+ return nil, errors.New("hooks: no socket path")
+ }
+ if !filepath.IsAbs(opts.Socket) {
+ return nil, fmt.Errorf("hooks: socket path %q is not absolute", opts.Socket)
+ }
+ if opts.OnPush == nil {
+ return nil, errors.New("hooks: no push notifier")
+ }
+ if opts.Backend.ReposRoot() == "" {
+ return nil, errors.New("hooks: backend has no repos root")
+ }
+ log := opts.Log
+ if log == nil {
+ log = slog.Default()
+ }
+ timeout := opts.Timeout
+ if timeout <= 0 {
+ timeout = DefaultTimeout
+ }
+ ttl := opts.OptionTTL
+ if ttl <= 0 {
+ ttl = DefaultOptionTTL
+ }
+ return &Server{
+ backend: opts.Backend,
+ socket: opts.Socket,
+ log: log,
+ onPush: opts.OnPush,
+ timeout: timeout,
+ optionTTL: ttl,
+ pending: make(map[string]*pendingPush),
+ }, nil
+}
+
+// Socket is the path this server listens on.
+func (s *Server) Socket() string { return s.socket }
+
+// Listen binds the socket.
+//
+// A leftover socket file from a crashed daemon is removed, but only after
+// proving it is dead: if something answers on it, another daemon is running
+// and this one refuses to start rather than stealing the push path from it.
+func (s *Server) Listen() error {
+ if err := os.MkdirAll(filepath.Dir(s.socket), socketDirMode); err != nil {
+ return fmt.Errorf("hooks: create %s: %w", filepath.Dir(s.socket), err)
+ }
+ // The directory may pre-date this version, or have been created with a
+ // looser umask; make it private either way.
+ if err := os.Chmod(filepath.Dir(s.socket), socketDirMode); err != nil {
+ return fmt.Errorf("hooks: restrict %s: %w", filepath.Dir(s.socket), err)
+ }
+
+ if _, err := os.Stat(s.socket); err == nil {
+ conn, dialErr := net.DialTimeout("unix", s.socket, time.Second)
+ if dialErr == nil {
+ conn.Close()
+ return fmt.Errorf("hooks: %s is already served by another process", s.socket)
+ }
+ if err := os.Remove(s.socket); err != nil {
+ return fmt.Errorf("hooks: remove the stale socket %s: %w", s.socket, err)
+ }
+ s.log.Warn("removed a stale hook socket", "socket", s.socket, "dial_error", dialErr)
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("hooks: stat %s: %w", s.socket, err)
+ }
+
+ ln, err := net.Listen("unix", s.socket)
+ if err != nil {
+ return fmt.Errorf("hooks: listen on %s: %w", s.socket, err)
+ }
+ if err := os.Chmod(s.socket, socketMode); err != nil {
+ ln.Close()
+ return fmt.Errorf("hooks: restrict %s: %w", s.socket, err)
+ }
+ s.listener = ln
+ return nil
+}
+
+// Serve accepts hook connections until ctx is cancelled, then waits for the
+// calls already in flight. Listen must have succeeded first.
+func (s *Server) Serve(ctx context.Context) error {
+ if s.listener == nil {
+ return errors.New("hooks: Serve called before Listen")
+ }
+
+ done := make(chan struct{})
+ defer close(done)
+ go func() {
+ select {
+ case <-ctx.Done():
+ s.listener.Close()
+ case <-done:
+ }
+ }()
+
+ for {
+ conn, err := s.listener.Accept()
+ if err != nil {
+ s.wg.Wait()
+ if ctx.Err() != nil || s.closing.Load() {
+ return nil
+ }
+ return fmt.Errorf("hooks: accept on %s: %w", s.socket, err)
+ }
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ s.serveConn(ctx, conn)
+ }()
+ }
+}
+
+// Close stops listening and removes the socket. It is idempotent, and safe to
+// call after Serve has already returned: a daemon shuts down by cancelling
+// Serve's context and then calling this, and a daemon that failed to start
+// after Listen calls only this.
+func (s *Server) Close() error {
+ if s.listener == nil {
+ return nil
+ }
+ s.closing.Store(true)
+ err := s.listener.Close()
+ if errors.Is(err, net.ErrClosed) {
+ err = nil
+ }
+ s.wg.Wait()
+ // net's unix listener unlinks the socket itself; removing it again is
+ // tolerated so a listener built elsewhere is still cleaned up.
+ if rmErr := os.Remove(s.socket); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
+ if err == nil {
+ err = fmt.Errorf("hooks: remove %s: %w", s.socket, rmErr)
+ }
+ }
+ return err
+}
+
+func (s *Server) serveConn(ctx context.Context, conn net.Conn) {
+ defer conn.Close()
+
+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
+ defer cancel()
+ if deadline, ok := ctx.Deadline(); ok {
+ // The context bounds the handler, not the socket reads; without a
+ // deadline on the connection a peer that connects and says nothing
+ // holds a goroutine forever.
+ if err := conn.SetDeadline(deadline); err != nil {
+ s.log.Error("could not bound a hook connection", "socket", s.socket, "error", err)
+ return
+ }
+ }
+
+ req, err := ReadRequest(conn)
+ if err != nil {
+ s.log.Error("unreadable hook request", "socket", s.socket, "error", err)
+ // The peer may be something that is not a hook at all; answer anyway,
+ // so a hook that sent a message we could not parse still gets a
+ // refusal rather than a closed connection it has to interpret.
+ s.reply(conn, errorResponse("the daemon could not read the request: %v", err))
+ return
+ }
+
+ resp := s.handle(ctx, req)
+ s.reply(conn, resp)
+}
+
+func (s *Server) reply(conn net.Conn, resp Response) {
+ if err := WriteResponse(conn, resp); err != nil {
+ s.log.Error("could not answer a hook", "socket", s.socket, "error", err)
+ }
+}
+
+// handle answers one request. It never panics a connection into the pusher's
+// terminal: every failure becomes a Response the hook knows how to print.
+func (s *Server) handle(ctx context.Context, req Request) Response {
+ if err := req.Validate(); err != nil {
+ s.log.Error("malformed hook request", "method", req.Method, "error", err)
+ return errorResponse("malformed %s request: %v", req.Method, err)
+ }
+
+ space, err := s.spaceFor(req.Repo)
+ if err != nil {
+ s.log.Error("hook named a repository we do not own", "repo", req.Repo, "error", err)
+ return errorResponse("%v", err)
+ }
+
+ principal, resp, ok := s.principal(ctx, space, req.Credential)
+ if !ok {
+ return resp
+ }
+
+ log := s.log.With(
+ "space", space.String(),
+ "principal", principal.String(),
+ "push", req.Push,
+ "method", string(req.Method),
+ )
+
+ switch req.Method {
+ case MethodPushOptions:
+ return s.handlePushOptions(req, space, log)
+ case MethodValidateRef:
+ return s.handleValidateRef(ctx, req, space, principal, log)
+ case MethodPushed:
+ return s.handlePushed(ctx, req, space, log)
+ default:
+ // Request.Validate already rejected anything else.
+ return errorResponse("unhandled method %q", req.Method)
+ }
+}
+
+// handlePushOptions records what pre-receive saw, and refuses a push option
+// this service does not understand.
+func (s *Server) handlePushOptions(req Request, space core.SpaceRef, log *slog.Logger) Response {
+ if unknown := UnknownOptions(req.Options); len(unknown) > 0 {
+ log.Info("refused unknown push options", "options", unknown)
+ return rejectedResponse(unknownOptionMessage(space, unknown))
+ }
+ skip := SkipValidation(req.Options)
+ if err := s.remember(req, skip); err != nil {
+ log.Error("could not record push options", "error", err)
+ return errorResponse("%v", err)
+ }
+ log.Info("push received", "refs", len(req.Updates), "skip_validation", skip)
+ return okResponse()
+}
+
+// handleValidateRef is the whole of the rejecting path: the refs rule, then
+// frontmatter and document-id validation, both inside service.ValidatePush so
+// the push path and the API cannot drift apart.
+func (s *Server) handleValidateRef(ctx context.Context, req Request, space core.SpaceRef,
+ principal authn.Principal, log *slog.Logger) Response {
+
+ update := req.Updates[0]
+ skip, err := s.recall(req, update)
+ if err != nil {
+ log.Error("no recorded pre-receive phase for this push", "ref", update.Ref, "error", err)
+ return errorResponse("%v", err)
+ }
+
+ err = s.backend.ValidatePush(ctx, service.PushRequest{
+ Space: space,
+ Principal: principal,
+ Ref: update.Ref,
+ Old: update.Old,
+ New: update.New,
+ SkipValidation: skip,
+ })
+ var rejection *service.PushRejection
+ switch {
+ case err == nil:
+ log.Info("ref accepted", "ref", update.Ref, "skip_validation", skip)
+ return okResponse()
+ case errors.As(err, &rejection):
+ log.Info("ref rejected", "ref", update.Ref, "problems", len(rejection.Problems),
+ "skippable", rejection.Skippable)
+ return rejectedResponse(rejection.Error())
+ default:
+ // Not a policy answer: Postgres down, an unreadable object, a space
+ // with no row. The hook fails the push closed on it.
+ log.Error("could not validate a ref", "ref", update.Ref, "error", err)
+ return errorResponse("spec.sr.ht could not validate %s: %v", update.Ref, err)
+ }
+}
+
+// handlePushed notifies the daemon that refs moved. Its answer cannot stop
+// anything — git ignores post-receive's exit status — but it is still reported
+// honestly so the hook can warn that the index is stale.
+func (s *Server) handlePushed(ctx context.Context, req Request, space core.SpaceRef, log *slog.Logger) Response {
+ s.forget(req)
+ if err := s.onPush(ctx, space, req.Updates); err != nil {
+ log.Error("could not record a landed push", "refs", len(req.Updates), "error", err)
+ return errorResponse("%v", err)
+ }
+ log.Info("push landed", "refs", len(req.Updates))
+ return okResponse()
+}
+
+// spaceFor turns the repository a hook is running in into a space.
+//
+// The hook's claim is never taken at face value: the path is matched against
+// this daemon's own repos root and then re-derived through gitx.DiskPath, so
+// the only paths that resolve are the ones this daemon would itself have
+// created. gitx is the single source of truth for that layout — deriving it
+// twice is how the two copies drift.
+func (s *Server) spaceFor(repo string) (core.SpaceRef, error) {
+ root, err := filepath.EvalSymlinks(s.backend.ReposRoot())
+ if err != nil {
+ return core.SpaceRef{}, fmt.Errorf("the repos root %s is unreadable: %w",
+ s.backend.ReposRoot(), err)
+ }
+ root, err = filepath.Abs(root)
+ if err != nil {
+ return core.SpaceRef{}, fmt.Errorf("the repos root %s is unresolvable: %w",
+ s.backend.ReposRoot(), err)
+ }
+
+ clean := filepath.Clean(repo)
+ rel, err := filepath.Rel(root, clean)
+ if err != nil {
+ return core.SpaceRef{}, fmt.Errorf("%s is not under the repos root %s", repo, root)
+ }
+ segs := strings.Split(rel, string(filepath.Separator))
+ if len(segs) != 2 || segs[0] == ".." || !strings.HasPrefix(segs[0], "~") {
+ return core.SpaceRef{}, fmt.Errorf("%s is not a space repository; "+
+ "this daemon serves %s/~<owner>/<space> only", repo, root)
+ }
+ ref, err := core.ParseSpaceRef(rel)
+ if err != nil {
+ return core.SpaceRef{}, fmt.Errorf("%s does not name a valid space: %w", repo, err)
+ }
+ if want := gitx.DiskPath(root, ref); want != clean {
+ return core.SpaceRef{}, fmt.Errorf("%s does not name a space; %s would live at %s",
+ repo, ref, want)
+ }
+ return ref, nil
+}
+
+// principal resolves the credential a hook forwarded.
+//
+// The owner is not looked up: sshd authenticated the SSH key and the forced
+// command asserted it, and there is exactly one owner on this instance, so the
+// name comes from the resolver rather than from the wire — a hook cannot name
+// somebody else. An agent's token is checked on every push.
+func (s *Server) principal(ctx context.Context, space core.SpaceRef, cred Credential) (authn.Principal, Response, bool) {
+ owner := s.backend.Resolver().Owner()
+ switch cred.Kind {
+ case PrincipalOwner:
+ return authn.Principal{Kind: authn.KindOwner, Owner: owner, CookieUser: owner}, Response{}, true
+ case PrincipalAgent:
+ tok, err := authn.ResolveAgentToken(ctx, s.backend.TokenStore(), cred.Token)
+ if err != nil {
+ if authn.IsAuthFailure(err) {
+ s.log.Warn("refused an agent push", "space", space.String(), "error", err)
+ return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false
+ }
+ // The store could not answer. That is not a bad credential and
+ // must not read as one; fail the push closed instead.
+ s.log.Error("could not validate an agent token", "space", space.String(), "error", err)
+ return authn.Principal{}, errorResponse(
+ "spec.sr.ht could not check the agent token presented with this push: %v", err), false
+ }
+ return authn.Principal{
+ Kind: authn.KindAgent,
+ Owner: owner,
+ Agent: cred.Agent,
+ Session: cred.Session,
+ TokenName: tok.Name,
+ }, Response{}, true
+ default:
+ // Request.Validate rejected every other spelling already.
+ return authn.Principal{}, errorResponse("unknown principal kind %q", cred.Kind), false
+ }
+}
+
+// pendingKey identifies one push: the repository plus the pid of the
+// receive-pack process every hook of that push is a child of.
+func pendingKey(req Request) string { return req.Repo + "\x00" + req.Push }
+
+// remember stores what pre-receive saw. It sweeps expired entries first, and
+// refuses rather than growing without bound.
+func (s *Server) remember(req Request, skip bool) error {
+ now := time.Now()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sweepLocked(now)
+ if len(s.pending) >= maxPendingPushes {
+ return fmt.Errorf("spec.sr.ht is already tracking %d pushes in flight and cannot accept another",
+ len(s.pending))
+ }
+ s.pending[pendingKey(req)] = &pendingPush{
+ updates: append([]RefUpdate(nil), req.Updates...),
+ skip: skip,
+ expires: now.Add(s.optionTTL),
+ }
+ return nil
+}
+
+// recall answers whether validation was waived for this ref.
+//
+// The pre-receive record is required, not optional. Absence is not read as
+// "not waived": it means either that the repository's hooks are half installed
+// — no pre-receive, so no push option would ever be seen and skip-validation
+// would silently never work — or that the daemon restarted mid-push. Both
+// deserve a sentence rather than a guess.
+//
+// The recorded ref update must also match this one exactly. That is what makes
+// the pid safe as a correlation key: a recycled pid would have to be paired
+// with an identical ref, old and new object name to be mistaken for this push.
+func (s *Server) recall(req Request, update RefUpdate) (bool, error) {
+ now := time.Now()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sweepLocked(now)
+
+ entry, ok := s.pending[pendingKey(req)]
+ if !ok {
+ return false, fmt.Errorf("the daemon did not see the pre-receive phase of this push. "+
+ "Either the repository's hooks are only partly installed (all of %s must be present) "+
+ "or the daemon restarted mid-push; push again",
+ strings.Join(modeNames(), ", "))
+ }
+ for _, u := range entry.updates {
+ if u == update {
+ return entry.skip, nil
+ }
+ }
+ return false, fmt.Errorf("the pre-receive phase of push %s did not announce %s; "+
+ "the daemon will not validate a ref it was not told about", req.Push, update)
+}
+
+// forget drops a push's record once post-receive has run.
+func (s *Server) forget(req Request) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.pending, pendingKey(req))
+ s.sweepLocked(time.Now())
+}
+
+func (s *Server) sweepLocked(now time.Time) {
+ for k, v := range s.pending {
+ if now.After(v.expires) {
+ delete(s.pending, k)
+ }
+ }
+}
+
+func modeNames() []string {
+ out := make([]string, 0, len(Modes()))
+ for _, m := range Modes() {
+ out = append(out, string(m))
+ }
+ return out
+}
+
+// unknownOptionMessage is what a mistyped push option prints. It is a
+// rejection rather than a shrug because there is exactly one option in the
+// vocabulary: silently ignoring "--push-option=skip-validaton" would reject
+// the push for the very thing the human believed they had waived.
+func unknownOptionMessage(space core.SpaceRef, unknown []string) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n")
+ fmt.Fprintf(&b, " space: %s\n\n", space)
+ for _, o := range unknown {
+ fmt.Fprintf(&b, " --push-option=%s is not a push option this service knows\n", o)
+ }
+ fmt.Fprintf(&b, "\nThe only push option is --push-option=%s, which waives\n", OptionSkipValidation)
+ fmt.Fprintf(&b, "frontmatter and document-id validation. Nothing was written.\n")
+ return b.String()
+}
+
+// badTokenMessage is what an agent sees when its token does not authenticate.
+// It never echoes the token.
+func badTokenMessage(space core.SpaceRef, cause error) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n")
+ fmt.Fprintf(&b, " space: %s\n\n", space)
+ fmt.Fprintf(&b, " the agent token presented with this push was refused:\n")
+ fmt.Fprintf(&b, " %v\n\n", cause)
+ fmt.Fprintf(&b, "Nothing was written. Agents write through the REST and MCP planes,\n")
+ fmt.Fprintf(&b, "not over git; a token that works there is not a git credential.\n")
+ return b.String()
+}
A hooks/server_test.go => hooks/server_test.go +491 -0
@@ 0,0 1,491 @@
+package hooks
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/db"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+const (
+ zeroOID = "0000000000000000000000000000000000000000"
+ oneOID = "1111111111111111111111111111111111111111"
+ twoOID = "2222222222222222222222222222222222222222"
+)
+
+// call sends one request over the real socket, which is what the daemon and
+// the hooks actually speak. Testing handle() directly would skip the framing.
+func call(t *testing.T, srv *Server, req Request) Response {
+ t.Helper()
+ resp, err := Client{Socket: srv.Socket(), Timeout: 10 * time.Second}.
+ Call(context.Background(), req)
+ if err != nil {
+ t.Fatalf("Call(%s): %v", req.Method, err)
+ }
+ return resp
+}
+
+// serverFixture is a server over a bare repository at the real layout.
+type serverFixture struct {
+ root string
+ repo string
+ back *fakeBackend
+ srv *Server
+}
+
+func newServerFixture(t *testing.T, tokens map[string]*db.AgentToken) *serverFixture {
+ t.Helper()
+ root := shortTempDir(t)
+ repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
+ for _, sub := range []string{"objects", "refs"} {
+ if err := mkdirAll(filepath.Join(repo, sub)); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ }
+ back := newFakeBackend(t, root, tokens)
+ srv, _ := startServer(t, back)
+ return &serverFixture{root: root, repo: repo, back: back, srv: srv}
+}
+
+func (f *serverFixture) request(method Method, push string, updates ...RefUpdate) Request {
+ return Request{
+ Version: ProtocolVersion,
+ Method: method,
+ Repo: f.repo,
+ Push: push,
+ Credential: Credential{Kind: PrincipalOwner},
+ Updates: updates,
+ }
+}
+
+var mainUpdate = RefUpdate{Ref: "refs/heads/main", Old: oneOID, New: twoOID}
+
+// TestPushLifecycle is the protocol as one push runs it: pre-receive records
+// the options, update reads them back, post-receive lands.
+func TestPushLifecycle(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ pre := f.request(MethodPushOptions, "4711", mainUpdate)
+ pre.Options = []string{OptionSkipValidation}
+ if resp := call(t, f.srv, pre); !resp.OK {
+ t.Fatalf("pre-receive: %+v", resp)
+ }
+
+ if resp := call(t, f.srv, f.request(MethodValidateRef, "4711", mainUpdate)); !resp.OK {
+ t.Fatalf("update: %+v", resp)
+ }
+ if len(f.back.seen) != 1 {
+ t.Fatalf("the backend saw %d requests, want 1", len(f.back.seen))
+ }
+ got := f.back.seen[0]
+ if got.Space != testSpace {
+ t.Errorf("space: got %s want %s", got.Space, testSpace)
+ }
+ if !got.SkipValidation {
+ t.Error("the push option recorded by pre-receive did not reach ValidatePush")
+ }
+ if !got.Principal.IsOwner() || got.Principal.Owner != testOwner {
+ t.Errorf("principal: got %+v", got.Principal)
+ }
+ if got.Ref != mainUpdate.Ref || got.Old != mainUpdate.Old || got.New != mainUpdate.New {
+ t.Errorf("ref update: got %s %s..%s", got.Ref, got.Old, got.New)
+ }
+
+ if resp := call(t, f.srv, f.request(MethodPushed, "4711", mainUpdate)); !resp.OK {
+ t.Fatalf("post-receive: %+v", resp)
+ }
+}
+
+// TestSkipValidationDefaultsOff proves the waiver is opt-in per push and does
+// not leak from one push into the next.
+func TestSkipValidationDefaultsOff(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ waived := f.request(MethodPushOptions, "1", mainUpdate)
+ waived.Options = []string{OptionSkipValidation}
+ call(t, f.srv, waived)
+ call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
+
+ call(t, f.srv, f.request(MethodPushOptions, "2", mainUpdate))
+ call(t, f.srv, f.request(MethodValidateRef, "2", mainUpdate))
+
+ if len(f.back.seen) != 2 {
+ t.Fatalf("the backend saw %d requests, want 2", len(f.back.seen))
+ }
+ if !f.back.seen[0].SkipValidation {
+ t.Error("the first push's waiver was lost")
+ }
+ if f.back.seen[1].SkipValidation {
+ t.Error("a waiver leaked into the next push")
+ }
+}
+
+// TestUpdateWithoutPreReceiveIsRefused is the fail-closed half of the
+// correlation. Absence is not read as "not waived": it means the hooks are
+// half installed or the daemon restarted mid-push, and either deserves a
+// sentence rather than a guess.
+func TestUpdateWithoutPreReceiveIsRefused(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ resp := call(t, f.srv, f.request(MethodValidateRef, "4711", mainUpdate))
+ if resp.OK {
+ t.Fatal("update was answered with no recorded pre-receive phase")
+ }
+ mentions(t, "the refusal", resp.Error, "pre-receive")
+ if len(f.back.seen) != 0 {
+ t.Error("ValidatePush was called for a push the daemon knew nothing about")
+ }
+}
+
+// TestUpdateForAnUnannouncedRefIsRefused is what makes the receive-pack pid
+// safe as a correlation key: a recycled pid would also have to be paired with
+// an identical ref and object names.
+func TestUpdateForAnUnannouncedRefIsRefused(t *testing.T) {
+ f := newServerFixture(t, nil)
+ call(t, f.srv, f.request(MethodPushOptions, "4711", mainUpdate))
+
+ other := RefUpdate{Ref: "refs/heads/proposals/9", Old: zeroOID, New: twoOID}
+ resp := call(t, f.srv, f.request(MethodValidateRef, "4711", other))
+ if resp.OK {
+ t.Fatal("update was answered for a ref pre-receive never announced")
+ }
+ mentions(t, "the refusal", resp.Error, "did not announce")
+}
+
+func TestExpiredPushOptionsAreRefused(t *testing.T) {
+ root := shortTempDir(t)
+ repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
+ if err := mkdirAll(repo); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ back := newFakeBackend(t, root, nil)
+ srv, _ := startServer(t, back, func(o *Options) { o.OptionTTL = time.Nanosecond })
+ f := &serverFixture{root: root, repo: repo, back: back, srv: srv}
+
+ call(t, f.srv, f.request(MethodPushOptions, "4711", mainUpdate))
+ time.Sleep(2 * time.Millisecond)
+ if resp := call(t, f.srv, f.request(MethodValidateRef, "4711", mainUpdate)); resp.OK {
+ t.Fatal("an expired record still authorized an update")
+ }
+}
+
+// TestUnknownPushOptionIsRejected: with one option in the vocabulary, silently
+// ignoring a typo would reject the push for the very thing the human believed
+// they had waived.
+func TestUnknownPushOptionIsRejected(t *testing.T) {
+ f := newServerFixture(t, nil)
+ req := f.request(MethodPushOptions, "4711", mainUpdate)
+ req.Options = []string{"skip-validaton"}
+
+ resp := call(t, f.srv, req)
+ if resp.OK {
+ t.Fatal("an unknown push option was ignored")
+ }
+ if !resp.Rejected {
+ t.Errorf("a mistyped option is a rejection, not an infrastructure failure: %+v", resp)
+ }
+ mentions(t, "the rejection", resp.Message,
+ "skip-validaton", "is not a push option", OptionSkipValidation)
+}
+
+// TestRepositoryMustBeOurs: a hook can only ever address a repository this
+// daemon owns, because the path is re-derived through gitx's layout rather
+// than parsed out of what the hook claimed.
+func TestRepositoryMustBeOurs(t *testing.T) {
+ f := newServerFixture(t, nil)
+
+ outside := []struct {
+ name string
+ repo string
+ }{
+ {"outside the repos root", "/etc"},
+ {"escaping the repos root", filepath.Join(f.root, "..", "elsewhere")},
+ {"too shallow", filepath.Join(f.root, "~bigbes")},
+ {"too deep", filepath.Join(f.root, "~bigbes", "rfcs", "objects")},
+ {"no owner sigil", filepath.Join(f.root, "bigbes", "rfcs")},
+ {"the socket directory", filepath.Join(f.root, socketDir, "x")},
+ }
+ for _, tt := range outside {
+ t.Run(tt.name, func(t *testing.T) {
+ req := f.request(MethodPushOptions, "1", mainUpdate)
+ req.Repo = tt.repo
+ resp := call(t, f.srv, req)
+ if resp.OK {
+ t.Fatalf("the daemon accepted %s as one of its repositories", tt.repo)
+ }
+ if resp.Error == "" {
+ t.Errorf("no explanation: %+v", resp)
+ }
+ })
+ }
+}
+
+// TestOwnerCredentialCannotNameSomebodyElse: the wire carries a kind, never a
+// username. The owner comes from the resolver.
+func TestOwnerCredentialCannotNameSomebodyElse(t *testing.T) {
+ f := newServerFixture(t, nil)
+ call(t, f.srv, f.request(MethodPushOptions, "1", mainUpdate))
+ call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
+
+ if len(f.back.seen) != 1 {
+ t.Fatalf("the backend saw %d requests", len(f.back.seen))
+ }
+ if got := f.back.seen[0].Principal.Owner; got != testOwner {
+ t.Errorf("owner: got %q want %q", got, testOwner)
+ }
+}
+
+func TestAgentCredential(t *testing.T) {
+ live := &db.AgentToken{ID: 1, Name: "laptop", Hash: authn.HashToken("good"), Created: time.Now()}
+ revokedAt := time.Now().Add(-time.Hour)
+ revoked := &db.AgentToken{ID: 2, Name: "old", Hash: authn.HashToken("dead"), Revoked: &revokedAt}
+ f := newServerFixture(t, map[string]*db.AgentToken{"good": live, "dead": revoked})
+
+ agent := func(token string) Request {
+ req := f.request(MethodPushOptions, "1", mainUpdate)
+ req.Credential = Credential{Kind: PrincipalAgent, Token: token, Agent: "claude/spec", Session: "s1"}
+ return req
+ }
+
+ t.Run("a valid token resolves to an agent", func(t *testing.T) {
+ if resp := call(t, f.srv, agent("good")); !resp.OK {
+ t.Fatalf("a valid agent token was refused: %+v", resp)
+ }
+ req := f.request(MethodValidateRef, "1", mainUpdate)
+ req.Credential = Credential{Kind: PrincipalAgent, Token: "good", Agent: "claude/spec", Session: "s1"}
+ if resp := call(t, f.srv, req); !resp.OK {
+ t.Fatalf("update: %+v", resp)
+ }
+ p := f.back.seen[len(f.back.seen)-1].Principal
+ if !p.IsAgent() || p.Agent != "claude/spec" || p.Session != "s1" || p.TokenName != "laptop" {
+ t.Errorf("principal: %+v", p)
+ }
+ })
+
+ t.Run("an unknown token is a rejection, and the token is not echoed", func(t *testing.T) {
+ resp := call(t, f.srv, agent("wrong"))
+ if resp.OK || !resp.Rejected {
+ t.Fatalf("an unknown token was not rejected: %+v", resp)
+ }
+ mentionsNot(t, "the rejection", resp.Message, "wrong")
+ })
+
+ t.Run("a revoked token says revoked", func(t *testing.T) {
+ resp := call(t, f.srv, agent("dead"))
+ if resp.OK || !resp.Rejected {
+ t.Fatalf("a revoked token was not rejected: %+v", resp)
+ }
+ mentions(t, "the rejection", resp.Message, "revoked")
+ })
+}
+
+// TestTokenStoreOutageIsNotABadCredential: a store that cannot answer must
+// fail the push closed, not read as an invalid token — the difference between
+// "retry later" and "reprovision your agent".
+func TestTokenStoreOutageIsNotABadCredential(t *testing.T) {
+ root := shortTempDir(t)
+ repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
+ if err := mkdirAll(repo); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ lookup := &fakeLookup{err: errFakeStore}
+ store := service.NewTokenStore(lookup)
+ resolver, err := authn.NewResolver(testOwner, store)
+ if err != nil {
+ t.Fatalf("NewResolver: %v", err)
+ }
+ back := &fakeBackend{root: root, resolver: resolver, tokens: store}
+ srv, _ := startServer(t, back)
+ f := &serverFixture{root: root, repo: repo, back: back, srv: srv}
+
+ req := f.request(MethodPushOptions, "1", mainUpdate)
+ req.Credential = Credential{Kind: PrincipalAgent, Token: "anything"}
+ resp := call(t, f.srv, req)
+ if resp.OK {
+ t.Fatal("a store outage let a push through")
+ }
+ if resp.Rejected {
+ t.Errorf("a store outage was reported as a bad credential: %+v", resp)
+ }
+ mentions(t, "the failure", resp.Error, "could not check the agent token")
+}
+
+// TestValidatePushRejectionIsPassedThroughVerbatim: the daemon composed the
+// text for a terminal and this package must not reword it.
+func TestValidatePushRejectionIsPassedThroughVerbatim(t *testing.T) {
+ f := newServerFixture(t, nil)
+ want := rejection("refs/heads/main", true, service.PushProblem{
+ Kind: service.ProblemFrontmatter,
+ Path: "specs/0002-broken.md",
+ Detail: "frontmatter is missing the required key `id`",
+ })
+ f.back.validate = func(context.Context, service.PushRequest) error { return want }
+
+ call(t, f.srv, f.request(MethodPushOptions, "1", mainUpdate))
+ resp := call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
+ if resp.OK || !resp.Rejected {
+ t.Fatalf("a rejection was not passed through: %+v", resp)
+ }
+ if resp.Message != want.Error() {
+ t.Errorf("the rejection was reworded:\ngot:\n%s\nwant:\n%s", resp.Message, want.Error())
+ }
+}
+
+// TestInfrastructureFailureIsNotAPolicyRejection keeps "you broke a rule"
+// and "we broke" apart: only the first is worth changing your push over.
+func TestInfrastructureFailureIsNotAPolicyRejection(t *testing.T) {
+ f := newServerFixture(t, nil)
+ f.back.validate = func(context.Context, service.PushRequest) error {
+ return fmt.Errorf("service: look up space: %w", errFakeStore)
+ }
+
+ call(t, f.srv, f.request(MethodPushOptions, "1", mainUpdate))
+ resp := call(t, f.srv, f.request(MethodValidateRef, "1", mainUpdate))
+ if resp.OK {
+ t.Fatal("a failed validation let the push through")
+ }
+ if resp.Rejected {
+ t.Errorf("an infrastructure failure was reported as a policy rejection: %+v", resp)
+ }
+ mentions(t, "the failure", resp.Error, "could not validate", errFakeStore.Error())
+}
+
+// TestPostReceiveReportsANotifierFailure: it cannot stop anything, but it must
+// not pretend the index was updated either.
+func TestPostReceiveReportsANotifierFailure(t *testing.T) {
+ root := shortTempDir(t)
+ repo := filepath.Join(root, "~"+testSpace.Owner, testSpace.Name)
+ if err := mkdirAll(repo); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ back := newFakeBackend(t, root, nil)
+ srv, _ := startServer(t, back, func(o *Options) {
+ o.OnPush = func(context.Context, core.SpaceRef, []RefUpdate) error {
+ return errors.New("the indexer is not running")
+ }
+ })
+ f := &serverFixture{root: root, repo: repo, back: back, srv: srv}
+
+ resp := call(t, f.srv, f.request(MethodPushed, "1", mainUpdate))
+ if resp.OK {
+ t.Fatal("a failed notification was reported as success")
+ }
+ mentions(t, "the failure", resp.Error, "indexer is not running")
+}
+
+func TestNewServerRequiresItsWiring(t *testing.T) {
+ root := shortTempDir(t)
+ back := newFakeBackend(t, root, nil)
+ ok := Options{Backend: back, Socket: SocketPath(root),
+ OnPush: func(context.Context, core.SpaceRef, []RefUpdate) error { return nil }}
+
+ tests := []struct {
+ name string
+ mut func(*Options)
+ want string
+ }{
+ {"valid", func(*Options) {}, ""},
+ {"no backend", func(o *Options) { o.Backend = nil }, "no backend"},
+ {"no socket", func(o *Options) { o.Socket = "" }, "no socket path"},
+ {"relative socket", func(o *Options) { o.Socket = "hook.sock" }, "not absolute"},
+ {"no notifier", func(o *Options) { o.OnPush = nil }, "no push notifier"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := ok
+ tt.mut(&opts)
+ _, err := NewServer(opts)
+ if tt.want == "" {
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("NewServer accepted %s", tt.name)
+ }
+ mentions(t, "the error", err.Error(), tt.want)
+ })
+ }
+}
+
+// TestListenRefusesToStealALiveSocket: two daemons on one repos root would
+// each validate half the pushes, and the second would silently take over the
+// push path from the first.
+func TestListenRefusesToStealALiveSocket(t *testing.T) {
+ root := shortTempDir(t)
+ back := newFakeBackend(t, root, nil)
+ startServer(t, back)
+
+ second, err := NewServer(Options{
+ Backend: back, Socket: SocketPath(root), Log: discardLogger(),
+ OnPush: func(context.Context, core.SpaceRef, []RefUpdate) error { return nil },
+ })
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+ err = second.Listen()
+ if err == nil {
+ second.Close()
+ t.Fatal("a second daemon took the socket from a live one")
+ }
+ mentions(t, "the error", err.Error(), "already served by another process")
+}
+
+// TestListenClearsADeadSocket: the other half — a socket left behind by a
+// crash must not block a restart.
+func TestListenClearsADeadSocket(t *testing.T) {
+ root := shortTempDir(t)
+ socket := SocketPath(root)
+ if err := mkdirAll(filepath.Dir(socket)); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ if err := writeFile(socket, "not a socket"); err != nil {
+ t.Fatalf("write a stale socket: %v", err)
+ }
+
+ back := newFakeBackend(t, root, nil)
+ srv, err := NewServer(Options{
+ Backend: back, Socket: socket, Log: discardLogger(),
+ OnPush: func(context.Context, core.SpaceRef, []RefUpdate) error { return nil },
+ })
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+ if err := srv.Listen(); err != nil {
+ t.Fatalf("Listen over a stale socket: %v", err)
+ }
+ t.Cleanup(func() { srv.Close() })
+}
+
+// TestGarbageOnTheSocketIsAnswered: the peer may not be a hook at all, and a
+// closed connection would leave a real hook guessing.
+func TestGarbageOnTheSocketIsAnswered(t *testing.T) {
+ f := newServerFixture(t, nil)
+ conn, err := dialUnix(f.srv.Socket())
+ if err != nil {
+ t.Fatalf("dial: %v", err)
+ }
+ defer conn.Close()
+ if _, err := conn.Write([]byte("hello?\n")); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ resp, err := ReadResponse(conn)
+ if err != nil {
+ t.Fatalf("ReadResponse: %v", err)
+ }
+ if resp.OK {
+ t.Fatal("the daemon answered ok to something that was not a request")
+ }
+ if !strings.Contains(resp.Error, "could not read the request") {
+ t.Errorf("unhelpful answer: %+v", resp)
+ }
+}