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 }