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 }