From c7e4f9f91da3c012cd45e4b95a559c53874b5be3 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 22 Jul 2026 14:43:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20hooks=20=E2=80=94=20the=20receive=20pat?= =?UTF-8?q?h,=20its=20daemon=20RPC,=20and=20the=20specsrht=20daemon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hooks are thin shims that RPC into the running daemon over a unix socket under the repos root, so validation lives in one place and the push path cannot drift from the API. They fail closed: an unreachable daemon rejects the push. Three hooks are installed, not two, because of two properties of git verified against 2.55 rather than inferred: - GIT_PUSH_OPTION_* reaches pre-receive and post-receive only; the update hook observably runs without them, so it cannot see --push-option=skip-validation on its own. - during pre-receive the pushed objects are still in receive-pack's quarantine and unreadable by any other process, so the daemon cannot validate there. git migrates them out before the first update hook, which makes update the earliest hook that works. pre-receive therefore forwards the push options and the ref list; the daemon records them keyed by (repository, receive-pack pid) and by the exact ref update; update does the rejecting; post-receive notifies. An update with no recorded pre-receive phase is refused rather than assumed unskippable. Each hook is a symlink to the specsrht binary, which dispatches on the name git invoked it as. Install also sets receive.advertisePushOptions, without which the escape hatch fails client-side. The daemon refreshes every space's hooks at startup and treats a failure as fatal. cmd/specsrht validates every config key at once before opening anything, serves /healthz on -b (default localhost:5091), runs the reconciler, and warm-shuts-down on SIGINT and SIGTERM alike by bridging SIGTERM into the SIGINT core-go's server.Run waits for. Tested with a real git push against a real bare repo with the hooks installed: a valid push lands, bad frontmatter is rejected with a readable message, skip-validation waives it, a force-push to the approved branch is refused with or without skip-validation, a mistyped push option is refused rather than ignored, and a push with no daemon is rejected. --- cmd/specsrht/main.go | 410 ++++++++++++++++++++++++++ cmd/specsrht/main_test.go | 169 +++++++++++ hooks/client.go | 90 ++++++ hooks/doc.go | 117 ++++++++ hooks/e2e_test.go | 331 +++++++++++++++++++++ hooks/env.go | 211 +++++++++++++ hooks/env_test.go | 252 ++++++++++++++++ hooks/fixture_test.go | 237 +++++++++++++++ hooks/hook.go | 385 ++++++++++++++++++++++++ hooks/hook_test.go | 278 ++++++++++++++++++ hooks/install.go | 155 ++++++++++ hooks/install_test.go | 159 ++++++++++ hooks/proto.go | 293 +++++++++++++++++++ hooks/proto_test.go | 157 ++++++++++ hooks/server.go | 602 ++++++++++++++++++++++++++++++++++++++ hooks/server_test.go | 491 +++++++++++++++++++++++++++++++ 16 files changed, 4337 insertions(+) create mode 100644 cmd/specsrht/main.go create mode 100644 cmd/specsrht/main_test.go create mode 100644 hooks/client.go create mode 100644 hooks/doc.go create mode 100644 hooks/e2e_test.go create mode 100644 hooks/env.go create mode 100644 hooks/env_test.go create mode 100644 hooks/fixture_test.go create mode 100644 hooks/hook.go create mode 100644 hooks/hook_test.go create mode 100644 hooks/install.go create mode 100644 hooks/install_test.go create mode 100644 hooks/proto.go create mode 100644 hooks/proto_test.go create mode 100644 hooks/server.go create mode 100644 hooks/server_test.go 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) + } +}