diff --git a/cmd/specsrht/main.go b/cmd/specsrht/main.go new file mode 100644 index 0000000000000000000000000000000000000000..2f7a44356fbdb226325a5d279248b102eb1583e0 --- /dev/null +++ b/cmd/specsrht/main.go @@ -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 /.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 ` 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)) + } + } + }() +} diff --git a/cmd/specsrht/main_test.go b/cmd/specsrht/main_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7f78e0c03b13b9c7db8b0f4672f196cfaa294990 --- /dev/null +++ b/cmd/specsrht/main_test.go @@ -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") + } +} diff --git a/hooks/client.go b/hooks/client.go new file mode 100644 index 0000000000000000000000000000000000000000..a045b67519b2a3af3731c022e8313a4e45e10589 --- /dev/null +++ b/hooks/client.go @@ -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 +} diff --git a/hooks/doc.go b/hooks/doc.go new file mode 100644 index 0000000000000000000000000000000000000000..fdd8774420f62bfea6f728d1622f1df9877a7d1d --- /dev/null +++ b/hooks/doc.go @@ -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_ 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 /.specsrht/hook.sock, where 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 +// /~/. 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 diff --git a/hooks/e2e_test.go b/hooks/e2e_test.go new file mode 100644 index 0000000000000000000000000000000000000000..64bcd763fc1e7f67a5cf14743941c0027f756079 --- /dev/null +++ b/hooks/e2e_test.go @@ -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) + } +} diff --git a/hooks/env.go b/hooks/env.go new file mode 100644 index 0000000000000000000000000000000000000000..01101000fae7f18b1f0b355cc6ac51545bcc940e --- /dev/null +++ b/hooks/env.go @@ -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 "~", 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: /~/. +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 +} diff --git a/hooks/env_test.go b/hooks/env_test.go new file mode 100644 index 0000000000000000000000000000000000000000..35fadd242d2df7d70c3ad409a1228cb5176bfc2a --- /dev/null +++ b/hooks/env_test.go @@ -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") + } +} diff --git a/hooks/fixture_test.go b/hooks/fixture_test.go new file mode 100644 index 0000000000000000000000000000000000000000..af4459576730f6d378433547c5bdfcdb2f603a39 --- /dev/null +++ b/hooks/fixture_test.go @@ -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 /~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) +} diff --git a/hooks/hook.go b/hooks/hook.go new file mode 100644 index 0000000000000000000000000000000000000000..397bbfd02ff33040eef377bcca219a5871867ac8 --- /dev/null +++ b/hooks/hook.go @@ -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 " 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 , 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 " " 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 \" \"", 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") +} diff --git a/hooks/hook_test.go b/hooks/hook_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1464b1efa5eecb7cf931526d801435244fcc0c40 --- /dev/null +++ b/hooks/hook_test.go @@ -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(), " ") +} + +// 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(), " ") +} + +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") +} diff --git a/hooks/install.go b/hooks/install.go new file mode 100644 index 0000000000000000000000000000000000000000..2d05ac96dc4f7a7ac37be41d3ccc40238e98fa11 --- /dev/null +++ b/hooks/install.go @@ -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 +} diff --git a/hooks/install_test.go b/hooks/install_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0c005bdeabb8389f5578f747ebdf1956abbf4608 --- /dev/null +++ b/hooks/install_test.go @@ -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) + } + } +} diff --git a/hooks/proto.go b/hooks/proto.go new file mode 100644 index 0000000000000000000000000000000000000000..c80be0260f29d5dbaacfb2280ffe450ccea11b76 --- /dev/null +++ b/hooks/proto.go @@ -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 +} diff --git a/hooks/proto_test.go b/hooks/proto_test.go new file mode 100644 index 0000000000000000000000000000000000000000..be02ca6328cfe83dd24991968d0c9cb20d637095 --- /dev/null +++ b/hooks/proto_test.go @@ -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") +} diff --git a/hooks/server.go b/hooks/server.go new file mode 100644 index 0000000000000000000000000000000000000000..41b45160fb44b44fda5bcdbbb303dcefcbdfa093 --- /dev/null +++ b/hooks/server.go @@ -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/~/ 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() +} diff --git a/hooks/server_test.go b/hooks/server_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dd5307b9c8122a112b61acdb33b2b79e83648dda --- /dev/null +++ b/hooks/server_test.go @@ -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) + } +}