internal/control/sig.go
403 lines · 13683 bytes
8 symbols in this file
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "strconv"
9 "strings"
10 "time"
11
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/sig"
16 "gitbay.org/gitbay/internal/store"
17)
18
19func init() {
20 register(Command{Path: []string{"pgp", "add"},
21 NeedsRecentSignIn: true,
22 Summary: "register an OpenPGP public key (armored)",
23 Usage: "pgp add < key.asc",
24 Examples: []string{"pgp add < key.asc"},
25 ReadsStdin: true, Run: runPGPAdd})
26 register(Command{Path: []string{"pgp", "list"},
27 Summary: "list registered OpenPGP keys",
28 Usage: "pgp list",
29 Examples: []string{"pgp list"}, ReadOnly: true, Run: runPGPList})
30 register(Command{Path: []string{"pgp", "remove"},
31 Summary: "remove an OpenPGP key by fingerprint",
32 Usage: "pgp remove <fingerprint>",
33 Examples: []string{"pgp remove ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234"}, Run: runPGPRemove})
34 register(Command{Path: []string{"repo", "commit"},
35 Summary: "show one commit with its patch",
36 Usage: "repo commit <owner/name> <sha>",
37 Examples: []string{"repo commit krz/gitbay a1b2c3d"},
38 ReadOnly: true, Run: runRepoCommit})
39 register(Command{Path: []string{"repo", "log"},
40 Summary: "commit log with signature states",
41 Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]",
42 Flags: []Flag{
43 {"--ref", "<r>", "branch, tag or commit to start from", "the default branch"},
44 {"--limit", "n", "rows to show", "30"},
45 {"--path", "<file>", "only commits touching this path", ""},
46 },
47 Examples: []string{"repo log krz/gitbay --limit 10"},
48 ReadOnly: true, Run: runRepoLog})
49}
50
51func runPGPAdd(c *Ctx, args []string) int {
52 if len(args) != 0 {
53 return c.usage()
54 }
55 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20))
56 if err != nil {
57 return c.fail(protocol.ExitFailure, "reading key: %v", err)
58 }
59 meta, err := sig.ParsePGPKey(raw)
60 if err != nil {
61 return c.failInput(err)
62 }
63 uids, _ := json.Marshal(meta.Emails)
64 if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
65 if errors.Is(err, store.ErrDuplicateKey) {
66 return c.failErr(err)
67 }
68 return c.fail(protocol.ExitFailure, "adding key: %v", err)
69 }
70 type out struct {
71 Fingerprint string `json:"fingerprint"`
72 Emails []string `json:"emails"`
73 }
74 d := out{meta.Fingerprint, meta.Emails}
75 return c.emit(d, func(w io.Writer) {
76 fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails)
77 })
78}
79
80func runPGPList(c *Ctx, args []string) int {
81 keys, err := c.Store.ListPGPKeys(c.User.ID)
82 if err != nil {
83 return c.fail(protocol.ExitFailure, "%v", err)
84 }
85 type out struct {
86 Fingerprint string `json:"fingerprint"`
87 Emails string `json:"emails"`
88 ExpiresAt *time.Time `json:"expires_at,omitempty"`
89 RevokedAt *time.Time `json:"revoked_at,omitempty"`
90 }
91 var ds []out
92 for _, k := range keys {
93 ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
94 }
95 return c.emitView(ds, func(w io.Writer) {
96 tb := c.table(w, "FINGERPRINT", "EMAILS")
97 for _, d := range ds {
98 tb.row(cRef(d.Fingerprint), cText(d.Emails))
99 }
100 tb.flush()
101 }, func() screen {
102 rows := make([]row, len(ds))
103 for i, d := range ds {
104 emails := d.Emails
105 var uids []string
106 if json.Unmarshal([]byte(d.Emails), &uids) == nil {
107 emails = strings.Join(uids, ", ")
108 }
109 lead, note := cGlyph(""), ""
110 switch {
111 case d.RevokedAt != nil:
112 lead, note = cGlyph("failed"), "revoked"
113 case d.ExpiresAt != nil && !d.ExpiresAt.After(time.Now()):
114 lead, note = cGlyph("failed"), "expired"
115 case d.ExpiresAt != nil:
116 note = "expires " + d.ExpiresAt.Format("2006-01-02")
117 }
118 rows[i] = rowOf(cFlexRef(d.Fingerprint), lead, cText(emails), cMeta(note))
119 }
120 return listScreen("OpenPGP keys", rows,
121 action{"Keys", []string{"pgp", "remove", "<fingerprint>"}},
122 )
123 })
124}
125
126func runPGPRemove(c *Ctx, args []string) int {
127 if len(args) != 1 {
128 return c.usage()
129 }
130 if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
131 if errors.Is(err, store.ErrNotFound) {
132 return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
133 }
134 return c.fail(protocol.ExitFailure, "%v", err)
135 }
136 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
137 fmt.Fprintf(w, "removed %s\n", args[0])
138 })
139}
140
141// sigParse is a package-local alias so callers avoid importing sig directly.
142func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
143
144// VerifyCommitCached verifies one commit with the epoch cache. Shared with
145// the web UI.
146func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
147 epoch, err := st.KeyEpoch()
148 if err != nil {
149 return sig.Result{}, err
150 }
151 if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
152 return sig.Result{}, err
153 } else if ok {
154 return res, nil
155 }
156 res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
157 if err != nil {
158 return sig.Result{}, err
159 }
160 if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
161 return sig.Result{}, err
162 }
163 return res, nil
164}
165
166func runRepoLog(c *Ctx, args []string) int {
167 f, perr := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"})
168 if perr != nil {
169 return c.fail(protocol.ExitUsage, "%v", perr)
170 }
171 limit, path, filePath, ref := 30, f.pos(0), f.Value("--path"), f.Value("--ref")
172 if f.Has("--limit") {
173 n, err := strconv.Atoi(f.Value("--limit"))
174 if err != nil || n < 1 || n > 1000 {
175 return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
176 }
177 limit = n
178 }
179 if path == "" {
180 return c.usage()
181 }
182 repo, code := resolveRepo(c, path, policy.CanRead)
183 if code >= 0 {
184 return code
185 }
186 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
187 if ref == "" {
188 ref = repo.DefaultBranch
189 }
190 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
191 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
192 }
193 var shas []string
194 var err error
195 if filePath != "" {
196 shas, err = gitutil.RevListPath(dir, ref, filePath, limit)
197 } else {
198 shas, err = gitutil.RevList(dir, ref, limit)
199 }
200 if err != nil {
201 return c.fail(protocol.ExitFailure, "reading log: %v", err)
202 }
203
204 type sigOut struct {
205 State string `json:"state"`
206 Signer string `json:"signer,omitempty"`
207 Fingerprint string `json:"key_fingerprint,omitempty"`
208 }
209 type out struct {
210 SHA string `json:"sha"`
211 Subject string `json:"subject"`
212 AuthorName string `json:"author_name"`
213 AuthorEmail string `json:"author_email"`
214 CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
215 Date string `json:"date"`
216 Signature sigOut `json:"signature"`
217 }
218 var ds []out
219 for _, sha := range shas {
220 raw, err := gitutil.ReadCommit(dir, sha)
221 if err != nil {
222 return c.fail(protocol.ExitFailure, "%v", err)
223 }
224 parsed, err := sig.ParseCommit(raw)
225 if err != nil {
226 return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
227 }
228 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
229 if err != nil {
230 return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
231 }
232 d := out{
233 SHA: sha,
234 Subject: parsed.Subject,
235 AuthorName: parsed.AuthorName,
236 AuthorEmail: parsed.AuthorEmail,
237 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
238 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
239 }
240 if parsed.CommitterEmail != parsed.AuthorEmail {
241 d.CommitterEmail = parsed.CommitterEmail
242 }
243 if res.SignerUserID != 0 {
244 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
245 d.Signature.Signer = u.Username
246 }
247 }
248 ds = append(ds, d)
249 }
250 return c.emitView(ds, func(w io.Writer) {
251 tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR")
252 for _, d := range ds {
253 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject),
254 cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail)))
255 }
256 tb.flush()
257 }, func() screen {
258 title := "Commits on " + ref
259 if filePath != "" {
260 title += " touching " + filePath
261 }
262 rows := make([]row, len(ds))
263 for i, d := range ds {
264 rows[i] = rowOf(cRef(fmt.Sprintf("%.10s", d.SHA)), cGlyph(d.Signature.State), cFlex(d.Subject), cMeta(d.AuthorName, relAge(d.Date, termNow())))
265 }
266 s := listScreen(title, rows)
267 if len(ds) > 0 {
268 s.actions = append(s.actions, action{"Read", []string{"repo", "commit", repo.Path(), ds[0].SHA[:min(12, len(ds[0].SHA))]}})
269 }
270 s.actions = append(s.actions, action{"Read", []string{"repo", "tree", repo.Path(), "--ref", ref}})
271 return s
272 })
273}
274
275// runRepoCommit shows one commit: its metadata, signature verdict, check
276// statuses, and its patch. The web's commit page read these straight from
277// git, which is why no other surface could open a commit.
278func runRepoCommit(c *Ctx, args []string) int {
279 if len(args) != 2 {
280 return c.usage()
281 }
282 repo, code := resolveRepo(c, args[0], policy.CanRead)
283 if code >= 0 {
284 return code
285 }
286 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
287 full, err := gitutil.ResolveRef(dir, args[1])
288 if err != nil {
289 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
290 }
291 raw, err := gitutil.ReadCommit(dir, full)
292 if err != nil {
293 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
294 }
295 parsed, err := sig.ParseCommit(raw)
296 if err != nil {
297 return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
298 }
299 res, err := VerifyCommitCached(c.Store, repo, parsed, full)
300 if err != nil {
301 return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
302 }
303 patch, truncated, err := gitutil.ShowPatch(dir, full, 4<<20)
304 if err != nil {
305 return c.fail(protocol.ExitFailure, "%v", err)
306 }
307 if truncated {
308 fmt.Fprintln(c.Stderr, "patch truncated at 4 MiB; clone the repository for the rest")
309 }
310 statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
311 if err != nil {
312 return c.fail(protocol.ExitFailure, "%v", err)
313 }
314
315 // The message body is everything after the subject line.
316 message := ""
317 if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
318 message = string(parsed.Payload)[i+2:]
319 }
320
321 type checkOut struct {
322 Context string `json:"context"`
323 State string `json:"state"`
324 URL string `json:"url,omitempty"`
325 }
326 type sigOut struct {
327 State string `json:"state"`
328 Signer string `json:"signer,omitempty"`
329 Fingerprint string `json:"key_fingerprint,omitempty"`
330 }
331 type out struct {
332 Path string `json:"path"`
333 SHA string `json:"sha"`
334 Subject string `json:"subject"`
335 Message string `json:"message,omitempty"`
336 AuthorName string `json:"author_name"`
337 AuthorEmail string `json:"author_email"`
338 CommitterEmail string `json:"committer_email,omitempty"`
339 Date string `json:"date"`
340 Signature sigOut `json:"signature"`
341 Checks []checkOut `json:"checks,omitempty"`
342 // Diff is the unified patch, parsed by the client the same way
343 // mr diff is.
344 Diff string `json:"diff"`
345 }
346 d := out{
347 Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
348 AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
349 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
350 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
351 Diff: patch,
352 }
353 if parsed.CommitterEmail != parsed.AuthorEmail {
354 d.CommitterEmail = parsed.CommitterEmail
355 }
356 if res.SignerUserID != 0 {
357 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
358 d.Signature.Signer = u.Username
359 }
360 }
361 for _, st := range statuses {
362 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
363 }
364 // Message carries the subject paragraph too; the views print it once.
365 body := ""
366 if _, rest, ok := strings.Cut(d.Message, "\n\n"); ok {
367 body = strings.TrimRight(rest, "\n")
368 }
369 return c.emit(d, func(w io.Writer) {
370 if c.Term.Cols == 0 {
371 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
372 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
373 if body != "" {
374 fmt.Fprintf(w, "\n%s\n", body)
375 }
376 fmt.Fprintf(w, "\n%s", d.Diff)
377 return
378 }
379 short, url := d.SHA[:min(10, len(d.SHA))], c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:min(12, len(d.SHA))])
380 s := screen{body: body, format: "text", fields: []field{
381 {"Commit", []cell{cLink(short, url), cText(d.Subject)}},
382 {"Author", []cell{cText(fmt.Sprintf("%s <%s>", d.AuthorName, d.AuthorEmail)), cAge(d.Date)}},
383 }}
384 if d.CommitterEmail != "" && d.CommitterEmail != d.AuthorEmail {
385 s.fields = append(s.fields, field{"Committer", []cell{cText(d.CommitterEmail)}})
386 }
387 s.fields = append(s.fields, field{"Signed", []cell{cGlyph(d.Signature.State), cState(d.Signature.State), cMeta(d.Signature.Signer, d.Signature.Fingerprint)}})
388 if !c.Term.Links {
389 s.fields = append(s.fields, field{"URL", []cell{cText(url)}})
390 }
391 checks := section{title: "Checks", n: len(d.Checks)}
392 for _, ch := range d.Checks {
393 checks.rows = append(checks.rows, rowOf(cGlyph(ch.State), cFlex(ch.Context)))
394 }
395 s.sections = []section{checks}
396 s.actions = []action{
397 {"Read", []string{"repo", "log", repo.Path(), "--ref", short}},
398 {"Read", []string{"repo", "tree", repo.Path(), "--ref", short}},
399 }
400 c.render(w, s)
401 fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
402 })
403}