internal/control/sig.go

335 lines · 10945 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.emit(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	})
102}
103
104func runPGPRemove(c *Ctx, args []string) int {
105	if len(args) != 1 {
106		return c.usage()
107	}
108	if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
109		if errors.Is(err, store.ErrNotFound) {
110			return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
111		}
112		return c.fail(protocol.ExitFailure, "%v", err)
113	}
114	return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
115		fmt.Fprintf(w, "removed %s\n", args[0])
116	})
117}
118
119// sigParse is a package-local alias so callers avoid importing sig directly.
120func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
121
122// VerifyCommitCached verifies one commit with the epoch cache. Shared with
123// the web UI.
124func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
125	epoch, err := st.KeyEpoch()
126	if err != nil {
127		return sig.Result{}, err
128	}
129	if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
130		return sig.Result{}, err
131	} else if ok {
132		return res, nil
133	}
134	res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
135	if err != nil {
136		return sig.Result{}, err
137	}
138	if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
139		return sig.Result{}, err
140	}
141	return res, nil
142}
143
144func runRepoLog(c *Ctx, args []string) int {
145	f, perr := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"})
146	if perr != nil {
147		return c.fail(protocol.ExitUsage, "%v", perr)
148	}
149	limit, path, filePath, ref := 30, f.pos(0), f.Value("--path"), f.Value("--ref")
150	if f.Has("--limit") {
151		n, err := strconv.Atoi(f.Value("--limit"))
152		if err != nil || n < 1 || n > 1000 {
153			return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
154		}
155		limit = n
156	}
157	if path == "" {
158		return c.usage()
159	}
160	repo, code := resolveRepo(c, path, policy.CanRead)
161	if code >= 0 {
162		return code
163	}
164	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
165	if ref == "" {
166		ref = repo.DefaultBranch
167	}
168	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
169		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
170	}
171	var shas []string
172	var err error
173	if filePath != "" {
174		shas, err = gitutil.RevListPath(dir, ref, filePath, limit)
175	} else {
176		shas, err = gitutil.RevList(dir, ref, limit)
177	}
178	if err != nil {
179		return c.fail(protocol.ExitFailure, "reading log: %v", err)
180	}
181
182	type sigOut struct {
183		State       string `json:"state"`
184		Signer      string `json:"signer,omitempty"`
185		Fingerprint string `json:"key_fingerprint,omitempty"`
186	}
187	type out struct {
188		SHA            string `json:"sha"`
189		Subject        string `json:"subject"`
190		AuthorName     string `json:"author_name"`
191		AuthorEmail    string `json:"author_email"`
192		CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
193		Date           string `json:"date"`
194		Signature      sigOut `json:"signature"`
195	}
196	var ds []out
197	for _, sha := range shas {
198		raw, err := gitutil.ReadCommit(dir, sha)
199		if err != nil {
200			return c.fail(protocol.ExitFailure, "%v", err)
201		}
202		parsed, err := sig.ParseCommit(raw)
203		if err != nil {
204			return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
205		}
206		res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
207		if err != nil {
208			return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
209		}
210		d := out{
211			SHA:         sha,
212			Subject:     parsed.Subject,
213			AuthorName:  parsed.AuthorName,
214			AuthorEmail: parsed.AuthorEmail,
215			Date:        time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
216			Signature:   sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
217		}
218		if parsed.CommitterEmail != parsed.AuthorEmail {
219			d.CommitterEmail = parsed.CommitterEmail
220		}
221		if res.SignerUserID != 0 {
222			if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
223				d.Signature.Signer = u.Username
224			}
225		}
226		ds = append(ds, d)
227	}
228	return c.emit(ds, func(w io.Writer) {
229		tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR")
230		for _, d := range ds {
231			tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject),
232				cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail)))
233		}
234		tb.flush()
235	})
236}
237
238// runRepoCommit shows one commit: its metadata, signature verdict, check
239// statuses, and its patch. The web's commit page read these straight from
240// git, which is why no other surface could open a commit.
241func runRepoCommit(c *Ctx, args []string) int {
242	if len(args) != 2 {
243		return c.usage()
244	}
245	repo, code := resolveRepo(c, args[0], policy.CanRead)
246	if code >= 0 {
247		return code
248	}
249	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
250	full, err := gitutil.ResolveRef(dir, args[1])
251	if err != nil {
252		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
253	}
254	raw, err := gitutil.ReadCommit(dir, full)
255	if err != nil {
256		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
257	}
258	parsed, err := sig.ParseCommit(raw)
259	if err != nil {
260		return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
261	}
262	res, err := VerifyCommitCached(c.Store, repo, parsed, full)
263	if err != nil {
264		return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
265	}
266	patch, truncated, err := gitutil.ShowPatch(dir, full, 4<<20)
267	if err != nil {
268		return c.fail(protocol.ExitFailure, "%v", err)
269	}
270	if truncated {
271		fmt.Fprintln(c.Stderr, "patch truncated at 4 MiB; clone the repository for the rest")
272	}
273	statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
274	if err != nil {
275		return c.fail(protocol.ExitFailure, "%v", err)
276	}
277
278	// The message body is everything after the subject line.
279	message := ""
280	if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
281		message = string(parsed.Payload)[i+2:]
282	}
283
284	type checkOut struct {
285		Context string `json:"context"`
286		State   string `json:"state"`
287		URL     string `json:"url,omitempty"`
288	}
289	type sigOut struct {
290		State       string `json:"state"`
291		Signer      string `json:"signer,omitempty"`
292		Fingerprint string `json:"key_fingerprint,omitempty"`
293	}
294	type out struct {
295		Path           string     `json:"path"`
296		SHA            string     `json:"sha"`
297		Subject        string     `json:"subject"`
298		Message        string     `json:"message,omitempty"`
299		AuthorName     string     `json:"author_name"`
300		AuthorEmail    string     `json:"author_email"`
301		CommitterEmail string     `json:"committer_email,omitempty"`
302		Date           string     `json:"date"`
303		Signature      sigOut     `json:"signature"`
304		Checks         []checkOut `json:"checks,omitempty"`
305		// Diff is the unified patch, parsed by the client the same way
306		// mr diff is.
307		Diff string `json:"diff"`
308	}
309	d := out{
310		Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
311		AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
312		Date:      time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
313		Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
314		Diff:      patch,
315	}
316	if parsed.CommitterEmail != parsed.AuthorEmail {
317		d.CommitterEmail = parsed.CommitterEmail
318	}
319	if res.SignerUserID != 0 {
320		if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
321			d.Signature.Signer = u.Username
322		}
323	}
324	for _, st := range statuses {
325		d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
326	}
327	return c.emit(d, func(w io.Writer) {
328		fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate:   %s\n\n    %s\n",
329			d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
330		if d.Message != "" {
331			fmt.Fprintf(w, "\n%s\n", d.Message)
332		}
333		fmt.Fprintf(w, "\n%s", d.Diff)
334	})
335}