vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | package hook |
| 2 | |
| 3 | // What a session may write to the issues branch. |
| 4 | // |
| 5 | // There are two ways to write there: the browser, one action at a time through a |
| 6 | // handler that asks the account's capability, and a push, which is this file. They |
| 7 | // have to offer the same actions for the same account, so both ask the ACL the same |
| 8 | // questions — CanDiscuss, CanOpenRequest, CanTriage, CanApprove — and this is the |
| 9 | // push boundary's half of that: a whole diff, checked path by path. |
| 10 | // |
| 11 | // Before this existed, "writing the issues branch requires maintain" stood in for the |
| 12 | // per-path rules, which is why only the browser could offer a contributor an issue or |
| 13 | // a comment. The rules were always expressible; they just had not been asked here. |
| 14 | |
| 15 | import ( |
| 16 | "fmt" |
| 17 | "sort" |
| 18 | "strings" |
| 19 | "time" |
| 20 | |
| 21 | "rodent.software/vole/ci/runs" |
| 22 | "rodent.software/vole/core/git" |
| 23 | "rodent.software/vole/forge/access" |
| 24 | "rodent.software/vole/metadata" |
| 25 | ) |
| 26 | |
| 27 | // lookupRunCredential resolves the fingerprint a session authenticated with to the |
| 28 | // job it belongs to, or nil when the session used an account key. An expired |
| 29 | // credential reads as absent: the row is swept later, and the refusal it produces is |
| 30 | // the same one an unknown key gives. |
| 31 | // isRequestFile reports whether a path is a change request's own file. It is spelled |
| 32 | // out here rather than assumed from a prefix so a future file beside change.md is |
| 33 | // not silently treated as one. |
| 34 | func isRequestFile(path string) bool { |
| 35 | return strings.HasPrefix(path, metadata.ChangesDir+"/") && strings.HasSuffix(path, "/change.md") |
| 36 | } |
| 37 | |
| 38 | func lookupRunCredential(cfg Config, fingerprint string) (*runs.RunCredential, error) { |
| 39 | if cfg.RunsDB == "" { |
| 40 | return nil, nil |
| 41 | } |
| 42 | open := openRuns(cfg.RunsDB) |
| 43 | store, err := open() |
| 44 | if err != nil { |
| 45 | return nil, err |
| 46 | } |
| 47 | defer store.Close() |
| 48 | return store.RunCredential(fingerprint, time.Now().UTC()) |
| 49 | } |
| 50 | |
| 51 | // writableMetadata reports what a push to the issues branch may not contain. It is |
| 52 | // called for every session, key or run credential: the credential changes how a job |
| 53 | // authenticates, not what the account may do. |
| 54 | // |
| 55 | // A maintainer may do what a maintainer may do, which is every write the branch |
| 56 | // carries; the one rule that applies to everybody is that an approval must be |
| 57 | // somebody else's. A contributor may discuss, file an issue, manage the item it wrote, |
| 58 | // and open a change from a branch it could have pushed, which is exactly what the |
| 59 | // browser offers the same account. |
| 60 | func writableMetadata(g *git.Repo, c change, acl *access.ACL, actor string) []string { |
| 61 | if c.old == "" || c.old == git.ZeroSHA { |
| 62 | // Nothing to compare against: the branch does not exist yet, and creating it is |
| 63 | // a maintainer's act, exactly as creating it from the browser is. |
| 64 | if acl.CanTriage(actor) { |
| 65 | return nil |
| 66 | } |
| 67 | return []string{"creating the issues branch needs a maintainer; it must already exist"} |
| 68 | } |
| 69 | status, err := g.DiffStatus(c.old, c.new) |
| 70 | if err != nil { |
| 71 | return []string{"cannot check what this push writes: " + err.Error()} |
| 72 | } |
| 73 | paths := make([]string, 0, len(status)) |
| 74 | for path := range status { |
| 75 | paths = append(paths, path) |
| 76 | } |
| 77 | sort.Strings(paths) |
| 78 | |
| 79 | var problems []string |
| 80 | for _, path := range paths { |
| 81 | change := status[path] |
| 82 | // An approval is an account's statement about its own review, whether it arrives |
| 83 | // through the browser or a push. approvalOwnership already requires the file to |
| 84 | // name the actor, and ValidateRequests already refuses one that approves its |
| 85 | // author's own change; what this adds is the eligibility the browser's approve |
| 86 | // button asks for, which the push boundary used to get for free from "writing the |
| 87 | // issues branch requires maintain". |
| 88 | if metadata.ApprovalPath(path) != "" { |
| 89 | if change == "A" && !acl.CanApprove(actor) { |
| 90 | problems = append(problems, fmt.Sprintf( |
| 91 | "%s: %s may not approve changes here", path, actor)) |
| 92 | } |
| 93 | continue |
| 94 | } |
| 95 | // Everything else on the branch is a maintainer's to write: triage covers |
| 96 | // issues, changes, and the files they carry. |
| 97 | if acl.CanTriage(actor) { |
| 98 | continue |
| 99 | } |
| 100 | switch { |
| 101 | case metadata.IsIssueCommentPath(path), metadata.IsRequestCommentPath(path): |
| 102 | // Adding a comment is discussing. Changing or removing one is the author's, |
| 103 | // and commentOwnership already checked that against the old blob. |
| 104 | if change != "A" { |
| 105 | continue |
| 106 | } |
| 107 | if !acl.CanDiscuss(actor) { |
| 108 | problems = append(problems, fmt.Sprintf( |
| 109 | "%s: %s may not discuss in this repository", path, actor)) |
| 110 | continue |
| 111 | } |
| 112 | author, ok := authorAt(g, c.new, path) |
| 113 | if !ok { |
| 114 | problems = append(problems, fmt.Sprintf("%s: cannot read whose comment this is", path)) |
| 115 | continue |
| 116 | } |
| 117 | if author != actor { |
| 118 | problems = append(problems, fmt.Sprintf( |
| 119 | "%s: this push writes as %s, and this comment says %s", path, actor, author)) |
| 120 | } |
| 121 | case isIssueFile(path): |
| 122 | id, _ := metadata.IssueIDFromPath(path) |
| 123 | if change == "D" { |
| 124 | // An issue is closed, not removed; the browser offers no delete, and |
| 125 | // neither does the CLI. |
| 126 | problems = append(problems, fmt.Sprintf("%s: an issue cannot be removed", path)) |
| 127 | continue |
| 128 | } |
| 129 | if change == "A" { |
| 130 | // Filing an issue is part of discussing, and the author is this push: |
| 131 | // the browser fills that field in from the session, so a push has to |
| 132 | // put the same name there. |
| 133 | if !acl.CanDiscuss(actor) { |
| 134 | problems = append(problems, fmt.Sprintf( |
| 135 | "%s: %s may not file an issue in this repository", path, actor)) |
| 136 | continue |
| 137 | } |
| 138 | if author, ok := authorAt(g, c.new, path); !ok || author != actor { |
| 139 | problems = append(problems, fmt.Sprintf( |
| 140 | "%s: this push files the issue as %s", path, actor)) |
| 141 | } |
| 142 | continue |
| 143 | } |
| 144 | // State, labels, assignees, and the description: a maintainer may triage |
| 145 | // any issue (handled above), and an author may manage their own. |
| 146 | old, found, err := metadata.LoadIssueAtCommit(g, c.old, id) |
| 147 | if err != nil || !found || old == nil { |
| 148 | problems = append(problems, fmt.Sprintf("%s: cannot read the issue being changed", path)) |
| 149 | continue |
| 150 | } |
| 151 | if old.Author != actor { |
| 152 | problems = append(problems, fmt.Sprintf( |
| 153 | "%s: %s may not change an issue filed by %s", path, actor, old.Author)) |
| 154 | } |
| 155 | case isRequestFile(path): |
| 156 | id, _ := metadata.RequestIDFromPath(path) |
| 157 | old, _, err := metadata.LoadRequestAt(g, c.old, id) |
| 158 | if change == "M" { |
| 159 | if err != nil || old == nil { |
| 160 | problems = append(problems, fmt.Sprintf("%s: cannot read the change being changed", path)) |
| 161 | continue |
| 162 | } |
| 163 | if old.Author != actor { |
| 164 | problems = append(problems, fmt.Sprintf( |
| 165 | "%s: %s may not change a change opened by %s", path, actor, old.Author)) |
| 166 | } |
| 167 | continue |
| 168 | } |
| 169 | if change != "A" { |
| 170 | problems = append(problems, fmt.Sprintf("%s: a job may not remove a change request", path)) |
| 171 | continue |
| 172 | } |
| 173 | req, found, err := metadata.LoadRequestAt(g, c.new, id) |
| 174 | if err != nil || !found || req == nil { |
| 175 | problems = append(problems, fmt.Sprintf("%s: cannot read the change request: %v", path, err)) |
| 176 | continue |
| 177 | } |
| 178 | if !acl.CanOpenRequest(actor) { |
| 179 | problems = append(problems, fmt.Sprintf( |
| 180 | "%s: %s may not open a change in this repository", path, actor)) |
| 181 | continue |
| 182 | } |
| 183 | if req.Author != actor { |
| 184 | problems = append(problems, fmt.Sprintf( |
| 185 | "%s: this push opens a change as %s, and this one says %s", path, actor, req.Author)) |
| 186 | } |
| 187 | // A contributor proposes a branch it could have pushed itself. A bare |
| 188 | // commit is the maintainer form, and a contributor has no business |
| 189 | // pointing at one. |
| 190 | switch { |
| 191 | case req.HeadRef == "": |
| 192 | problems = append(problems, fmt.Sprintf( |
| 193 | "%s: a contribution must follow a branch, not a bare commit", path)) |
| 194 | case !access.IsContributorRef(actor, "refs/heads/"+req.HeadRef): |
| 195 | problems = append(problems, fmt.Sprintf( |
| 196 | "%s: %s may only propose a branch under %s", |
| 197 | path, actor, access.ContributorBranchPrefix(actor))) |
| 198 | } |
| 199 | default: |
| 200 | problems = append(problems, fmt.Sprintf( |
| 201 | "%s: a contributor may write a comment, an issue, or a change request", path)) |
| 202 | } |
| 203 | } |
| 204 | return problems |
| 205 | } |
| 206 | |
| 207 | // authorAt reads the author of a metadata file at a commit. |
| 208 | func authorAt(g *git.Repo, commit, path string) (string, bool) { |
| 209 | raw, err := g.BlobAt(commit, path) |
| 210 | if err != nil { |
| 211 | return "", false |
| 212 | } |
| 213 | author, err := metadata.AuthorOf(raw) |
| 214 | if err != nil { |
| 215 | return "", false |
| 216 | } |
| 217 | return author, true |
| 218 | } |
| 219 | |
| 220 | // isIssueFile reports whether path is an issue's own file rather than one of its |
| 221 | // comments. |
| 222 | func isIssueFile(path string) bool { |
| 223 | if _, ok := metadata.IssueIDFromPath(path); !ok { |
| 224 | return false |
| 225 | } |
| 226 | return !metadata.IsIssueCommentPath(path) |
| 227 | } |
| 228 |