package hooks
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"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 — and the tokens.sr.ht plane an agent's
// credential is validated on. It is the same resolver the HTTP surfaces
// authenticate through, which is the point: one plane, one implementation of
// "is this credential good?".
Resolver() *authn.Resolver
// ValidatePush answers whether one ref may move. A *service.PushRejection
// is a policy refusal whose Error() is the text to print; anything else is
// an infrastructure failure and must fail the push closed.
ValidatePush(ctx context.Context, req service.PushRequest) error
}
// PushNotifier is what the daemon does when a push lands: reindex the changed
// documents and advance the space's index rev stamp.
//
// It is injected rather than being a Backend method because Phase 1 has no
// indexer — bleve and the index rev stamp belong to Phase 2 — and a stamp
// advanced without a reindex behind it would be a lie the reconciler could not
// detect. Until then the daemon supplies a notifier that records the push and
// nothing else, and the reconciler reports the staleness.
type PushNotifier func(ctx context.Context, space core.SpaceRef, updates []RefUpdate) error
// Options configures a Server.
type Options struct {
// Backend and Socket are required.
Backend Backend
Socket string
// OnPush is called for each landed push. Required: a server with no
// notifier would accept post-receive calls and drop them, which reads
// exactly like a working index that never updates.
OnPush PushNotifier
// Log defaults to slog.Default().
Log *slog.Logger
// Timeout bounds handling one request. Zero means DefaultTimeout.
Timeout time.Duration
// OptionTTL is how long a push's recorded options survive without the
// matching update calls. Zero means DefaultOptionTTL.
OptionTTL time.Duration
}
const (
// DefaultOptionTTL is how long the daemon remembers a push's options. The
// gap between pre-receive and the last update of one push is milliseconds;
// this is three orders of magnitude of slack, and the only cost of an
// entry outliving its push is a few hundred bytes until it is swept.
DefaultOptionTTL = 10 * time.Minute
// maxPendingPushes caps the option table. Any local process can connect to
// the socket, so the table must not be a way to exhaust memory; at the
// documented volume — one human, tens of documents a day — a thousand
// pushes in flight at once means something is wrong, and failing closed is
// the right answer to that.
maxPendingPushes = 1024
// socketDirMode keeps the socket's directory private to the service user.
// The socket is an unauthenticated write path to the daemon: anyone who
// can connect can assert the owner principal.
socketDirMode = 0o700
socketMode = 0o600
)
// Server is the daemon side of the receive path: a unix socket the hooks call.
//
// The socket is unix-domain and not a TCP port on localhost, for one reason
// that decides it: filesystem permissions. An "owner" credential is an
// assertion, so the ability to connect is the ability to push as the owner —
// a 0700 directory holding a 0600 socket makes that reachable only by the
// service user, which no localhost TCP port can do.
type Server struct {
backend Backend
socket string
log *slog.Logger
onPush PushNotifier
timeout time.Duration
optionTTL time.Duration
listener net.Listener
wg sync.WaitGroup
// closing distinguishes a listener we shut down from one that failed. Both
// surface as net.ErrClosed out of Accept, and only one of them is an error.
closing atomic.Bool
mu sync.Mutex
pending map[string]*pendingPush
}
// pendingPush is what pre-receive told us about a push, waiting for the update
// calls it belongs to.
type pendingPush struct {
updates []RefUpdate
skip bool
expires time.Time
}
// NewServer builds a server. It does not listen; call Listen.
func NewServer(opts Options) (*Server, error) {
if opts.Backend == nil {
return nil, errors.New("hooks: no backend")
}
if opts.Socket == "" {
return nil, errors.New("hooks: no socket path")
}
if !filepath.IsAbs(opts.Socket) {
return nil, fmt.Errorf("hooks: socket path %q is not absolute", opts.Socket)
}
if opts.OnPush == nil {
return nil, errors.New("hooks: no push notifier")
}
if opts.Backend.ReposRoot() == "" {
return nil, errors.New("hooks: backend has no repos root")
}
log := opts.Log
if log == nil {
log = slog.Default()
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
ttl := opts.OptionTTL
if ttl <= 0 {
ttl = DefaultOptionTTL
}
return &Server{
backend: opts.Backend,
socket: opts.Socket,
log: log,
onPush: opts.OnPush,
timeout: timeout,
optionTTL: ttl,
pending: make(map[string]*pendingPush),
}, nil
}
// Socket is the path this server listens on.
func (s *Server) Socket() string { return s.socket }
// Listen binds the socket.
//
// A leftover socket file from a crashed daemon is removed, but only after
// proving it is dead: if something answers on it, another daemon is running
// and this one refuses to start rather than stealing the push path from it.
func (s *Server) Listen() error {
if err := os.MkdirAll(filepath.Dir(s.socket), socketDirMode); err != nil {
return fmt.Errorf("hooks: create %s: %w", filepath.Dir(s.socket), err)
}
// The directory may pre-date this version, or have been created with a
// looser umask; make it private either way.
if err := os.Chmod(filepath.Dir(s.socket), socketDirMode); err != nil {
return fmt.Errorf("hooks: restrict %s: %w", filepath.Dir(s.socket), err)
}
if _, err := os.Stat(s.socket); err == nil {
conn, dialErr := net.DialTimeout("unix", s.socket, time.Second)
if dialErr == nil {
conn.Close()
return fmt.Errorf("hooks: %s is already served by another process", s.socket)
}
if err := os.Remove(s.socket); err != nil {
return fmt.Errorf("hooks: remove the stale socket %s: %w", s.socket, err)
}
s.log.Warn("removed a stale hook socket", "socket", s.socket, "dial_error", dialErr)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("hooks: stat %s: %w", s.socket, err)
}
ln, err := net.Listen("unix", s.socket)
if err != nil {
return fmt.Errorf("hooks: listen on %s: %w", s.socket, err)
}
if err := os.Chmod(s.socket, socketMode); err != nil {
ln.Close()
return fmt.Errorf("hooks: restrict %s: %w", s.socket, err)
}
s.listener = ln
return nil
}
// Serve accepts hook connections until ctx is cancelled, then waits for the
// calls already in flight. Listen must have succeeded first.
func (s *Server) Serve(ctx context.Context) error {
if s.listener == nil {
return errors.New("hooks: Serve called before Listen")
}
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
s.listener.Close()
case <-done:
}
}()
for {
conn, err := s.listener.Accept()
if err != nil {
s.wg.Wait()
if ctx.Err() != nil || s.closing.Load() {
return nil
}
return fmt.Errorf("hooks: accept on %s: %w", s.socket, err)
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.serveConn(ctx, conn)
}()
}
}
// Close stops listening and removes the socket. It is idempotent, and safe to
// call after Serve has already returned: a daemon shuts down by cancelling
// Serve's context and then calling this, and a daemon that failed to start
// after Listen calls only this.
func (s *Server) Close() error {
if s.listener == nil {
return nil
}
s.closing.Store(true)
err := s.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
s.wg.Wait()
// net's unix listener unlinks the socket itself; removing it again is
// tolerated so a listener built elsewhere is still cleaned up.
if rmErr := os.Remove(s.socket); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
if err == nil {
err = fmt.Errorf("hooks: remove %s: %w", s.socket, rmErr)
}
}
return err
}
func (s *Server) serveConn(ctx context.Context, conn net.Conn) {
defer conn.Close()
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
if deadline, ok := ctx.Deadline(); ok {
// The context bounds the handler, not the socket reads; without a
// deadline on the connection a peer that connects and says nothing
// holds a goroutine forever.
if err := conn.SetDeadline(deadline); err != nil {
s.log.Error("could not bound a hook connection", "socket", s.socket, "error", err)
return
}
}
req, err := ReadRequest(conn)
if err != nil {
s.log.Error("unreadable hook request", "socket", s.socket, "error", err)
// The peer may be something that is not a hook at all; answer anyway,
// so a hook that sent a message we could not parse still gets a
// refusal rather than a closed connection it has to interpret.
s.reply(conn, errorResponse("the daemon could not read the request: %v", err))
return
}
resp := s.handle(ctx, req)
s.reply(conn, resp)
}
func (s *Server) reply(conn net.Conn, resp Response) {
if err := WriteResponse(conn, resp); err != nil {
s.log.Error("could not answer a hook", "socket", s.socket, "error", err)
}
}
// handle answers one request. It never panics a connection into the pusher's
// terminal: every failure becomes a Response the hook knows how to print.
func (s *Server) handle(ctx context.Context, req Request) Response {
if err := req.Validate(); err != nil {
s.log.Error("malformed hook request", "method", req.Method, "error", err)
return errorResponse("malformed %s request: %v", req.Method, err)
}
space, err := s.spaceFor(req.Repo)
if err != nil {
s.log.Error("hook named a repository we do not own", "repo", req.Repo, "error", err)
return errorResponse("%v", err)
}
principal, resp, ok := s.principal(ctx, space, req.Credential)
if !ok {
return resp
}
log := s.log.With(
"space", space.String(),
"principal", principal.String(),
"push", req.Push,
"method", string(req.Method),
)
switch req.Method {
case MethodPushOptions:
return s.handlePushOptions(req, space, log)
case MethodValidateRef:
return s.handleValidateRef(ctx, req, space, principal, log)
case MethodPushed:
return s.handlePushed(ctx, req, space, log)
default:
// Request.Validate already rejected anything else.
return errorResponse("unhandled method %q", req.Method)
}
}
// handlePushOptions records what pre-receive saw, and refuses a push option
// this service does not understand.
func (s *Server) handlePushOptions(req Request, space core.SpaceRef, log *slog.Logger) Response {
if unknown := UnknownOptions(req.Options); len(unknown) > 0 {
log.Info("refused unknown push options", "options", unknown)
return rejectedResponse(unknownOptionMessage(space, unknown))
}
skip := SkipValidation(req.Options)
if err := s.remember(req, skip); err != nil {
log.Error("could not record push options", "error", err)
return errorResponse("%v", err)
}
log.Info("push received", "refs", len(req.Updates), "skip_validation", skip)
return okResponse()
}
// handleValidateRef is the whole of the rejecting path: the refs rule, then
// frontmatter and document-id validation, both inside service.ValidatePush so
// the push path and the API cannot drift apart.
func (s *Server) handleValidateRef(ctx context.Context, req Request, space core.SpaceRef,
principal authn.Principal, log *slog.Logger) Response {
update := req.Updates[0]
skip, err := s.recall(req, update)
if err != nil {
log.Error("no recorded pre-receive phase for this push", "ref", update.Ref, "error", err)
return errorResponse("%v", err)
}
err = s.backend.ValidatePush(ctx, service.PushRequest{
Space: space,
Principal: principal,
Ref: update.Ref,
Old: update.Old,
New: update.New,
SkipValidation: skip,
})
var rejection *service.PushRejection
switch {
case err == nil:
log.Info("ref accepted", "ref", update.Ref, "skip_validation", skip)
return okResponse()
case errors.As(err, &rejection):
log.Info("ref rejected", "ref", update.Ref, "problems", len(rejection.Problems),
"skippable", rejection.Skippable)
return rejectedResponse(rejection.Error())
default:
// Not a policy answer: Postgres down, an unreadable object, a space
// with no row. The hook fails the push closed on it.
log.Error("could not validate a ref", "ref", update.Ref, "error", err)
return errorResponse("spec.sr.ht could not validate %s: %v", update.Ref, err)
}
}
// handlePushed notifies the daemon that refs moved. Its answer cannot stop
// anything — git ignores post-receive's exit status — but it is still reported
// honestly so the hook can warn that the index is stale.
func (s *Server) handlePushed(ctx context.Context, req Request, space core.SpaceRef, log *slog.Logger) Response {
s.forget(req)
if err := s.onPush(ctx, space, req.Updates); err != nil {
log.Error("could not record a landed push", "refs", len(req.Updates), "error", err)
return errorResponse("%v", err)
}
log.Info("push landed", "refs", len(req.Updates))
return okResponse()
}
// spaceFor turns the repository a hook is running in into a space.
//
// The hook's claim is never taken at face value: the path is matched against
// this daemon's own repos root and then re-derived through gitx.DiskPath, so
// the only paths that resolve are the ones this daemon would itself have
// created. gitx is the single source of truth for that layout — deriving it
// twice is how the two copies drift.
func (s *Server) spaceFor(repo string) (core.SpaceRef, error) {
root, err := filepath.EvalSymlinks(s.backend.ReposRoot())
if err != nil {
return core.SpaceRef{}, fmt.Errorf("the repos root %s is unreadable: %w",
s.backend.ReposRoot(), err)
}
root, err = filepath.Abs(root)
if err != nil {
return core.SpaceRef{}, fmt.Errorf("the repos root %s is unresolvable: %w",
s.backend.ReposRoot(), err)
}
clean := filepath.Clean(repo)
rel, err := filepath.Rel(root, clean)
if err != nil {
return core.SpaceRef{}, fmt.Errorf("%s is not under the repos root %s", repo, root)
}
segs := strings.Split(rel, string(filepath.Separator))
if len(segs) != 2 || segs[0] == ".." || !strings.HasPrefix(segs[0], "~") {
return core.SpaceRef{}, fmt.Errorf("%s is not a space repository; "+
"this daemon serves %s/~<owner>/<space> only", repo, root)
}
ref, err := core.ParseSpaceRef(rel)
if err != nil {
return core.SpaceRef{}, fmt.Errorf("%s does not name a valid space: %w", repo, err)
}
if want := gitx.DiskPath(root, ref); want != clean {
return core.SpaceRef{}, fmt.Errorf("%s does not name a space; %s would live at %s",
repo, ref, want)
}
return ref, nil
}
// principal resolves the credential a hook forwarded.
//
// The owner is not looked up: sshd authenticated the SSH key and the forced
// command asserted it, and there is exactly one owner on this instance, so the
// name comes from the resolver rather than from the wire — a hook cannot name
// somebody else. An agent's credential is checked on every push.
//
// An agent goes through authn.Resolver.ResolveAgent, the same call the HTTP
// surfaces reach through their middleware. This path used to read the
// agent_token table directly, which is exactly how it ended up accepting a
// credential the HTTP planes had already stopped being the only door for: two
// implementations of one question, drifting. There is one credential plane now
// and one implementation of checking it.
//
// The grant check is here rather than in ValidatePush because it is about the
// credential and not about the ref: a push by an agent is a proposal by another
// transport, so it needs spec:propose exactly as the REST and MCP write planes
// do. The refs rule still runs afterwards, inside ValidatePush, and still
// confines the agent to proposals/* — a grant does not replace it and cannot
// widen it.
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:
p, err := s.backend.Resolver().ResolveAgent(ctx, cred.Token, cred.Agent, cred.Session)
if err != nil {
// StatusFor is the one table: a bad or foreign credential (401) and
// a good one this instance has nothing to grant (403) are policy
// refusals the pusher can read and act on; anything else means we
// could not check, and an unanswerable check fails the push closed
// rather than reading as a bad token.
if status := authn.StatusFor(err); status < http.StatusInternalServerError {
s.log.Warn("refused an agent push", "space", space.String(), "error", err)
return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false
}
s.log.Error("could not validate an agent credential", "space", space.String(), "error", err)
return authn.Principal{}, errorResponse(
"spec.sr.ht could not check the credential presented with this push: %v", err), false
}
if err := p.Authorize(authn.ActionPropose); err != nil {
s.log.Warn("refused an agent push", "space", space.String(), "error", err)
return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false
}
return p, 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 credential does not
// authenticate or does not carry spec:propose. 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 credential presented with this push was refused:\n")
fmt.Fprintf(&b, " %v\n\n", cause)
fmt.Fprintf(&b, "Nothing was written. Agent credentials are tokens.sr.ht working tokens,\n")
fmt.Fprintf(&b, "the same ones the REST and MCP planes take, and a push needs the\n")
fmt.Fprintf(&b, "spec:propose grant just as those do.\n")
return b.String()
}