package gitx
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
"github.com/go-git/go-git/v5/plumbing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// ProposalPrefix is the namespace agents may write and nothing else. It is
// core's constant under this package's name: the prefix a ref is checked
// against here and the prefix a proposal row's branch is built from are one
// value, or the two disagree the day one of them is edited.
const ProposalPrefix = core.ProposalPrefix
// branchRefPrefix is the only ref namespace a space repository uses. Tags and
// notes are not part of the model, and a branch outside these two namespaces
// would be invisible to the reader, the reconciler and the index.
const branchRefPrefix = "refs/heads/"
// maxRefLen caps a ref name. Well under any filesystem limit; the point is to
// keep a pathological name out of the hook's error message and the loose-ref
// directory, not to be permissive.
const maxRefLen = 255
// PrincipalKind is who is pushing. There is exactly one human on this instance
// and many agents, so this is the whole of the identity the refs rule needs:
// the boundary that matters is not human-versus-human, it is what an agent may
// move.
type PrincipalKind string
const (
// PrincipalHuman is the owner, pushing over SSH through receive-pack. Their
// push is the approval — there is nobody to review it.
PrincipalHuman PrincipalKind = "human"
// PrincipalAgent is any agent token. One token or many, the constraint is
// the same and it is the one that bounds the damage a runaway agent can do.
PrincipalAgent PrincipalKind = "agent"
)
// ParsePrincipalKind validates a principal kind arriving from a hook
// environment or a token row.
func ParsePrincipalKind(s string) (PrincipalKind, error) {
switch PrincipalKind(s) {
case PrincipalHuman, PrincipalAgent:
return PrincipalKind(s), nil
}
return "", fmt.Errorf("%w: principal %q is not one of human|agent", ErrRefRejected, s)
}
// RefUpdate is one proposed ref move, as the update hook sees it.
type RefUpdate struct {
// Ref is the full ref name, e.g. "refs/heads/main".
Ref string
// Old is the value the ref currently holds; zero means the ref is being
// created.
Old plumbing.Hash
// New is the value proposed; zero means the ref is being deleted.
New plumbing.Hash
// FastForward reports whether New is reachable from Old. The caller must
// compute it — Old.IsZero() || Repo.IsAncestor(ctx, Old, New) — because
// ancestry needs the object database and CheckRefUpdate is a pure function
// so that it can be exhaustively tested. It is ignored for deletions.
FastForward bool
}
// CheckRefUpdate is the refs rule: may this principal move this ref?
//
// The human pushes to the approved branch. Agents may only write proposal
// branches.
//
// Concretely:
//
// - Only branches exist. A tag, a note or any other ref namespace is refused
// for both principals, because nothing in the model reads one and a ref the
// reader and reconciler do not know about is a place for content to rot.
// - The approved branch: the human only, fast-forward only, never deleted.
// A force-update is refused even from the owner — it would orphan every
// proposal's recorded base and silently rewrite approved text.
// - proposals/*: either principal, any update including a force-update or a
// delete. A proposal branch is scratch space; nothing reads it as canonical
// and rewriting one is how an agent revises its own work.
//
// It is a pure function of its arguments so hooks/ can call it without a
// repository and so every combination can be tested. A nil error means the
// update is permitted; every rejection wraps ErrRefRejected with a message fit
// to send back to the pushing client.
func CheckRefUpdate(principal PrincipalKind, approvedBranch string, u RefUpdate) error {
if _, err := ParsePrincipalKind(string(principal)); err != nil {
return err
}
if err := ValidateBranch(approvedBranch); err != nil {
return fmt.Errorf("%w: approved branch %q is unusable: %v", ErrRefRejected, approvedBranch, err)
}
if err := validateRefName(u.Ref); err != nil {
return fmt.Errorf("%w: %v", ErrRefRejected, err)
}
if u.Old.IsZero() && u.New.IsZero() {
return fmt.Errorf("%w: %s: update moves nothing (old and new are both zero)", ErrRefRejected, u.Ref)
}
if !strings.HasPrefix(u.Ref, branchRefPrefix) {
return fmt.Errorf("%w: %s: only branches under %s may be updated in a space",
ErrRefRejected, u.Ref, branchRefPrefix)
}
branch := strings.TrimPrefix(u.Ref, branchRefPrefix)
if err := ValidateBranch(branch); err != nil {
return fmt.Errorf("%w: %v", ErrRefRejected, err)
}
switch {
case branch == approvedBranch:
if principal != PrincipalHuman {
return fmt.Errorf("%w: %s: an agent may only write %s*, not the approved branch",
ErrRefRejected, u.Ref, ProposalPrefix)
}
if u.New.IsZero() {
return fmt.Errorf("%w: %s: the approved branch may not be deleted", ErrRefRejected, u.Ref)
}
if !u.FastForward {
return fmt.Errorf("%w: %s: the approved branch takes fast-forwards only, not a force-update",
ErrRefRejected, u.Ref)
}
return nil
case IsProposalBranch(branch):
return nil
default:
return fmt.Errorf("%w: %s: a space carries the approved branch %q and %s* and nothing else",
ErrRefRejected, u.Ref, approvedBranch, ProposalPrefix)
}
}
// IsProposalBranch reports whether a short branch name is in the proposal
// namespace. The bare name "proposals" is not: it is the namespace itself, and
// a branch by that name would block every proposal branch under it.
func IsProposalBranch(branch string) bool {
if !strings.HasPrefix(branch, ProposalPrefix) {
return false
}
if ValidateBranch(branch) != nil {
return false
}
return strings.TrimPrefix(branch, ProposalPrefix) != ""
}
// ProposalBranch is the branch name for a proposal id, "proposals/42".
//
// The derivation is core's, so a branch cut here and a branch recorded on the
// proposal row cannot drift apart. What this wrapper adds is the failure class
// gitx callers branch on: an id that names no proposal is a bad revision here,
// exactly like a malformed ref, and core's ErrInvalidProposalID stays in the
// chain for a caller that wants to tell the two apart.
func ProposalBranch(id int64) (string, error) {
branch, err := core.ProposalBranch(id)
if err != nil {
return "", fmt.Errorf("%w: %w", ErrBadRev, err)
}
return branch, nil
}
// ParseProposalBranch recovers the proposal id from a branch name produced by
// ProposalBranch. A proposal branch with a non-numeric suffix is valid as a ref
// but carries no id, so ok is false rather than the id being guessed.
func ParseProposalBranch(branch string) (int64, bool) {
if !IsProposalBranch(branch) {
return 0, false
}
id, err := strconv.ParseInt(strings.TrimPrefix(branch, ProposalPrefix), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
// ValidateBranch checks a short branch name ("main", "proposals/42").
func ValidateBranch(branch string) error {
if err := validateRefComponent("branch", branch); err != nil {
return err
}
// Rejecting the full-ref spelling here is what stops "refs/heads/main" from
// being accepted as a branch and expanding to refs/heads/refs/heads/main.
if strings.HasPrefix(branch, "refs/") {
return fmt.Errorf("%w: branch %q must be a short name, not a full ref", ErrBadRev, branch)
}
return validateRefName(branchRefPrefix + branch)
}
// validateRefName applies git's ref-name rules to a full ref, plus a length cap
// and a UTF-8 check that git-check-ref-format does not make.
func validateRefName(ref string) error {
if err := validateRefComponent("ref", ref); err != nil {
return err
}
if !strings.HasPrefix(ref, "refs/") {
return fmt.Errorf("%w: ref %q must start with \"refs/\"", ErrBadRev, ref)
}
if err := plumbing.ReferenceName(ref).Validate(); err != nil {
return fmt.Errorf("%w: ref %q: %v", ErrBadRev, ref, err)
}
return nil
}
// validateRefComponent holds the checks shared by revisions, branches and full
// refs: length, UTF-8, no control characters, no traversal, and none of the
// bytes that make a name mean something else to a shell, to git's revision
// parser, or to a reader looking at a review page.
func validateRefComponent(kind, s string) error {
if s == "" {
return fmt.Errorf("%w: empty %s", ErrBadRev, kind)
}
if len(s) > maxRefLen {
return fmt.Errorf("%w: %s is too long (%d > %d)", ErrBadRev, kind, len(s), maxRefLen)
}
if !utf8.ValidString(s) {
return fmt.Errorf("%w: %s %q is not valid UTF-8", ErrBadRev, kind, s)
}
for _, r := range s {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("%w: %s %q contains a control character", ErrBadRev, kind, s)
}
switch r {
case ' ', '~', '^', ':', '?', '*', '[', '\\', '"', '\'', '<', '>', '|', ';', '&', '$', '`', '\t':
return fmt.Errorf("%w: %s %q contains a disallowed character %q", ErrBadRev, kind, s, r)
}
}
if s[0] == '-' {
return fmt.Errorf("%w: %s %q must not start with '-'", ErrBadRev, kind, s)
}
if strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/") {
return fmt.Errorf("%w: %s %q must not start or end with '/'", ErrBadRev, kind, s)
}
if strings.Contains(s, "..") {
return fmt.Errorf("%w: %s %q must not contain '..'", ErrBadRev, kind, s)
}
if strings.Contains(s, "//") {
return fmt.Errorf("%w: %s %q must not contain an empty component", ErrBadRev, kind, s)
}
if strings.Contains(s, "@{") {
return fmt.Errorf("%w: %s %q must not contain \"@{\"", ErrBadRev, kind, s)
}
if strings.HasSuffix(s, ".lock") || strings.Contains(s, ".lock/") {
return fmt.Errorf("%w: %s %q must not have a \".lock\" component", ErrBadRev, kind, s)
}
if s == "@" {
return fmt.Errorf("%w: %s must not be \"@\"", ErrBadRev, kind)
}
for _, comp := range strings.Split(s, "/") {
if strings.HasPrefix(comp, ".") || strings.HasSuffix(comp, ".") {
return fmt.Errorf("%w: %s %q component %q must not start or end with '.'", ErrBadRev, kind, s, comp)
}
}
return nil
}