internal/control/sig.go
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}