internal/control/sig.go

403 lines · 13683 bytes

  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}