Commit bc49092e23

bc49092e2314bc25c40ede89ec5a744f5cc6fec8

parent: ffcb3af61a

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 23:59 UTC

control: a store failure is a failure, not a usage error

Fifty-seven sites reported any error from a store or helper call as a
usage error, so a SQLite I/O failure was exit 2 over ssh and HTTP 400
over the API, and a not-found from the store was a usage error too.
failErr maps not-found to exit 3, a database or I/O failure to exit 1
(store.IsInternal), and everything else, which is the caller's input
or a state that refuses the change, to exit 2 as before.

Closes #107

Layout: unified · split

internal/control/admin.go +1 −1
@@ -310,7 +310,7 @@ func setAdmin(c *Ctx, args []string, admin bool) int {
310 } 310 }
311 if err := c.Store.SetUserAdmin(u.ID, admin); err != nil { 311 if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
312 if errors.Is(err, store.ErrLastAdmin) { 312 if errors.Is(err, store.ErrLastAdmin) {
313 return c.fail(protocol.ExitUsage, "%v", err) 313 return c.failErr(err)
314 } 314 }
315 return c.fail(protocol.ExitFailure, "%v", err) 315 return c.fail(protocol.ExitFailure, "%v", err)
316 } 316 }
internal/control/adminhost.go +5 −5
@@ -87,7 +87,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
87 return c.fail(protocol.ExitUsage, usage) 87 return c.fail(protocol.ExitUsage, usage)
88 } 88 }
89 if err := policy.ValidateOwnerName(username); err != nil { 89 if err := policy.ValidateOwnerName(username); err != nil {
90 return c.fail(protocol.ExitUsage, "%v", err) 90 return c.failErr(err)
91 } 91 }
92 // Parse the key before creating anything, so a bad key leaves no 92 // Parse the key before creating anything, so a bad key leaves no
93 // half-made account behind. 93 // half-made account behind.
@@ -103,7 +103,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
103 } 103 }
104 uid, err := c.Store.CreateUser(username, isAdmin) 104 uid, err := c.Store.CreateUser(username, isAdmin)
105 if err != nil { 105 if err != nil {
106 return c.fail(protocol.ExitUsage, "%v", err) 106 return c.failErr(err)
107 } 107 }
108 if email != "" { 108 if email != "" {
109 by := "" 109 by := ""
@@ -111,14 +111,14 @@ func runAdminUserCreate(c *Ctx, args []string) int {
111 by = "admin" 111 by = "admin"
112 } 112 }
113 if err := c.Store.AddEmail(uid, email, by, true); err != nil { 113 if err := c.Store.AddEmail(uid, email, by, true); err != nil {
114 return c.fail(protocol.ExitUsage, "%v", err) 114 return c.failErr(err)
115 } 115 }
116 } 116 }
117 fp := "" 117 fp := ""
118 if pub != nil { 118 if pub != nil {
119 fp = ssh.FingerprintSHA256(pub) 119 fp = ssh.FingerprintSHA256(pub)
120 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil { 120 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
121 return c.fail(protocol.ExitUsage, "%v", err) 121 return c.failErr(err)
122 } 122 }
123 } 123 }
124 c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username}) 124 c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username})
@@ -201,7 +201,7 @@ func runAdminUserDelete(c *Ctx, args []string) int {
201 return c.fail(protocol.ExitUsage, "that is your own account") 201 return c.fail(protocol.ExitUsage, "that is your own account")
202 } 202 }
203 if err := c.Store.DeleteUser(u.ID); err != nil { 203 if err := c.Store.DeleteUser(u.ID); err != nil {
204 return c.fail(protocol.ExitUsage, "%v", err) 204 return c.failErr(err)
205 } 205 }
206 c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username}) 206 c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username})
207 return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) { 207 return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) {
internal/control/build.go +1 −1
@@ -173,7 +173,7 @@ func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
173 } 173 }
174 jobs, err := ci.Parse(raw) 174 jobs, err := ci.Parse(raw)
175 if err != nil { 175 if err != nil {
176 return nil, "", c.fail(protocol.ExitUsage, "%v", err) 176 return nil, "", c.failErr(err)
177 } 177 }
178 return jobs, sha, -1 178 return jobs, sha, -1
179} 179}
internal/control/control.go +17
@@ -5,6 +5,7 @@ package control
5 5
6import ( 6import (
7 "encoding/json" 7 "encoding/json"
8 "errors"
8 "fmt" 9 "fmt"
9 "io" 10 "io"
10 "reflect" 11 "reflect"
@@ -164,6 +165,22 @@ func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
164 return protocol.ExitOK 165 return protocol.ExitOK
165} 166}
166 167
168// failErr reports an error from a store or helper call with the exit code
169// its kind deserves: not-found is not-found, the database or I/O failing
170// is a failure, and anything else is the caller's mistake, which is what
171// most such errors are (a name that does not validate, a state that does
172// not allow the change). A SQLite I/O error used to be a usage error and
173// an HTTP 400 (#107).
174func (c *Ctx) failErr(err error) int {
175 switch {
176 case errors.Is(err, store.ErrNotFound):
177 return c.fail(protocol.ExitNotFound, "%v", err)
178 case store.IsInternal(err):
179 return c.fail(protocol.ExitFailure, "%v", err)
180 }
181 return c.fail(protocol.ExitUsage, "%v", err)
182}
183
167func (c *Ctx) fail(code int, format string, args ...any) int { 184func (c *Ctx) fail(code int, format string, args ...any) int {
168 msg := fmt.Sprintf(format, args...) 185 msg := fmt.Sprintf(format, args...)
169 if c.JSON { 186 if c.JSON {
internal/control/control_test.go +24
@@ -3,7 +3,10 @@ package control
3import ( 3import (
4 "bytes" 4 "bytes"
5 "encoding/json" 5 "encoding/json"
6 "errors"
7 "fmt"
6 "io" 8 "io"
9 "io/fs"
7 "slices" 10 "slices"
8 "strings" 11 "strings"
9 "testing" 12 "testing"
@@ -223,3 +226,24 @@ func TestRefusalsHonourJSON(t *testing.T) {
223 } 226 }
224 } 227 }
225} 228}
229
230// TestFailErrExitCodes: not-found, an internal failure, and the caller's
231// mistake each get their own exit code (#107).
232func TestFailErrExitCodes(t *testing.T) {
233 code := func(err error) int {
234 c := &Ctx{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
235 return c.failErr(err)
236 }
237 if got := code(store.ErrNotFound); got != protocol.ExitNotFound {
238 t.Errorf("not found: %d", got)
239 }
240 if got := code(fmt.Errorf("looking up: %w", store.ErrNotFound)); got != protocol.ExitNotFound {
241 t.Errorf("wrapped not found: %d", got)
242 }
243 if got := code(errors.New("name must be lowercase")); got != protocol.ExitUsage {
244 t.Errorf("caller's mistake: %d", got)
245 }
246 if got := code(&fs.PathError{Op: "open", Path: "/x", Err: fs.ErrPermission}); got != protocol.ExitFailure {
247 t.Errorf("i/o failure: %d", got)
248 }
249}
internal/control/deploykey.go +1 −1
@@ -58,7 +58,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
58 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) 58 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode)
59 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { 59 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
60 if errors.Is(err, store.ErrDuplicateKey) { 60 if errors.Is(err, store.ErrDuplicateKey) {
61 return c.fail(protocol.ExitUsage, "%v", err) 61 return c.failErr(err)
62 } 62 }
63 return c.fail(protocol.ExitFailure, "%v", err) 63 return c.fail(protocol.ExitFailure, "%v", err)
64 } 64 }
internal/control/diffcomment.go +2 −2
@@ -81,7 +81,7 @@ func runDiffComment(c *Ctx, args []string) int {
81 } 81 }
82 body, err := bodyFrom(c, message, file) 82 body, err := bodyFrom(c, message, file)
83 if err != nil { 83 if err != nil {
84 return c.fail(protocol.ExitUsage, "%v", err) 84 return c.failErr(err)
85 } 85 }
86 if strings.TrimSpace(body) == "" { 86 if strings.TrimSpace(body) == "" {
87 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") 87 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
@@ -116,7 +116,7 @@ func runDiffComment(c *Ctx, args []string) int {
116 if errors.Is(err, store.ErrNotFound) { 116 if errors.Is(err, store.ErrNotFound) {
117 return c.fail(protocol.ExitNotFound, "%v", err) 117 return c.fail(protocol.ExitNotFound, "%v", err)
118 } 118 }
119 return c.fail(protocol.ExitUsage, "%v", err) 119 return c.failErr(err)
120 } 120 }
121 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 121 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
122 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 122 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
internal/control/identity.go +1 −1
@@ -109,7 +109,7 @@ func runKeysAdd(c *Ctx, args []string) int {
109 fp := ssh.FingerprintSHA256(pub) 109 fp := ssh.FingerprintSHA256(pub)
110 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { 110 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
111 if errors.Is(err, store.ErrDuplicateKey) { 111 if errors.Is(err, store.ErrDuplicateKey) {
112 return c.fail(protocol.ExitUsage, "%v", err) 112 return c.failErr(err)
113 } 113 }
114 return c.fail(protocol.ExitFailure, "adding key: %v", err) 114 return c.fail(protocol.ExitFailure, "adding key: %v", err)
115 } 115 }
internal/control/import.go +1 −1
@@ -63,7 +63,7 @@ func runRepoImport(c *Ctx, args []string) int {
63 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url>") 63 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url>")
64 } 64 }
65 if err := policy.ValidateName(name); err != nil { 65 if err := policy.ValidateName(name); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err) 66 return c.failErr(err)
67 } 67 }
68 // Same ownership rule as repo create: yourself, or an org you admin. 68 // Same ownership rule as repo create: yourself, or an org you admin.
69 ownerKind, ownerID := "user", c.User.ID 69 ownerKind, ownerID := "user", c.User.ID
internal/control/issue.go +8 −8
@@ -162,7 +162,7 @@ func runIssueCreate(c *Ctx, args []string) int {
162 } 162 }
163 fmtName, err := markupFormat(format) 163 fmtName, err := markupFormat(format)
164 if err != nil { 164 if err != nil {
165 return c.fail(protocol.ExitUsage, "%v", err) 165 return c.failErr(err)
166 } 166 }
167 if fmtName == "" { 167 if fmtName == "" {
168 fmtName = "md" 168 fmtName = "md"
@@ -177,7 +177,7 @@ func runIssueCreate(c *Ctx, args []string) int {
177 } 177 }
178 b, err := bodyFrom(c, body, file) 178 b, err := bodyFrom(c, body, file)
179 if err != nil { 179 if err != nil {
180 return c.fail(protocol.ExitUsage, "%v", err) 180 return c.failErr(err)
181 } 181 }
182 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName) 182 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
183 if err != nil { 183 if err != nil {
@@ -326,7 +326,7 @@ func runIssueComment(c *Ctx, args []string) int {
326 } 326 }
327 fmtName, err := markupFormat(format) 327 fmtName, err := markupFormat(format)
328 if err != nil { 328 if err != nil {
329 return c.fail(protocol.ExitUsage, "%v", err) 329 return c.failErr(err)
330 } 330 }
331 if fmtName == "" { 331 if fmtName == "" {
332 fmtName = "md" 332 fmtName = "md"
@@ -340,7 +340,7 @@ func runIssueComment(c *Ctx, args []string) int {
340 } 340 }
341 body, err := bodyFrom(c, message, file) 341 body, err := bodyFrom(c, message, file)
342 if err != nil { 342 if err != nil {
343 return c.fail(protocol.ExitUsage, "%v", err) 343 return c.failErr(err)
344 } 344 }
345 if strings.TrimSpace(body) == "" { 345 if strings.TrimSpace(body) == "" {
346 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") 346 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
@@ -424,13 +424,13 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body, f
424 if file != "" { 424 if file != "" {
425 b, err := bodyFrom(c, "", file) 425 b, err := bodyFrom(c, "", file)
426 if err != nil { 426 if err != nil {
427 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err) 427 return nil, nil, nil, nil, c.failErr(err)
428 } 428 }
429 bodyV, haveBody = b, true 429 bodyV, haveBody = b, true
430 } 430 }
431 fmtName, err := markupFormat(formatV) 431 fmtName, err := markupFormat(formatV)
432 if err != nil { 432 if err != nil {
433 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err) 433 return nil, nil, nil, nil, c.failErr(err)
434 } 434 }
435 if !haveTitle && !haveBody && fmtName == "" { 435 if !haveTitle && !haveBody && fmtName == "" {
436 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind) 436 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
@@ -506,7 +506,7 @@ func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
506func runIssueLabel(c *Ctx, args []string) int { 506func runIssueLabel(c *Ctx, args []string) int {
507 rest, adds, removes, err := addRemoveFlags(args) 507 rest, adds, removes, err := addRemoveFlags(args)
508 if err != nil { 508 if err != nil {
509 return c.fail(protocol.ExitUsage, "%v", err) 509 return c.failErr(err)
510 } 510 }
511 if len(adds)+len(removes) == 0 { 511 if len(adds)+len(removes) == 0 {
512 return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...") 512 return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
@@ -543,7 +543,7 @@ func runIssueLabel(c *Ctx, args []string) int {
543func runIssueAssign(c *Ctx, args []string) int { 543func runIssueAssign(c *Ctx, args []string) int {
544 rest, adds, removes, err := addRemoveFlags(args) 544 rest, adds, removes, err := addRemoveFlags(args)
545 if err != nil { 545 if err != nil {
546 return c.fail(protocol.ExitUsage, "%v", err) 546 return c.failErr(err)
547 } 547 }
548 if len(adds)+len(removes) == 0 { 548 if len(adds)+len(removes) == 0 {
549 return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...") 549 return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
internal/control/milestone.go +1 −1
@@ -78,7 +78,7 @@ func runMilestoneCreate(c *Ctx, args []string) int {
78 return code 78 return code
79 } 79 }
80 if _, err := c.Store.CreateMilestone(repo.ID, title, description, due); err != nil { 80 if _, err := c.Store.CreateMilestone(repo.ID, title, description, due); err != nil {
81 return c.fail(protocol.ExitUsage, "%v", err) 81 return c.failErr(err)
82 } 82 }
83 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) { 83 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
84 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path()) 84 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
internal/control/mirrorcmd.go +1 −1
@@ -63,7 +63,7 @@ func runMirrorAdd(c *Ctx, args []string) int {
63 // The worker's git process dials this URL from the server: same SSRF 63 // The worker's git process dials this URL from the server: same SSRF
64 // surface as a webhook target, same rules. 64 // surface as a webhook target, same rules.
65 if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil { 65 if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err) 66 return c.failErr(err)
67 } 67 }
68 repo, code := resolveRepo(c, path, policy.CanAdmin) 68 repo, code := resolveRepo(c, path, policy.CanAdmin)
69 if code >= 0 { 69 if code >= 0 {
internal/control/mr.go +5 −5
@@ -95,7 +95,7 @@ func runRepoFork(c *Ctx, args []string) int {
95 name = src.Name 95 name = src.Name
96 } 96 }
97 if err := policy.ValidateName(name); err != nil { 97 if err := policy.ValidateName(name); err != nil {
98 return c.fail(protocol.ExitUsage, "%v", err) 98 return c.failErr(err)
99 } 99 }
100 if code := checkRepoQuota(c); code >= 0 { 100 if code := checkRepoQuota(c); code >= 0 {
101 return code 101 return code
@@ -266,7 +266,7 @@ func runMRCreate(c *Ctx, args []string) int {
266 } 266 }
267 fmtName, err := markupFormat(format) 267 fmtName, err := markupFormat(format)
268 if err != nil { 268 if err != nil {
269 return c.fail(protocol.ExitUsage, "%v", err) 269 return c.failErr(err)
270 } 270 }
271 if fmtName == "" { 271 if fmtName == "" {
272 fmtName = "md" 272 fmtName = "md"
@@ -303,7 +303,7 @@ func runMRCreate(c *Ctx, args []string) int {
303 } 303 }
304 b, err := bodyFrom(c, body, file) 304 b, err := bodyFrom(c, body, file)
305 if err != nil { 305 if err != nil {
306 return c.fail(protocol.ExitUsage, "%v", err) 306 return c.failErr(err)
307 } 307 }
308 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName) 308 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
309 if err != nil { 309 if err != nil {
@@ -751,7 +751,7 @@ func runMRComment(c *Ctx, args []string) int {
751 } 751 }
752 fmtName, err := markupFormat(format) 752 fmtName, err := markupFormat(format)
753 if err != nil { 753 if err != nil {
754 return c.fail(protocol.ExitUsage, "%v", err) 754 return c.failErr(err)
755 } 755 }
756 if fmtName == "" { 756 if fmtName == "" {
757 fmtName = "md" 757 fmtName = "md"
@@ -765,7 +765,7 @@ func runMRComment(c *Ctx, args []string) int {
765 } 765 }
766 body, err := bodyFrom(c, message, file) 766 body, err := bodyFrom(c, message, file)
767 if err != nil { 767 if err != nil {
768 return c.fail(protocol.ExitUsage, "%v", err) 768 return c.failErr(err)
769 } 769 }
770 if strings.TrimSpace(body) == "" { 770 if strings.TrimSpace(body) == "" {
771 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") 771 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
internal/control/org.go +5 −5
@@ -63,7 +63,7 @@ func runOrgCreate(c *Ctx, args []string) int {
63 return c.fail(protocol.ExitUsage, "usage: org create <name>") 63 return c.fail(protocol.ExitUsage, "usage: org create <name>")
64 } 64 }
65 if err := policy.ValidateOwnerName(args[0]); err != nil { 65 if err := policy.ValidateOwnerName(args[0]); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err) 66 return c.failErr(err)
67 } 67 }
68 if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil { 68 if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil {
69 return c.fail(protocol.ExitFailure, "%v", err) 69 return c.fail(protocol.ExitFailure, "%v", err)
@@ -138,7 +138,7 @@ func runOrgRename(c *Ctx, args []string) int {
138 } 138 }
139 newName := args[1] 139 newName := args[1]
140 if err := policy.ValidateOwnerName(newName); err != nil { 140 if err := policy.ValidateOwnerName(newName); err != nil {
141 return c.fail(protocol.ExitUsage, "%v", err) 141 return c.failErr(err)
142 } 142 }
143 oldDir := filepath.Join(c.Cfg.Server.Root, "repos", org.Name) 143 oldDir := filepath.Join(c.Cfg.Server.Root, "repos", org.Name)
144 newDir := filepath.Join(c.Cfg.Server.Root, "repos", newName) 144 newDir := filepath.Join(c.Cfg.Server.Root, "repos", newName)
@@ -146,7 +146,7 @@ func runOrgRename(c *Ctx, args []string) int {
146 return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName) 146 return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName)
147 } 147 }
148 if err := c.Store.RenameOrg(org.ID, newName); err != nil { 148 if err := c.Store.RenameOrg(org.ID, newName); err != nil {
149 return c.fail(protocol.ExitUsage, "%v", err) 149 return c.failErr(err)
150 } 150 }
151 // Repo paths on disk derive from the owner name; move the tree. If the 151 // Repo paths on disk derive from the owner name; move the tree. If the
152 // move fails, revert the database so name and disk stay consistent. 152 // move fails, revert the database so name and disk stay consistent.
@@ -220,7 +220,7 @@ func runOrgMembersAdd(c *Ctx, args []string) int {
220 return c.fail(protocol.ExitFailure, "%v", err) 220 return c.fail(protocol.ExitFailure, "%v", err)
221 } 221 }
222 if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil { 222 if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil {
223 return c.fail(protocol.ExitUsage, "%v", err) 223 return c.failErr(err)
224 } 224 }
225 return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) { 225 return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) {
226 fmt.Fprintf(w, "%s is now a %s of %s\n", target.Username, role, org.Name) 226 fmt.Fprintf(w, "%s is now a %s of %s\n", target.Username, role, org.Name)
@@ -246,7 +246,7 @@ func runOrgMembersRemove(c *Ctx, args []string) int {
246 if errors.Is(err, store.ErrNotFound) { 246 if errors.Is(err, store.ErrNotFound) {
247 return c.fail(protocol.ExitNotFound, "%s is not a member of %s", target.Username, org.Name) 247 return c.fail(protocol.ExitNotFound, "%s is not a member of %s", target.Username, org.Name)
248 } 248 }
249 return c.fail(protocol.ExitUsage, "%v", err) 249 return c.failErr(err)
250 } 250 }
251 return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) { 251 return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) {
252 fmt.Fprintf(w, "removed %s from %s\n", target.Username, org.Name) 252 fmt.Fprintf(w, "removed %s from %s\n", target.Username, org.Name)
internal/control/pagescmd.go +1 −1
@@ -74,7 +74,7 @@ func runDomainAdd(c *Ctx, args []string) int {
74 } 74 }
75 domain := strings.ToLower(args[1]) 75 domain := strings.ToLower(args[1])
76 if err := validatePageDomain(c, domain); err != nil { 76 if err := validatePageDomain(c, domain); err != nil {
77 return c.fail(protocol.ExitUsage, "%v", err) 77 return c.failErr(err)
78 } 78 }
79 repo, code := resolveRepo(c, args[0], policy.CanAdmin) 79 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
80 if code >= 0 { 80 if code >= 0 {
internal/control/profile.go +4 −4
@@ -345,7 +345,7 @@ func runProfileShow(c *Ctx, args []string) int {
345func runProfileSet(c *Ctx, args []string) int { 345func runProfileSet(c *Ctx, args []string) int {
346 rest, e, err := parseProfileFlags(c, args) 346 rest, e, err := parseProfileFlags(c, args)
347 if err != nil { 347 if err != nil {
348 return c.fail(protocol.ExitUsage, "%v", err) 348 return c.failErr(err)
349 } 349 }
350 if len(rest) != 0 { 350 if len(rest) != 0 {
351 return c.fail(protocol.ExitUsage, 351 return c.fail(protocol.ExitUsage,
@@ -360,7 +360,7 @@ func runProfileSet(c *Ctx, args []string) int {
360 } 360 }
361 p, err = applyProfile(p, e) 361 p, err = applyProfile(p, e)
362 if err != nil { 362 if err != nil {
363 return c.fail(protocol.ExitUsage, "%v", err) 363 return c.failErr(err)
364 } 364 }
365 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil { 365 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
366 return c.fail(protocol.ExitFailure, "%v", err) 366 return c.fail(protocol.ExitFailure, "%v", err)
@@ -373,7 +373,7 @@ func runProfileSet(c *Ctx, args []string) int {
373func runOrgProfile(c *Ctx, args []string) int { 373func runOrgProfile(c *Ctx, args []string) int {
374 rest, e, err := parseProfileFlags(c, args) 374 rest, e, err := parseProfileFlags(c, args)
375 if err != nil { 375 if err != nil {
376 return c.fail(protocol.ExitUsage, "%v", err) 376 return c.failErr(err)
377 } 377 }
378 if len(rest) != 1 { 378 if len(rest) != 1 {
379 return c.fail(protocol.ExitUsage, 379 return c.fail(protocol.ExitUsage,
@@ -393,7 +393,7 @@ func runOrgProfile(c *Ctx, args []string) int {
393 } 393 }
394 p, err = applyProfile(p, e) 394 p, err = applyProfile(p, e)
395 if err != nil { 395 if err != nil {
396 return c.fail(protocol.ExitUsage, "%v", err) 396 return c.failErr(err)
397 } 397 }
398 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil { 398 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
399 return c.fail(protocol.ExitFailure, "%v", err) 399 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/release.go +6 −6
@@ -110,7 +110,7 @@ func runReleaseCreate(c *Ctx, args []string) int {
110 } 110 }
111 fmtName, err := markupFormat(format) 111 fmtName, err := markupFormat(format)
112 if err != nil { 112 if err != nil {
113 return c.fail(protocol.ExitUsage, "%v", err) 113 return c.failErr(err)
114 } 114 }
115 if fmtName == "" { 115 if fmtName == "" {
116 fmtName = "md" 116 fmtName = "md"
@@ -128,13 +128,13 @@ func runReleaseCreate(c *Ctx, args []string) int {
128 } 128 }
129 body, err := bodyFrom(c, notes, file) 129 body, err := bodyFrom(c, notes, file)
130 if err != nil { 130 if err != nil {
131 return c.fail(protocol.ExitUsage, "%v", err) 131 return c.failErr(err)
132 } 132 }
133 if title == "" { 133 if title == "" {
134 title = tag 134 title = tag
135 } 135 }
136 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil { 136 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil {
137 return c.fail(protocol.ExitUsage, "%v", err) 137 return c.failErr(err)
138 } 138 }
139 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag)) 139 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
140 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) { 140 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
@@ -203,7 +203,7 @@ func runReleaseEdit(c *Ctx, args []string) int {
203 } 203 }
204 fmtName, err := markupFormat(format) 204 fmtName, err := markupFormat(format)
205 if err != nil { 205 if err != nil {
206 return c.fail(protocol.ExitUsage, "%v", err) 206 return c.failErr(err)
207 } 207 }
208 if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") { 208 if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
209 return c.fail(protocol.ExitUsage, usage) 209 return c.fail(protocol.ExitUsage, usage)
@@ -228,7 +228,7 @@ func runReleaseEdit(c *Ctx, args []string) int {
228 body := rel.Notes 228 body := rel.Notes
229 if setNotes { 229 if setNotes {
230 if body, err = bodyFrom(c, notes, file); err != nil { 230 if body, err = bodyFrom(c, notes, file); err != nil {
231 return c.fail(protocol.ExitUsage, "%v", err) 231 return c.failErr(err)
232 } 232 }
233 } 233 }
234 if fmtName == "" { 234 if fmtName == "" {
@@ -349,7 +349,7 @@ func runAssetAdd(c *Ctx, args []string) int {
349 } 349 }
350 sum := hex.EncodeToString(h.Sum(nil)) 350 sum := hex.EncodeToString(h.Sum(nil))
351 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil { 351 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
352 return c.fail(protocol.ExitUsage, "%v", err) 352 return c.failErr(err)
353 } 353 }
354 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil { 354 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
355 c.Store.RemoveReleaseAsset(rel.ID, name) 355 c.Store.RemoveReleaseAsset(rel.ID, name)
internal/control/repo.go +6 −6
@@ -170,7 +170,7 @@ func runRepoCreate(c *Ctx, args []string) int {
170 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") 170 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
171 } 171 }
172 if err := policyValidateRepoName(name); err != nil { 172 if err := policyValidateRepoName(name); err != nil {
173 return c.fail(protocol.ExitUsage, "%v", err) 173 return c.failErr(err)
174 } 174 }
175 ownerKind, ownerID := "user", c.User.ID 175 ownerKind, ownerID := "user", c.User.ID
176 if owner != c.User.Username { 176 if owner != c.User.Username {
@@ -382,7 +382,7 @@ func runRepoTransfer(c *Ctx, args []string) int {
382 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) 382 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
383 } 383 }
384 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil { 384 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
385 return c.fail(protocol.ExitUsage, "%v", err) 385 return c.failErr(err)
386 } 386 }
387 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil { 387 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
388 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID) 388 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
@@ -554,7 +554,7 @@ func runSetWebsite(c *Ctx, args []string) int {
554 } 554 }
555 site := strings.TrimSpace(args[1]) 555 site := strings.TrimSpace(args[1])
556 if err := validateWebsite(site); err != nil { 556 if err := validateWebsite(site); err != nil {
557 return c.fail(protocol.ExitUsage, "%v", err) 557 return c.failErr(err)
558 } 558 }
559 if len(site) > 256 { 559 if len(site) > 256 {
560 return c.fail(protocol.ExitUsage, "website URL too long (max 256)") 560 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
@@ -710,7 +710,7 @@ func editTopics(c *Ctx, args []string, add bool) int {
710 if add { 710 if add {
711 for _, t := range topics { 711 for _, t := range topics {
712 if err := policy.ValidateTopic(t); err != nil { 712 if err := policy.ValidateTopic(t); err != nil {
713 return c.fail(protocol.ExitUsage, "%v", err) 713 return c.failErr(err)
714 } 714 }
715 } 715 }
716 have, err := c.Store.ListTopics(repo.ID) 716 have, err := c.Store.ListTopics(repo.ID)
@@ -757,7 +757,7 @@ func runRepoSearch(c *Ctx, args []string) int {
757 return c.fail(protocol.ExitUsage, "usage: repo search <query>") 757 return c.fail(protocol.ExitUsage, "usage: repo search <query>")
758 } 758 }
759 if err := validQuery(args[0]); err != nil { 759 if err := validQuery(args[0]); err != nil {
760 return c.fail(protocol.ExitUsage, "%v", err) 760 return c.failErr(err)
761 } 761 }
762 q := strings.ToLower(args[0]) 762 q := strings.ToLower(args[0])
763 763
@@ -833,7 +833,7 @@ func runRepoGrep(c *Ctx, args []string) int {
833 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]") 833 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
834 } 834 }
835 if err := validQuery(query); err != nil { 835 if err := validQuery(query); err != nil {
836 return c.fail(protocol.ExitUsage, "%v", err) 836 return c.failErr(err)
837 } 837 }
838 repo, code := resolveRepo(c, path, policy.CanRead) 838 repo, code := resolveRepo(c, path, policy.CanRead)
839 if code >= 0 { 839 if code >= 0 {
internal/control/sig.go +2 −2
@@ -45,12 +45,12 @@ func runPGPAdd(c *Ctx, args []string) int {
45 } 45 }
46 meta, err := sig.ParsePGPKey(raw) 46 meta, err := sig.ParsePGPKey(raw)
47 if err != nil { 47 if err != nil {
48 return c.fail(protocol.ExitUsage, "%v", err) 48 return c.failErr(err)
49 } 49 }
50 uids, _ := json.Marshal(meta.Emails) 50 uids, _ := json.Marshal(meta.Emails)
51 if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil { 51 if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
52 if errors.Is(err, store.ErrDuplicateKey) { 52 if errors.Is(err, store.ErrDuplicateKey) {
53 return c.fail(protocol.ExitUsage, "%v", err) 53 return c.failErr(err)
54 } 54 }
55 return c.fail(protocol.ExitFailure, "adding key: %v", err) 55 return c.fail(protocol.ExitFailure, "adding key: %v", err)
56 } 56 }
internal/control/teams.go +2 −2
@@ -94,10 +94,10 @@ func runTeamCreate(c *Ctx, args []string) int {
94 return code 94 return code
95 } 95 }
96 if err := policy.ValidateName(args[1]); err != nil { 96 if err := policy.ValidateName(args[1]); err != nil {
97 return c.fail(protocol.ExitUsage, "%v", err) 97 return c.failErr(err)
98 } 98 }
99 if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil { 99 if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil {
100 return c.fail(protocol.ExitUsage, "%v", err) 100 return c.failErr(err)
101 } 101 }
102 return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) { 102 return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) {
103 fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1]) 103 fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1])
internal/control/token.go +2 −2
@@ -66,7 +66,7 @@ func runTokenCreate(c *Ctx, args []string) int {
66 if ttl != "" { 66 if ttl != "" {
67 d, err := parseTTL(ttl) 67 d, err := parseTTL(ttl)
68 if err != nil { 68 if err != nil {
69 return c.fail(protocol.ExitUsage, "%v", err) 69 return c.failErr(err)
70 } 70 }
71 t := time.Now().Add(d) 71 t := time.Now().Add(d)
72 expires = &t 72 expires = &t
@@ -78,7 +78,7 @@ func runTokenCreate(c *Ctx, args []string) int {
78 // The gb_ prefix makes leaked tokens findable by secret scanners. 78 // The gb_ prefix makes leaked tokens findable by secret scanners.
79 token := "gb_" + raw 79 token := "gb_" + raw
80 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil { 80 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
81 return c.fail(protocol.ExitUsage, "%v", err) 81 return c.failErr(err)
82 } 82 }
83 type out struct { 83 type out struct {
84 Name string `json:"name"` 84 Name string `json:"name"`
internal/control/webhook.go +1 −1
@@ -63,7 +63,7 @@ func runWebhookAdd(c *Ctx, args []string) int {
63 return code 63 return code
64 } 64 }
65 if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil { 65 if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err) 66 return c.failErr(err)
67 } 67 }
68 id, err := c.Store.AddWebhook(repo.ID, url, secret, events) 68 id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
69 if err != nil { 69 if err != nil {
internal/store/isinternal_test.go added +22
@@ -0,0 +1,22 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestIsInternal(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13 _, err := s.DB.Exec("INSERT INTO no_such_table (x) VALUES (1)")
14 if err == nil || !IsInternal(err) {
15 t.Errorf("a SQLite error is internal: %v", err)
16 }
17 for _, e := range []error{ErrNotFound, ErrExists, ErrDuplicateKey, errors.New("name must be lowercase")} {
18 if IsInternal(e) {
19 t.Errorf("%v is not internal", e)
20 }
21 }
22}
internal/store/store.go +13 −1
@@ -2,6 +2,7 @@
2package store 2package store
3 3
4import ( 4import (
5 "context"
5 "database/sql" 6 "database/sql"
6 "embed" 7 "embed"
7 "errors" 8 "errors"
@@ -12,7 +13,7 @@ import (
12 "strconv" 13 "strconv"
13 "strings" 14 "strings"
14 15
15 _ "modernc.org/sqlite" 16 "modernc.org/sqlite"
16) 17)
17 18
18//go:embed migrations/*.sql 19//go:embed migrations/*.sql
@@ -177,3 +178,14 @@ func (s *Store) migrateTo(target int) error {
177 } 178 }
178 return nil 179 return nil
179} 180}
181
182// IsInternal reports whether err is the database or the I/O beneath it
183// failing, as opposed to a sentinel or a message about the caller's
184// input. Callers map it to a failure exit rather than a usage error.
185func IsInternal(err error) bool {
186 var sqlErr *sqlite.Error
187 var pathErr *fs.PathError
188 return errors.As(err, &sqlErr) || errors.As(err, &pathErr) ||
189 errors.Is(err, sql.ErrTxDone) || errors.Is(err, sql.ErrConnDone) ||
190 errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
191}