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