package service
import (
"context"
"encoding/hex"
"fmt"
"sort"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"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/gitx"
)
// PushRequest is one proposed ref update, as the `update` hook sees it and
// forwards it to the daemon over the localhost RPC.
//
// Object names are hex strings rather than plumbing.Hash so hooks/ can build
// this straight from the hook's argv without importing gitx, and so an
// unparseable value is a rejection rather than something that silently becomes
// the zero hash — which the refs rule would read as "creating a branch".
type PushRequest struct {
// Space is the space being pushed to.
Space core.SpaceRef
// Principal is who is pushing, as authn resolved the SSH key or token the
// forced command was invoked with. An anonymous principal is refused: there
// is no unauthenticated write path.
Principal authn.Principal
// Ref is the full ref name, "refs/heads/main".
Ref string
// Old is the value the ref currently holds. Empty or the all-zero object
// name means the ref is being created.
Old string
// New is the value proposed. Empty or the all-zero object name means the
// ref is being deleted.
New string
// SkipValidation carries `--push-option=skip-validation`.
//
// It waives frontmatter and document-id validation and nothing else. The
// refs rule is never skippable: the escape hatch exists so a hook bug or a
// bad schema cannot lock the owner out of their own repository, not so that
// an agent can reach the approved branch.
SkipValidation bool
}
// PushProblemKind classifies one reason a push was refused, so the API and the
// review UI can branch on it without parsing the message.
type PushProblemKind string
const (
// ProblemRefsRule is the refs rule refusing this principal this ref. Never
// skippable.
ProblemRefsRule PushProblemKind = "refs-rule"
// ProblemFrontmatter is a document whose frontmatter is missing,
// unparseable, or fails the space's schema.
ProblemFrontmatter PushProblemKind = "frontmatter"
// ProblemDuplicateID is two documents in the pushed tree carrying one id.
ProblemDuplicateID PushProblemKind = "duplicate-id"
// ProblemIDCollision is a document id already registered to another space.
ProblemIDCollision PushProblemKind = "id-collision"
)
// PushProblem is one reason a push was refused.
type PushProblem struct {
// Kind is the class of failure.
Kind PushProblemKind
// Path is the offending document, empty for a problem that is not about
// one document (the refs rule).
Path string
// Detail is one line naming what is wrong, written for the human reading
// their terminal after a rejected `git push`.
Detail string
}
// PushRejection is the structured refusal the `update` hook prints and exits
// non-zero on. It wraps ErrPushRejected, so callers match the class with
// errors.Is and type-assert only when they want the detail.
type PushRejection struct {
Space core.SpaceRef
Ref string
Problems []PushProblem
// Skippable reports whether `--push-option=skip-validation` would let this
// push through. It is false whenever any problem is a refs-rule violation,
// and the message says so rather than suggesting a flag that will not help.
Skippable bool
}
func (e *PushRejection) Is(target error) bool { return target == ErrPushRejected }
// Error renders the rejection as the message a human sees on their terminal
// after `git push`. Every line is short enough to survive git's "remote: "
// prefix in an 80-column terminal, and the offending document is always named
// first, because "which file" is the first thing anybody wants to know.
func (e *PushRejection) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n")
fmt.Fprintf(&b, " space: %s\n", e.Space)
fmt.Fprintf(&b, " ref: %s\n\n", e.Ref)
for _, p := range e.Problems {
if p.Path != "" {
fmt.Fprintf(&b, " %s\n %s\n", p.Path, p.Detail)
continue
}
fmt.Fprintf(&b, " %s\n", p.Detail)
}
fmt.Fprintf(&b, "\n%s. Nothing was written; the ref still points where it did.\n",
plural(len(e.Problems), "problem"))
if e.Skippable {
b.WriteString("Re-push with --push-option=skip-validation to bypass frontmatter\n")
b.WriteString("and document-id validation.\n")
} else {
b.WriteString("The refs rule cannot be bypassed: --push-option=skip-validation\n")
b.WriteString("waives frontmatter and document-id validation only.\n")
}
return b.String()
}
func plural(n int, what string) string {
if n == 1 {
return fmt.Sprintf("1 %s", what)
}
return fmt.Sprintf("%d %ss", n, what)
}
// ValidatePush is what the `update` hook calls, per ref, before the ref moves.
//
// It answers in two parts, and the split is the design's:
//
// 1. The refs rule — may this principal move this ref? Always checked, never
// skippable, and checked first so that a rejection for the right reason is
// not preceded by pages of schema complaints.
// 2. Frontmatter and document-id validation of everything this push changes.
// Waived by SkipValidation, because a hook bug or a bad schema must never
// be able to lock the owner out of their own repository.
//
// A nil return means the push may proceed. A *PushRejection means it must not,
// and its Error() is the text to print. Any other error is an infrastructure
// failure — Postgres down, repository unreadable — and the hook must fail
// closed on it: a rejected push is recoverable in one command, while a silently
// unvalidated one is a corruption discovered much later.
func (s *Service) ValidatePush(ctx context.Context, req PushRequest) error {
sp, err := s.OpenSpace(ctx, req.Space)
if err != nil {
return err
}
oldHash, err := parseObjectName("old", req.Old)
if err != nil {
return err
}
newHash, err := parseObjectName("new", req.New)
if err != nil {
return err
}
if problem := s.checkRefsRule(ctx, sp, req, oldHash, newHash); problem != nil {
return &PushRejection{
Space: req.Space,
Ref: req.Ref,
Problems: []PushProblem{*problem},
Skippable: false,
}
}
// A deletion leaves no tree to validate, and skip-validation waives
// everything that is left. Both still went through the refs rule above.
if newHash.IsZero() || req.SkipValidation {
return nil
}
problems, err := s.validateContent(ctx, sp, oldHash, newHash)
if err != nil {
return err
}
if len(problems) > 0 {
return &PushRejection{
Space: req.Space,
Ref: req.Ref,
Problems: problems,
Skippable: true,
}
}
return nil
}
// checkRefsRule applies gitx.CheckRefUpdate, computing the fast-forward fact it
// cannot compute itself. It returns nil when the update is permitted.
func (s *Service) checkRefsRule(ctx context.Context, sp *Space, req PushRequest, old, new plumbing.Hash) *PushProblem {
kind, err := principalKind(req.Principal)
if err != nil {
return &PushProblem{Kind: ProblemRefsRule, Detail: err.Error()}
}
// Ancestry is only meaningful when both ends name a commit. A creation has
// no old value and a deletion has no new one; gitx treats a creation as a
// fast-forward and ignores the flag entirely for a deletion.
fastForward := old.IsZero()
if !old.IsZero() && !new.IsZero() {
ff, err := sp.Repo.IsAncestor(ctx, old, new)
if err != nil {
// Not knowing whether this is a fast-forward is not permission to
// assume it is: an unreadable object must refuse the push, not
// wave through a force-update of the approved branch.
return &PushProblem{
Kind: ProblemRefsRule,
Detail: fmt.Sprintf("cannot determine whether %s..%s is a fast-forward: %v", old, new, err),
}
}
fastForward = ff
}
err = gitx.CheckRefUpdate(kind, sp.ApprovedBranch(), gitx.RefUpdate{
Ref: req.Ref,
Old: old,
New: new,
FastForward: fastForward,
})
if err != nil {
return &PushProblem{Kind: ProblemRefsRule, Detail: err.Error()}
}
return nil
}
// validateContent validates the frontmatter of every document this push changes
// and checks document-id uniqueness, both within the pushed tree and against
// the global registry.
func (s *Service) validateContent(ctx context.Context, sp *Space, old, new plumbing.Hash) ([]PushProblem, error) {
all, changed, err := s.changedDocuments(ctx, sp, old, new)
if err != nil {
return nil, err
}
// The schema is read at the *new* revision, so a push that edits .spec.yml
// is validated against the policy it is installing. Validating against the
// old one would make a schema change and the documents that satisfy it
// impossible to land in a single push.
policy, err := s.Policy(ctx, sp, new.String())
if err != nil {
return nil, err
}
problems, refs := validateDocuments(all, changed, policy.Schema)
collisions, err := s.store.CheckDocIDCollisions(ctx, sp.ID, refs)
if err != nil {
return nil, fmt.Errorf("service: check document id collisions for %s: %w", sp.Ref, err)
}
byID := make(map[string]string, len(refs))
for _, r := range refs {
byID[r.ID.String()] = r.Path
}
for _, c := range collisions {
owner, err := s.store.GetSpaceByID(ctx, c.Existing.SpaceID)
if err != nil {
return nil, fmt.Errorf("service: resolve space %d holding document id %s: %w",
c.Existing.SpaceID, c.DocID, err)
}
problems = append(problems, PushProblem{
Kind: ProblemIDCollision,
Path: byID[c.DocID.String()],
Detail: fmt.Sprintf("id %s is already registered to %s at %s",
c.DocID, owner.Ref, c.Existing.Path),
})
}
sort.SliceStable(problems, func(i, j int) bool { return problems[i].Path < problems[j].Path })
return problems, nil
}
// changedDocuments returns every document at the new revision, and the subset
// of them this push changes.
//
// The baseline is the ref's old value, or — when the ref is being created — the
// space's approved head. A brand-new proposal branch is cut from the approved
// branch, so comparing it against nothing would revalidate the entire space and
// let one document that was pushed with --push-option=skip-validation block
// every future proposal branch.
//
// Only the changed subset is schema-validated, for the same reason. Malformed
// documents already on a branch are tolerated rather than fatal: a single typo
// must not become an outage that blocks every later push.
func (s *Service) changedDocuments(ctx context.Context, sp *Space, old, new plumbing.Hash) (all, changed []Document, err error) {
all, err = s.ListDocuments(ctx, sp, new.String())
if err != nil {
return nil, nil, err
}
baseline := old.String()
if old.IsZero() {
baseline = ApprovedRev
}
before, err := s.ListDocuments(ctx, sp, baseline)
if err != nil {
return nil, nil, err
}
prior := make(map[string]string, len(before))
for _, d := range before {
prior[d.Path] = d.Blob
}
for _, d := range all {
if prior[d.Path] != d.Blob {
changed = append(changed, d)
}
}
return all, changed, nil
}
// validateDocuments is the whole of push validation that needs neither git nor
// Postgres: schema conformance of the changed documents, and id uniqueness
// within the pushed tree. It returns the problems it found and the (id, path)
// refs of the changed documents, which is what the registry check runs against.
//
// A duplicate id is reported only when at least one of the documents carrying
// it is part of this push. Two colliding documents that were both already there
// are somebody's earlier skip-validation typo; rejecting every subsequent push
// until they are fixed would turn a cosmetic error into a lockout, and the fix
// itself would be unpushable.
func validateDocuments(all, changed []Document, schema core.Schema) ([]PushProblem, []db.DocRef) {
byID := make(map[string][]string)
for _, d := range all {
fm, _, err := core.ParseDocument(d.Data)
if err != nil {
continue // reported below if this document is part of the push
}
if id, err := core.ParseDocID(fm.ID); err == nil {
byID[id.String()] = append(byID[id.String()], d.Path)
}
}
var problems []PushProblem
var refs []db.DocRef
for _, d := range changed {
fm, _, err := core.ParseDocument(d.Data)
if err != nil {
problems = append(problems, PushProblem{
Kind: ProblemFrontmatter,
Path: d.Path,
Detail: err.Error(),
})
continue
}
if err := schema.ValidateFrontmatter(fm); err != nil {
problems = append(problems, PushProblem{
Kind: ProblemFrontmatter,
Path: d.Path,
Detail: err.Error(),
})
continue
}
id, err := core.ParseDocID(fm.ID)
if err != nil {
// Reachable only when the space's schema does not require `id`.
// Such a document is unregistrable but not malformed, so it is not
// a problem — it simply contributes nothing to the registry.
continue
}
if others := without(byID[id.String()], d.Path); len(others) > 0 {
problems = append(problems, PushProblem{
Kind: ProblemDuplicateID,
Path: d.Path,
Detail: fmt.Sprintf("id %s is also carried by %s",
id, strings.Join(others, ", ")),
})
}
refs = append(refs, db.DocRef{ID: id, Path: d.Path})
}
sort.SliceStable(problems, func(i, j int) bool { return problems[i].Path < problems[j].Path })
return problems, refs
}
// without returns paths with one occurrence of self removed.
func without(paths []string, self string) []string {
out := make([]string, 0, len(paths))
dropped := false
for _, p := range paths {
if p == self && !dropped {
dropped = true
continue
}
out = append(out, p)
}
if len(out) == 0 {
return nil
}
return out
}
// principalKind maps a resolved identity onto the two principals the refs rule
// knows about. An anonymous principal is refused rather than mapped to either:
// there is no unauthenticated write path, and defaulting it to "agent" would
// give an unidentified pusher the proposal namespace.
func principalKind(p authn.Principal) (gitx.PrincipalKind, error) {
switch {
case p.IsOwner():
return gitx.PrincipalHuman, nil
case p.IsAgent():
return gitx.PrincipalAgent, nil
default:
return "", fmt.Errorf("no credential identifies this push; %s may not write any ref", p)
}
}
// zeroObjectName is git's "this ref does not exist" sentinel as the hook spells
// it on the command line.
const zeroObjectName = "0000000000000000000000000000000000000000"
// parseObjectName converts a hook's hex argument into an object name. The empty
// string and the all-zero name both mean "absent".
//
// plumbing.NewHash is deliberately not used: it maps anything unparseable to
// the zero hash, which the refs rule would read as a branch creation or a
// deletion. A malformed argument is a bug in whatever built the request, and it
// fails here rather than becoming a permitted force-push.
func parseObjectName(which, s string) (plumbing.Hash, error) {
if s == "" || s == zeroObjectName {
return plumbing.ZeroHash, nil
}
if len(s) != len(zeroObjectName) {
return plumbing.ZeroHash, fmt.Errorf("service: %s object name %q is not %d hex digits",
which, s, len(zeroObjectName))
}
if _, err := hex.DecodeString(s); err != nil {
return plumbing.ZeroHash, fmt.Errorf("service: %s object name %q is not hex: %w", which, s, err)
}
return plumbing.NewHash(s), nil
}