tenseleyflow/shithub / 80bf62a

Browse files

S20: AheadBehind/CommitsBetween/IsAncestor/SetSymbolicRef + protection enforcer

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
80bf62a01c3b9071715e0088d67b159e7b3cd853
Parents
c8ddb9c
Tree
7a11560

3 changed files

StatusFile+-
A internal/repos/git/branchops.go 104 0
A internal/repos/protection/protection.go 179 0
A internal/repos/protection/protection_test.go 82 0
internal/repos/git/branchops.goadded
@@ -0,0 +1,104 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package git
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"os/exec"
10
+	"strconv"
11
+	"strings"
12
+)
13
+
14
+// AheadBehind returns the number of commits unique to head (ahead)
15
+// and unique to base (behind), computed via
16
+// `git rev-list --left-right --count base...head`. Output shape is
17
+// "<behind>\t<ahead>" — the left side is base, right is head.
18
+//
19
+// When base or head doesn't exist on the repo we surface the typed
20
+// ErrRefNotFound so callers can render "—" instead of a number.
21
+func AheadBehind(ctx context.Context, gitDir, base, head string) (ahead, behind int, err error) {
22
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
23
+		"rev-list", "--left-right", "--count", base+"..."+head)
24
+	out, runErr := cmd.Output()
25
+	if runErr != nil {
26
+		var ee *exec.ExitError
27
+		if errors.As(runErr, &ee) {
28
+			stderr := string(ee.Stderr)
29
+			if strings.Contains(stderr, "unknown revision") || strings.Contains(stderr, "ambiguous argument") {
30
+				return 0, 0, ErrRefNotFound
31
+			}
32
+		}
33
+		return 0, 0, wrapExecErr(runErr)
34
+	}
35
+	parts := strings.Fields(strings.TrimSpace(string(out)))
36
+	if len(parts) != 2 {
37
+		return 0, 0, fmt.Errorf("rev-list: unexpected output %q", out)
38
+	}
39
+	behind, _ = strconv.Atoi(parts[0])
40
+	ahead, _ = strconv.Atoi(parts[1])
41
+	return ahead, behind, nil
42
+}
43
+
44
+// CommitsBetween returns the commits unique to head (the right side
45
+// of the symmetric range). Used by the compare view's commits list.
46
+func CommitsBetween(ctx context.Context, gitDir, base, head string, max int) ([]Commit, error) {
47
+	if max <= 0 {
48
+		max = 250
49
+	}
50
+	const sep = "\x1f"
51
+	const recordEnd = "\x1e"
52
+	format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%at", "%s"}, sep) + sep + "%b" + recordEnd
53
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
54
+		"log",
55
+		"--max-count="+strconv.Itoa(max),
56
+		"--format="+format,
57
+		base+".."+head,
58
+	)
59
+	out, err := cmd.Output()
60
+	if err != nil {
61
+		var ee *exec.ExitError
62
+		if errors.As(err, &ee) && strings.Contains(string(ee.Stderr), "unknown revision") {
63
+			return nil, ErrRefNotFound
64
+		}
65
+		return nil, wrapExecErr(err)
66
+	}
67
+	return parseLogOutput(out)
68
+}
69
+
70
+// IsAncestor reports whether commit a is an ancestor of commit b.
71
+// Used by the pre-receive force-push detector: a fast-forward is
72
+// `IsAncestor(old, new)`.
73
+func IsAncestor(ctx context.Context, gitDir, a, b string) (bool, error) {
74
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
75
+		"merge-base", "--is-ancestor", a, b)
76
+	err := cmd.Run()
77
+	if err == nil {
78
+		return true, nil
79
+	}
80
+	var ee *exec.ExitError
81
+	if errors.As(err, &ee) {
82
+		// Exit 1 = not an ancestor. Anything else = real error.
83
+		if ee.ExitCode() == 1 {
84
+			return false, nil
85
+		}
86
+	}
87
+	return false, wrapExecErr(err)
88
+}
89
+
90
+// SetSymbolicRef updates HEAD (or any other symbolic ref) atomically.
91
+// Used by the default-branch change to point HEAD at the new branch.
92
+func SetSymbolicRef(ctx context.Context, gitDir, ref, target string) error {
93
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
94
+		"symbolic-ref", ref, target)
95
+	if out, err := cmd.CombinedOutput(); err != nil {
96
+		return fmt.Errorf("symbolic-ref %s -> %s: %w (%s)", ref, target, err, out)
97
+	}
98
+	return nil
99
+}
100
+
101
+// ErrRefNotFound is returned when git can't resolve a ref or commit.
102
+// Distinguished from generic exec failures so handlers can render a
103
+// 404-leaning response.
104
+var ErrRefNotFound = errors.New("git: ref not found")
internal/repos/protection/protection.goadded
@@ -0,0 +1,179 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package protection enforces branch-protection rules on incoming
4
+// pushes. The pre-receive hook (S14) calls into Enforce once per
5
+// pushed ref; this package owns the matching, the per-rule checks,
6
+// and the rejection messages.
7
+//
8
+// Rule scope is `refs/heads/*` only — tag refs are out of scope here
9
+// (tag protection is its own thing in a future sprint).
10
+package protection
11
+
12
+import (
13
+	"context"
14
+	"errors"
15
+	"fmt"
16
+	"path/filepath"
17
+	"sort"
18
+	"strings"
19
+
20
+	"github.com/jackc/pgx/v5/pgxpool"
21
+
22
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
23
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
24
+)
25
+
26
+// Decision is the result of evaluating a single ref update against
27
+// the rule set. Allow=true means the push proceeds; Allow=false
28
+// surfaces the reason+rule pattern back to the user via stderr.
29
+type Decision struct {
30
+	Allow      bool
31
+	Reason     string
32
+	RuleID     int64
33
+	Pattern    string
34
+}
35
+
36
+// Update is one ref update from the pre-receive hook's stdin.
37
+type Update struct {
38
+	OldSHA string
39
+	NewSHA string
40
+	Ref    string // "refs/heads/<name>" — tag refs and other namespaces are skipped
41
+	Pusher int64  // user_id; 0 means anonymous which any rule that requires explicit pushers will reject
42
+}
43
+
44
+// Enforce evaluates the rule set against `u`. Returns Allow=true
45
+// when no rule rejects; otherwise Allow=false with a human-readable
46
+// reason naming the pattern that matched.
47
+//
48
+// Rule precedence: longest-pattern-match wins (alphabetical tiebreak).
49
+// Rules don't apply to tag pushes or non-heads namespaces.
50
+func Enforce(ctx context.Context, pool *pgxpool.Pool, gitDir string, repoID int64, u Update) (Decision, error) {
51
+	if !strings.HasPrefix(u.Ref, "refs/heads/") {
52
+		return Decision{Allow: true, Reason: "non-branch ref"}, nil
53
+	}
54
+	branch := strings.TrimPrefix(u.Ref, "refs/heads/")
55
+
56
+	rq := reposdb.New()
57
+	rules, err := rq.ListBranchProtectionRules(ctx, pool, repoID)
58
+	if err != nil {
59
+		return Decision{}, fmt.Errorf("load rules: %w", err)
60
+	}
61
+	rule, ok := matchRule(rules, branch)
62
+	if !ok {
63
+		return Decision{Allow: true, Reason: "no rule matched"}, nil
64
+	}
65
+
66
+	isCreate := isAllZeros(u.OldSHA)
67
+	isDelete := isAllZeros(u.NewSHA)
68
+
69
+	// 1. Deletion gate.
70
+	if isDelete && rule.PreventDeletion {
71
+		return deny(rule, "deletion of this branch is blocked by protection rule"), nil
72
+	}
73
+
74
+	// 2. Force-push gate. Only meaningful when this is an update of an
75
+	// existing branch (both sides non-zero). Skipping allows the create
76
+	// case (oldSHA all-zero) and the delete case (handled above).
77
+	if !isCreate && !isDelete && rule.PreventForcePush {
78
+		ff, err := repogit.IsAncestor(ctx, gitDir, u.OldSHA, u.NewSHA)
79
+		if err != nil {
80
+			return Decision{}, fmt.Errorf("ancestor check: %w", err)
81
+		}
82
+		if !ff {
83
+			return deny(rule, "force-push to this branch is blocked by protection rule"), nil
84
+		}
85
+	}
86
+
87
+	// 3. Allowed-pushers gate.
88
+	if len(rule.AllowedPusherUserIds) > 0 {
89
+		ok := false
90
+		for _, id := range rule.AllowedPusherUserIds {
91
+			if id == u.Pusher {
92
+				ok = true
93
+				break
94
+			}
95
+		}
96
+		if !ok {
97
+			return deny(rule, "pusher is not on the allowed list for this branch"), nil
98
+		}
99
+	}
100
+
101
+	// 4. require_signed_commits, require_pr_for_push, status_checks_required
102
+	//    are placeholder columns wired by S20's migration; their owning
103
+	//    sprints flip them on. No-op here.
104
+
105
+	return Decision{Allow: true, Reason: "passed all rules", RuleID: rule.ID, Pattern: rule.Pattern}, nil
106
+}
107
+
108
+func deny(r reposdb.BranchProtectionRule, reason string) Decision {
109
+	return Decision{
110
+		Allow:   false,
111
+		Reason:  reason,
112
+		RuleID:  r.ID,
113
+		Pattern: r.Pattern,
114
+	}
115
+}
116
+
117
+// matchRule returns the rule with the longest pattern matching branch
118
+// (alphabetical tiebreaker). Returns ok=false when no rule matches.
119
+//
120
+// Patterns use filepath.Match semantics:
121
+//   - `*` matches any sequence of non-separator chars (NOT crossing `/`)
122
+//   - `?` matches a single non-separator char
123
+//   - `[abc]` matches one of a/b/c
124
+//
125
+// `release/*` matches `release/v1.0` but NOT `release/v1.0/sub`.
126
+func matchRule(rules []reposdb.BranchProtectionRule, branch string) (reposdb.BranchProtectionRule, bool) {
127
+	type cand struct {
128
+		rule reposdb.BranchProtectionRule
129
+	}
130
+	var matches []cand
131
+	for _, r := range rules {
132
+		ok, err := filepath.Match(r.Pattern, branch)
133
+		if err != nil {
134
+			continue // bad pattern — admin should fix; treat as no-match
135
+		}
136
+		if ok {
137
+			matches = append(matches, cand{rule: r})
138
+		}
139
+	}
140
+	if len(matches) == 0 {
141
+		return reposdb.BranchProtectionRule{}, false
142
+	}
143
+	sort.Slice(matches, func(i, j int) bool {
144
+		li, lj := len(matches[i].rule.Pattern), len(matches[j].rule.Pattern)
145
+		if li != lj {
146
+			return li > lj
147
+		}
148
+		return matches[i].rule.Pattern < matches[j].rule.Pattern
149
+	})
150
+	return matches[0].rule, true
151
+}
152
+
153
+// isAllZeros reports whether a SHA string is git's "this side is
154
+// absent" sentinel (40 zeros). Both pre-receive lines use this.
155
+func isAllZeros(sha string) bool {
156
+	if len(sha) != 40 {
157
+		return false
158
+	}
159
+	for _, c := range sha {
160
+		if c != '0' {
161
+			return false
162
+		}
163
+	}
164
+	return true
165
+}
166
+
167
+// FriendlyMessage formats a deny Decision for the user's git client.
168
+// The pre-receive hook writes this to stderr.
169
+func FriendlyMessage(d Decision) string {
170
+	if d.Allow {
171
+		return ""
172
+	}
173
+	return fmt.Sprintf("shithub: %s (rule pattern %q).", d.Reason, d.Pattern)
174
+}
175
+
176
+// ErrTransient is returned by Enforce when DB connectivity is the
177
+// failure cause. Pre-receive maps this to "transient error; try
178
+// again" and rejects the push (fail closed per S20 spec).
179
+var ErrTransient = errors.New("protection: transient error")
internal/repos/protection/protection_test.goadded
@@ -0,0 +1,82 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package protection
4
+
5
+import (
6
+	"testing"
7
+
8
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
9
+)
10
+
11
+func TestMatchRule_LongestPrefixWins(t *testing.T) {
12
+	t.Parallel()
13
+	rules := []reposdb.BranchProtectionRule{
14
+		{ID: 1, Pattern: "*"},
15
+		{ID: 2, Pattern: "release/*"},
16
+		{ID: 3, Pattern: "release/v1.0"},
17
+	}
18
+	cases := []struct {
19
+		branch  string
20
+		wantID  int64
21
+		wantHit bool
22
+	}{
23
+		{"release/v1.0", 3, true},
24
+		{"release/beta", 2, true},
25
+		// `*` doesn't cross `/` per filepath.Match — no rule matches
26
+		// a slash-containing branch when no rule has a deeper pattern.
27
+		{"release/v1.0/sub", 0, false},
28
+		{"trunk", 1, true},
29
+		{"feature-x", 1, true},
30
+	}
31
+	for _, c := range cases {
32
+		got, ok := matchRule(rules, c.branch)
33
+		if ok != c.wantHit {
34
+			t.Errorf("branch %q: hit=%v want %v", c.branch, ok, c.wantHit)
35
+			continue
36
+		}
37
+		if !c.wantHit {
38
+			continue
39
+		}
40
+		if got.ID != c.wantID {
41
+			t.Errorf("branch %q: matched rule %d, want %d", c.branch, got.ID, c.wantID)
42
+		}
43
+	}
44
+}
45
+
46
+func TestMatchRule_NoMatchReturnsFalse(t *testing.T) {
47
+	t.Parallel()
48
+	rules := []reposdb.BranchProtectionRule{{Pattern: "release/*"}}
49
+	if _, ok := matchRule(rules, "trunk"); ok {
50
+		t.Errorf("expected no match for trunk against release/*")
51
+	}
52
+}
53
+
54
+func TestMatchRule_AlphabeticalTiebreak(t *testing.T) {
55
+	t.Parallel()
56
+	rules := []reposdb.BranchProtectionRule{
57
+		{ID: 1, Pattern: "feature/*"},
58
+		{ID: 2, Pattern: "feat[u]re/*"}, // same length matching same string
59
+	}
60
+	got, ok := matchRule(rules, "feature/foo")
61
+	if !ok {
62
+		t.Fatalf("expected match")
63
+	}
64
+	// Both patterns have same length; alphabetical tiebreak:
65
+	// "feat[u]re/*" < "feature/*" so the [u]re/* wins.
66
+	if got.ID != 2 {
67
+		t.Errorf("got rule %d, want 2 (alphabetical tiebreak)", got.ID)
68
+	}
69
+}
70
+
71
+func TestIsAllZeros(t *testing.T) {
72
+	t.Parallel()
73
+	if !isAllZeros("0000000000000000000000000000000000000000") {
74
+		t.Errorf("40 zeros → true")
75
+	}
76
+	if isAllZeros("0000000000000000000000000000000000000001") {
77
+		t.Errorf("39 zeros + 1 → false")
78
+	}
79
+	if isAllZeros("00000") {
80
+		t.Errorf("short string → false")
81
+	}
82
+}