Commit bc49092e23
Verified · cmc ci/build: success ci/test: success ci/vuln: success
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 | 311 | if err := c.Store.SetUserAdmin(u.ID, admin); err != nil { |
| 312 | 312 | if errors.Is(err, store.ErrLastAdmin) { |
| 313 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 313 | return c.failErr(err) | |
| 314 | 314 | } |
| 315 | 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 | 87 | return c.fail(protocol.ExitUsage, usage) |
| 88 | 88 | } |
| 89 | 89 | if err := policy.ValidateOwnerName(username); err != nil { |
| 90 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 90 | return c.failErr(err) | |
| 91 | 91 | } |
| 92 | 92 | // Parse the key before creating anything, so a bad key leaves no |
| 93 | 93 | // half-made account behind. |
| @@ -103,7 +103,7 @@ func runAdminUserCreate(c *Ctx, args []string) int { | ||
| 103 | 103 | } |
| 104 | 104 | uid, err := c.Store.CreateUser(username, isAdmin) |
| 105 | 105 | if err != nil { |
| 106 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 106 | return c.failErr(err) | |
| 107 | 107 | } |
| 108 | 108 | if email != "" { |
| 109 | 109 | by := "" |
| @@ -111,14 +111,14 @@ func runAdminUserCreate(c *Ctx, args []string) int { | ||
| 111 | 111 | by = "admin" |
| 112 | 112 | } |
| 113 | 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 | 117 | fp := "" |
| 118 | 118 | if pub != nil { |
| 119 | 119 | fp = ssh.FingerprintSHA256(pub) |
| 120 | 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 | 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 | 201 | return c.fail(protocol.ExitUsage, "that is your own account") |
| 202 | 202 | } |
| 203 | 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 | 206 | c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username}) |
| 207 | 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 | 174 | jobs, err := ci.Parse(raw) |
| 175 | 175 | if err != nil { |
| 176 | return nil, "", c.fail(protocol.ExitUsage, "%v", err) | |
| 176 | return nil, "", c.failErr(err) | |
| 177 | 177 | } |
| 178 | 178 | return jobs, sha, -1 |
| 179 | 179 | } |
internal/control/control.go +17
| @@ -5,6 +5,7 @@ package control | ||
| 5 | 5 | |
| 6 | 6 | import ( |
| 7 | 7 | "encoding/json" |
| 8 | "errors" | |
| 8 | 9 | "fmt" |
| 9 | 10 | "io" |
| 10 | 11 | "reflect" |
| @@ -164,6 +165,22 @@ func (c *Ctx) emit(data any, plain func(w io.Writer)) int { | ||
| 164 | 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). | |
| 174 | func (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 | ||
| 167 | 184 | func (c *Ctx) fail(code int, format string, args ...any) int { |
| 168 | 185 | msg := fmt.Sprintf(format, args...) |
| 169 | 186 | if c.JSON { |
internal/control/control_test.go +24
| @@ -3,7 +3,10 @@ package control | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "encoding/json" |
| 6 | "errors" | |
| 7 | "fmt" | |
| 6 | 8 | "io" |
| 9 | "io/fs" | |
| 7 | 10 | "slices" |
| 8 | 11 | "strings" |
| 9 | 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). | |
| 232 | func 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 | 58 | scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) |
| 59 | 59 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { |
| 60 | 60 | if errors.Is(err, store.ErrDuplicateKey) { |
| 61 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 61 | return c.failErr(err) | |
| 62 | 62 | } |
| 63 | 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 | 82 | body, err := bodyFrom(c, message, file) |
| 83 | 83 | if err != nil { |
| 84 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 84 | return c.failErr(err) | |
| 85 | 85 | } |
| 86 | 86 | if strings.TrimSpace(body) == "" { |
| 87 | 87 | return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") |
| @@ -116,7 +116,7 @@ func runDiffComment(c *Ctx, args []string) int { | ||
| 116 | 116 | if errors.Is(err, store.ErrNotFound) { |
| 117 | 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 | 121 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 122 | 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 | 109 | fp := ssh.FingerprintSHA256(pub) |
| 110 | 110 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { |
| 111 | 111 | if errors.Is(err, store.ErrDuplicateKey) { |
| 112 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 112 | return c.failErr(err) | |
| 113 | 113 | } |
| 114 | 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 | 63 | return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url>") |
| 64 | 64 | } |
| 65 | 65 | if err := policy.ValidateName(name); err != nil { |
| 66 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 66 | return c.failErr(err) | |
| 67 | 67 | } |
| 68 | 68 | // Same ownership rule as repo create: yourself, or an org you admin. |
| 69 | 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 | 163 | fmtName, err := markupFormat(format) |
| 164 | 164 | if err != nil { |
| 165 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 165 | return c.failErr(err) | |
| 166 | 166 | } |
| 167 | 167 | if fmtName == "" { |
| 168 | 168 | fmtName = "md" |
| @@ -177,7 +177,7 @@ func runIssueCreate(c *Ctx, args []string) int { | ||
| 177 | 177 | } |
| 178 | 178 | b, err := bodyFrom(c, body, file) |
| 179 | 179 | if err != nil { |
| 180 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 180 | return c.failErr(err) | |
| 181 | 181 | } |
| 182 | 182 | n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName) |
| 183 | 183 | if err != nil { |
| @@ -326,7 +326,7 @@ func runIssueComment(c *Ctx, args []string) int { | ||
| 326 | 326 | } |
| 327 | 327 | fmtName, err := markupFormat(format) |
| 328 | 328 | if err != nil { |
| 329 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 329 | return c.failErr(err) | |
| 330 | 330 | } |
| 331 | 331 | if fmtName == "" { |
| 332 | 332 | fmtName = "md" |
| @@ -340,7 +340,7 @@ func runIssueComment(c *Ctx, args []string) int { | ||
| 340 | 340 | } |
| 341 | 341 | body, err := bodyFrom(c, message, file) |
| 342 | 342 | if err != nil { |
| 343 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 343 | return c.failErr(err) | |
| 344 | 344 | } |
| 345 | 345 | if strings.TrimSpace(body) == "" { |
| 346 | 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 | 424 | if file != "" { |
| 425 | 425 | b, err := bodyFrom(c, "", file) |
| 426 | 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 | 429 | bodyV, haveBody = b, true |
| 430 | 430 | } |
| 431 | 431 | fmtName, err := markupFormat(formatV) |
| 432 | 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 | 435 | if !haveTitle && !haveBody && fmtName == "" { |
| 436 | 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) { | ||
| 506 | 506 | func runIssueLabel(c *Ctx, args []string) int { |
| 507 | 507 | rest, adds, removes, err := addRemoveFlags(args) |
| 508 | 508 | if err != nil { |
| 509 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 509 | return c.failErr(err) | |
| 510 | 510 | } |
| 511 | 511 | if len(adds)+len(removes) == 0 { |
| 512 | 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 { | ||
| 543 | 543 | func runIssueAssign(c *Ctx, args []string) int { |
| 544 | 544 | rest, adds, removes, err := addRemoveFlags(args) |
| 545 | 545 | if err != nil { |
| 546 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 546 | return c.failErr(err) | |
| 547 | 547 | } |
| 548 | 548 | if len(adds)+len(removes) == 0 { |
| 549 | 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 | 78 | return code |
| 79 | 79 | } |
| 80 | 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 | 83 | return c.emit(map[string]string{"milestone": title}, func(w io.Writer) { |
| 84 | 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 | 63 | // The worker's git process dials this URL from the server: same SSRF |
| 64 | 64 | // surface as a webhook target, same rules. |
| 65 | 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 | 68 | repo, code := resolveRepo(c, path, policy.CanAdmin) |
| 69 | 69 | if code >= 0 { |
internal/control/mr.go +5 −5
| @@ -95,7 +95,7 @@ func runRepoFork(c *Ctx, args []string) int { | ||
| 95 | 95 | name = src.Name |
| 96 | 96 | } |
| 97 | 97 | if err := policy.ValidateName(name); err != nil { |
| 98 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 98 | return c.failErr(err) | |
| 99 | 99 | } |
| 100 | 100 | if code := checkRepoQuota(c); code >= 0 { |
| 101 | 101 | return code |
| @@ -266,7 +266,7 @@ func runMRCreate(c *Ctx, args []string) int { | ||
| 266 | 266 | } |
| 267 | 267 | fmtName, err := markupFormat(format) |
| 268 | 268 | if err != nil { |
| 269 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 269 | return c.failErr(err) | |
| 270 | 270 | } |
| 271 | 271 | if fmtName == "" { |
| 272 | 272 | fmtName = "md" |
| @@ -303,7 +303,7 @@ func runMRCreate(c *Ctx, args []string) int { | ||
| 303 | 303 | } |
| 304 | 304 | b, err := bodyFrom(c, body, file) |
| 305 | 305 | if err != nil { |
| 306 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 306 | return c.failErr(err) | |
| 307 | 307 | } |
| 308 | 308 | n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName) |
| 309 | 309 | if err != nil { |
| @@ -751,7 +751,7 @@ func runMRComment(c *Ctx, args []string) int { | ||
| 751 | 751 | } |
| 752 | 752 | fmtName, err := markupFormat(format) |
| 753 | 753 | if err != nil { |
| 754 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 754 | return c.failErr(err) | |
| 755 | 755 | } |
| 756 | 756 | if fmtName == "" { |
| 757 | 757 | fmtName = "md" |
| @@ -765,7 +765,7 @@ func runMRComment(c *Ctx, args []string) int { | ||
| 765 | 765 | } |
| 766 | 766 | body, err := bodyFrom(c, message, file) |
| 767 | 767 | if err != nil { |
| 768 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 768 | return c.failErr(err) | |
| 769 | 769 | } |
| 770 | 770 | if strings.TrimSpace(body) == "" { |
| 771 | 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 | 63 | return c.fail(protocol.ExitUsage, "usage: org create <name>") |
| 64 | 64 | } |
| 65 | 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 | 68 | if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil { |
| 69 | 69 | return c.fail(protocol.ExitFailure, "%v", err) |
| @@ -138,7 +138,7 @@ func runOrgRename(c *Ctx, args []string) int { | ||
| 138 | 138 | } |
| 139 | 139 | newName := args[1] |
| 140 | 140 | if err := policy.ValidateOwnerName(newName); err != nil { |
| 141 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 141 | return c.failErr(err) | |
| 142 | 142 | } |
| 143 | 143 | oldDir := filepath.Join(c.Cfg.Server.Root, "repos", org.Name) |
| 144 | 144 | newDir := filepath.Join(c.Cfg.Server.Root, "repos", newName) |
| @@ -146,7 +146,7 @@ func runOrgRename(c *Ctx, args []string) int { | ||
| 146 | 146 | return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName) |
| 147 | 147 | } |
| 148 | 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 | 151 | // Repo paths on disk derive from the owner name; move the tree. If the |
| 152 | 152 | // move fails, revert the database so name and disk stay consistent. |
| @@ -220,7 +220,7 @@ func runOrgMembersAdd(c *Ctx, args []string) int { | ||
| 220 | 220 | return c.fail(protocol.ExitFailure, "%v", err) |
| 221 | 221 | } |
| 222 | 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 | 225 | return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) { |
| 226 | 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 | 246 | if errors.Is(err, store.ErrNotFound) { |
| 247 | 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 | 251 | return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) { |
| 252 | 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 | 75 | domain := strings.ToLower(args[1]) |
| 76 | 76 | if err := validatePageDomain(c, domain); err != nil { |
| 77 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 77 | return c.failErr(err) | |
| 78 | 78 | } |
| 79 | 79 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) |
| 80 | 80 | if code >= 0 { |
internal/control/profile.go +4 −4
| @@ -345,7 +345,7 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 345 | 345 | func runProfileSet(c *Ctx, args []string) int { |
| 346 | 346 | rest, e, err := parseProfileFlags(c, args) |
| 347 | 347 | if err != nil { |
| 348 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 348 | return c.failErr(err) | |
| 349 | 349 | } |
| 350 | 350 | if len(rest) != 0 { |
| 351 | 351 | return c.fail(protocol.ExitUsage, |
| @@ -360,7 +360,7 @@ func runProfileSet(c *Ctx, args []string) int { | ||
| 360 | 360 | } |
| 361 | 361 | p, err = applyProfile(p, e) |
| 362 | 362 | if err != nil { |
| 363 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 363 | return c.failErr(err) | |
| 364 | 364 | } |
| 365 | 365 | if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil { |
| 366 | 366 | return c.fail(protocol.ExitFailure, "%v", err) |
| @@ -373,7 +373,7 @@ func runProfileSet(c *Ctx, args []string) int { | ||
| 373 | 373 | func runOrgProfile(c *Ctx, args []string) int { |
| 374 | 374 | rest, e, err := parseProfileFlags(c, args) |
| 375 | 375 | if err != nil { |
| 376 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 376 | return c.failErr(err) | |
| 377 | 377 | } |
| 378 | 378 | if len(rest) != 1 { |
| 379 | 379 | return c.fail(protocol.ExitUsage, |
| @@ -393,7 +393,7 @@ func runOrgProfile(c *Ctx, args []string) int { | ||
| 393 | 393 | } |
| 394 | 394 | p, err = applyProfile(p, e) |
| 395 | 395 | if err != nil { |
| 396 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 396 | return c.failErr(err) | |
| 397 | 397 | } |
| 398 | 398 | if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil { |
| 399 | 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 | 111 | fmtName, err := markupFormat(format) |
| 112 | 112 | if err != nil { |
| 113 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 113 | return c.failErr(err) | |
| 114 | 114 | } |
| 115 | 115 | if fmtName == "" { |
| 116 | 116 | fmtName = "md" |
| @@ -128,13 +128,13 @@ func runReleaseCreate(c *Ctx, args []string) int { | ||
| 128 | 128 | } |
| 129 | 129 | body, err := bodyFrom(c, notes, file) |
| 130 | 130 | if err != nil { |
| 131 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 131 | return c.failErr(err) | |
| 132 | 132 | } |
| 133 | 133 | if title == "" { |
| 134 | 134 | title = tag |
| 135 | 135 | } |
| 136 | 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 | 139 | c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag)) |
| 140 | 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 | 204 | fmtName, err := markupFormat(format) |
| 205 | 205 | if err != nil { |
| 206 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 206 | return c.failErr(err) | |
| 207 | 207 | } |
| 208 | 208 | if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") { |
| 209 | 209 | return c.fail(protocol.ExitUsage, usage) |
| @@ -228,7 +228,7 @@ func runReleaseEdit(c *Ctx, args []string) int { | ||
| 228 | 228 | body := rel.Notes |
| 229 | 229 | if setNotes { |
| 230 | 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 | 234 | if fmtName == "" { |
| @@ -349,7 +349,7 @@ func runAssetAdd(c *Ctx, args []string) int { | ||
| 349 | 349 | } |
| 350 | 350 | sum := hex.EncodeToString(h.Sum(nil)) |
| 351 | 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 | 354 | if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil { |
| 355 | 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 | 170 | return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") |
| 171 | 171 | } |
| 172 | 172 | if err := policyValidateRepoName(name); err != nil { |
| 173 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 173 | return c.failErr(err) | |
| 174 | 174 | } |
| 175 | 175 | ownerKind, ownerID := "user", c.User.ID |
| 176 | 176 | if owner != c.User.Username { |
| @@ -382,7 +382,7 @@ func runRepoTransfer(c *Ctx, args []string) int { | ||
| 382 | 382 | return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) |
| 383 | 383 | } |
| 384 | 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 | 387 | if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil { |
| 388 | 388 | c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID) |
| @@ -554,7 +554,7 @@ func runSetWebsite(c *Ctx, args []string) int { | ||
| 554 | 554 | } |
| 555 | 555 | site := strings.TrimSpace(args[1]) |
| 556 | 556 | if err := validateWebsite(site); err != nil { |
| 557 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 557 | return c.failErr(err) | |
| 558 | 558 | } |
| 559 | 559 | if len(site) > 256 { |
| 560 | 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 | 710 | if add { |
| 711 | 711 | for _, t := range topics { |
| 712 | 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 | 716 | have, err := c.Store.ListTopics(repo.ID) |
| @@ -757,7 +757,7 @@ func runRepoSearch(c *Ctx, args []string) int { | ||
| 757 | 757 | return c.fail(protocol.ExitUsage, "usage: repo search <query>") |
| 758 | 758 | } |
| 759 | 759 | if err := validQuery(args[0]); err != nil { |
| 760 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 760 | return c.failErr(err) | |
| 761 | 761 | } |
| 762 | 762 | q := strings.ToLower(args[0]) |
| 763 | 763 | |
| @@ -833,7 +833,7 @@ func runRepoGrep(c *Ctx, args []string) int { | ||
| 833 | 833 | return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]") |
| 834 | 834 | } |
| 835 | 835 | if err := validQuery(query); err != nil { |
| 836 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 836 | return c.failErr(err) | |
| 837 | 837 | } |
| 838 | 838 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 839 | 839 | if code >= 0 { |
internal/control/sig.go +2 −2
| @@ -45,12 +45,12 @@ func runPGPAdd(c *Ctx, args []string) int { | ||
| 45 | 45 | } |
| 46 | 46 | meta, err := sig.ParsePGPKey(raw) |
| 47 | 47 | if err != nil { |
| 48 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 48 | return c.failErr(err) | |
| 49 | 49 | } |
| 50 | 50 | uids, _ := json.Marshal(meta.Emails) |
| 51 | 51 | if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil { |
| 52 | 52 | if errors.Is(err, store.ErrDuplicateKey) { |
| 53 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 53 | return c.failErr(err) | |
| 54 | 54 | } |
| 55 | 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 | 94 | return code |
| 95 | 95 | } |
| 96 | 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 | 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 | 102 | return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) { |
| 103 | 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 | 66 | if ttl != "" { |
| 67 | 67 | d, err := parseTTL(ttl) |
| 68 | 68 | if err != nil { |
| 69 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 69 | return c.failErr(err) | |
| 70 | 70 | } |
| 71 | 71 | t := time.Now().Add(d) |
| 72 | 72 | expires = &t |
| @@ -78,7 +78,7 @@ func runTokenCreate(c *Ctx, args []string) int { | ||
| 78 | 78 | // The gb_ prefix makes leaked tokens findable by secret scanners. |
| 79 | 79 | token := "gb_" + raw |
| 80 | 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 | 83 | type out struct { |
| 84 | 84 | Name string `json:"name"` |
internal/control/webhook.go +1 −1
| @@ -63,7 +63,7 @@ func runWebhookAdd(c *Ctx, args []string) int { | ||
| 63 | 63 | return code |
| 64 | 64 | } |
| 65 | 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 | 68 | id, err := c.Store.AddWebhook(repo.ID, url, secret, events) |
| 69 | 69 | if err != nil { |
internal/store/isinternal_test.go added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 @@ | ||
| 2 | 2 | package store |
| 3 | 3 | |
| 4 | 4 | import ( |
| 5 | "context" | |
| 5 | 6 | "database/sql" |
| 6 | 7 | "embed" |
| 7 | 8 | "errors" |
| @@ -12,7 +13,7 @@ import ( | ||
| 12 | 13 | "strconv" |
| 13 | 14 | "strings" |
| 14 | 15 | |
| 15 | _ "modernc.org/sqlite" | |
| 16 | "modernc.org/sqlite" | |
| 16 | 17 | ) |
| 17 | 18 | |
| 18 | 19 | //go:embed migrations/*.sql |
| @@ -177,3 +178,14 @@ func (s *Store) migrateTo(target int) error { | ||
| 177 | 178 | } |
| 178 | 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. | |
| 185 | func 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 | } | |