internal/control/sig.go

4744c629ed4f0158bdea7d0f1d207fdccb1f7f99
gitbay/internal/control/sig.go history · blame · raw

331 lines · 10640 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		Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd})
 22	register(Command{Path: []string{"pgp", "list"},
 23		Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList})
 24	register(Command{Path: []string{"pgp", "remove"},
 25		Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
 26	register(Command{Path: []string{"repo", "commit"},
 27		Summary:  "show one commit with its patch: repo commit <owner/name> <sha>",
 28		ReadOnly: true, Run: runRepoCommit})
 29	register(Command{Path: []string{"repo", "log"},
 30		Summary: "commit log with signature states: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
 31}
 32
 33func runPGPAdd(c *Ctx, args []string) int {
 34	if len(args) != 0 {
 35		return c.fail(protocol.ExitUsage, "usage: pgp add < key.asc")
 36	}
 37	raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20))
 38	if err != nil {
 39		return c.fail(protocol.ExitFailure, "reading key: %v", err)
 40	}
 41	meta, err := sig.ParsePGPKey(raw)
 42	if err != nil {
 43		return c.fail(protocol.ExitUsage, "%v", err)
 44	}
 45	uids, _ := json.Marshal(meta.Emails)
 46	if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
 47		if errors.Is(err, store.ErrDuplicateKey) {
 48			return c.fail(protocol.ExitUsage, "%v", err)
 49		}
 50		return c.fail(protocol.ExitFailure, "adding key: %v", err)
 51	}
 52	type out struct {
 53		Fingerprint string   `json:"fingerprint"`
 54		Emails      []string `json:"emails"`
 55	}
 56	d := out{meta.Fingerprint, meta.Emails}
 57	return c.emit(d, func(w io.Writer) {
 58		fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails)
 59	})
 60}
 61
 62func runPGPList(c *Ctx, args []string) int {
 63	keys, err := c.Store.ListPGPKeys(c.User.ID)
 64	if err != nil {
 65		return c.fail(protocol.ExitFailure, "%v", err)
 66	}
 67	type out struct {
 68		Fingerprint string     `json:"fingerprint"`
 69		Emails      string     `json:"emails"`
 70		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
 71		RevokedAt   *time.Time `json:"revoked_at,omitempty"`
 72	}
 73	var ds []out
 74	for _, k := range keys {
 75		ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
 76	}
 77	return c.emit(ds, func(w io.Writer) {
 78		for _, d := range ds {
 79			fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails)
 80		}
 81	})
 82}
 83
 84func runPGPRemove(c *Ctx, args []string) int {
 85	if len(args) != 1 {
 86		return c.fail(protocol.ExitUsage, "usage: pgp remove <fingerprint>")
 87	}
 88	if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
 89		if errors.Is(err, store.ErrNotFound) {
 90			return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
 91		}
 92		return c.fail(protocol.ExitFailure, "%v", err)
 93	}
 94	return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
 95		fmt.Fprintf(w, "removed %s\n", args[0])
 96	})
 97}
 98
 99// sigParse is a package-local alias so callers avoid importing sig directly.
100func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
101
102// VerifyCommitCached verifies one commit with the epoch cache. Shared with
103// the web UI.
104func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
105	epoch, err := st.KeyEpoch()
106	if err != nil {
107		return sig.Result{}, err
108	}
109	if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
110		return sig.Result{}, err
111	} else if ok {
112		return res, nil
113	}
114	res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
115	if err != nil {
116		return sig.Result{}, err
117	}
118	if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
119		return sig.Result{}, err
120	}
121	return res, nil
122}
123
124func runRepoLog(c *Ctx, args []string) int {
125	limit := 30
126	var path, filePath, ref string
127	for i := 0; i < len(args); i++ {
128		switch args[i] {
129		case "--ref":
130			if i+1 >= len(args) {
131				return c.fail(protocol.ExitUsage, "--ref requires a value")
132			}
133			ref = args[i+1]
134			i++
135		case "--limit":
136			if i+1 >= len(args) {
137				return c.fail(protocol.ExitUsage, "--limit requires a value")
138			}
139			n, err := strconv.Atoi(args[i+1])
140			if err != nil || n < 1 || n > 1000 {
141				return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
142			}
143			limit = n
144			i++
145		case "--path":
146			if i+1 >= len(args) {
147				return c.fail(protocol.ExitUsage, "--path requires a value")
148			}
149			filePath = args[i+1]
150			i++
151		default:
152			if path != "" {
153				return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
154			}
155			path = args[i]
156		}
157	}
158	if path == "" {
159		return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
160	}
161	repo, code := resolveRepo(c, path, policy.CanRead)
162	if code >= 0 {
163		return code
164	}
165	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
166	if ref == "" {
167		ref = repo.DefaultBranch
168	}
169	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
170		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
171	}
172	var shas []string
173	var err error
174	if filePath != "" {
175		shas, err = gitutil.RevListPath(dir, ref, filePath, limit)
176	} else {
177		shas, err = gitutil.RevList(dir, ref, limit)
178	}
179	if err != nil {
180		return c.fail(protocol.ExitFailure, "reading log: %v", err)
181	}
182
183	type sigOut struct {
184		State       string `json:"state"`
185		Signer      string `json:"signer,omitempty"`
186		Fingerprint string `json:"key_fingerprint,omitempty"`
187	}
188	type out struct {
189		SHA            string `json:"sha"`
190		Subject        string `json:"subject"`
191		AuthorName     string `json:"author_name"`
192		AuthorEmail    string `json:"author_email"`
193		CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
194		Date           string `json:"date"`
195		Signature      sigOut `json:"signature"`
196	}
197	var ds []out
198	for _, sha := range shas {
199		raw, err := gitutil.ReadCommit(dir, sha)
200		if err != nil {
201			return c.fail(protocol.ExitFailure, "%v", err)
202		}
203		parsed, err := sig.ParseCommit(raw)
204		if err != nil {
205			return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
206		}
207		res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
208		if err != nil {
209			return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
210		}
211		d := out{
212			SHA:         sha,
213			Subject:     parsed.Subject,
214			AuthorName:  parsed.AuthorName,
215			AuthorEmail: parsed.AuthorEmail,
216			Date:        time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
217			Signature:   sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
218		}
219		if parsed.CommitterEmail != parsed.AuthorEmail {
220			d.CommitterEmail = parsed.CommitterEmail
221		}
222		if res.SignerUserID != 0 {
223			if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
224				d.Signature.Signer = u.Username
225			}
226		}
227		ds = append(ds, d)
228	}
229	return c.emit(ds, func(w io.Writer) {
230		for _, d := range ds {
231			fmt.Fprintf(w, "%.10s  %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail)
232		}
233	})
234}
235
236// runRepoCommit shows one commit: its metadata, signature verdict, check
237// statuses, and its patch. The web's commit page read these straight from
238// git, which is why no other surface could open a commit.
239func runRepoCommit(c *Ctx, args []string) int {
240	const usage = "repo commit <owner/name> <sha>"
241	if len(args) != 2 {
242		return c.fail(protocol.ExitUsage, "usage: %s", usage)
243	}
244	repo, code := resolveRepo(c, args[0], policy.CanRead)
245	if code >= 0 {
246		return code
247	}
248	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
249	full, err := gitutil.ResolveRef(dir, args[1])
250	if err != nil {
251		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
252	}
253	raw, err := gitutil.ReadCommit(dir, full)
254	if err != nil {
255		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
256	}
257	parsed, err := sig.ParseCommit(raw)
258	if err != nil {
259		return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
260	}
261	res, err := VerifyCommitCached(c.Store, repo, parsed, full)
262	if err != nil {
263		return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
264	}
265	patch, err := gitutil.ShowPatch(dir, full, 4<<20)
266	if err != nil {
267		return c.fail(protocol.ExitFailure, "%v", err)
268	}
269	statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
270	if err != nil {
271		return c.fail(protocol.ExitFailure, "%v", err)
272	}
273
274	// The message body is everything after the subject line.
275	message := ""
276	if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
277		message = string(parsed.Payload)[i+2:]
278	}
279
280	type checkOut struct {
281		Context string `json:"context"`
282		State   string `json:"state"`
283		URL     string `json:"url,omitempty"`
284	}
285	type sigOut struct {
286		State       string `json:"state"`
287		Signer      string `json:"signer,omitempty"`
288		Fingerprint string `json:"key_fingerprint,omitempty"`
289	}
290	type out struct {
291		Path           string     `json:"path"`
292		SHA            string     `json:"sha"`
293		Subject        string     `json:"subject"`
294		Message        string     `json:"message,omitempty"`
295		AuthorName     string     `json:"author_name"`
296		AuthorEmail    string     `json:"author_email"`
297		CommitterEmail string     `json:"committer_email,omitempty"`
298		Date           string     `json:"date"`
299		Signature      sigOut     `json:"signature"`
300		Checks         []checkOut `json:"checks,omitempty"`
301		// Diff is the unified patch, parsed by the client the same way
302		// mr diff is.
303		Diff string `json:"diff"`
304	}
305	d := out{
306		Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
307		AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
308		Date:      time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
309		Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
310		Diff:      patch,
311	}
312	if parsed.CommitterEmail != parsed.AuthorEmail {
313		d.CommitterEmail = parsed.CommitterEmail
314	}
315	if res.SignerUserID != 0 {
316		if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
317			d.Signature.Signer = u.Username
318		}
319	}
320	for _, st := range statuses {
321		d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
322	}
323	return c.emit(d, func(w io.Writer) {
324		fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate:   %s\n\n    %s\n",
325			d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
326		if d.Message != "" {
327			fmt.Fprintf(w, "\n%s\n", d.Message)
328		}
329		fmt.Fprintf(w, "\n%s", d.Diff)
330	})
331}