feat: verified commit signatures (SSH and OpenPGP) with protocol badges (#72)
- commit
- 94ae608fcea4e13f07ac015bb4d3955005fb3eda
- author
- brice · · 2026-10-18 18:14 UTC
- committer
- bnema · · 2026-10-18 18:14 UTC
- parent
- f347d8a
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
74 files · +6406 -525
M README.md +1 -0
| @@ -130,6 +130,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t | |||
| 130 | 130 | - [Git over HTTP](docs/git-http.md): URLs and authentication | |
| 131 | 131 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | |
| 132 | 132 | - [Merge requests](docs/merge-requests.md) | |
| 133 | + | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | |
| 133 | 134 | - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links | |
| 134 | 135 | - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows | |
| 135 | 136 | - [CI events](docs/ci-events.md) | |
A docs/commit-signatures.md +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | # Commit signatures | |
| 2 | + | ||
| 3 | + | ermite checks commit signatures on the commit list and the commit page and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits` and `GetCommit`. | |
| 4 | + | ||
| 5 | + | ## Formats | |
| 6 | + | ||
| 7 | + | | Format | Git setting | Checked | | |
| 8 | + | |---|---|---| | |
| 9 | + | | SSH | `gpg.format=ssh` | yes | | |
| 10 | + | | OpenPGP | `gpg.format=openpgp` (default) | yes | | |
| 11 | + | | X.509 | `gpg.format=x509` | no, shown as unverified | | |
| 12 | + | ||
| 13 | + | Signatures are checked in the server, without `gpg` or `ssh-keygen`. | |
| 14 | + | ||
| 15 | + | ## States | |
| 16 | + | ||
| 17 | + | | Badge | Meaning | | |
| 18 | + | |---|---| | |
| 19 | + | | `SSH Verified` / `GPG Verified` | valid signature, trusted key, committer email listed for that key | | |
| 20 | + | | `Unverified` | unknown key, email not listed for the key, expired or revoked OpenPGP key, or X.509; the tooltip says which | | |
| 21 | + | | `Bad signature` | the signature is malformed or does not match the commit | | |
| 22 | + | | none (list) / `unsigned` (commit page) | no signature | | |
| 23 | + | ||
| 24 | + | An OpenPGP key's expiry is judged when the signature was made; a revoked key is unverified for all its commits. OpenPGP signatures over SHA-1 count as bad signatures. | |
| 25 | + | ||
| 26 | + | ## Trusted keys | |
| 27 | + | ||
| 28 | + | Only the owner (the admin token, or a token with every scope, no repository list and no branch prefix) changes the list. Any authenticated caller can read it. | |
| 29 | + | ||
| 30 | + | ```sh | |
| 31 | + | ermite signing-key trust add --format ssh --title laptop --email me@example.com ~/.ssh/id_ed25519.pub | |
| 32 | + | gpg --armor --export me@example.com | ermite signing-key trust add --format gpg --title gpg --email me@example.com - | |
| 33 | + | ermite signing-key trust list | |
| 34 | + | ermite signing-key trust remove <id> | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | - A key may sign for 1 to 10 emails; up to 100 keys. | |
| 38 | + | - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP. | |
| 39 | + | - The server's own key (`ermite signing-key show`), which signs squash merges, is always trusted for the committer email and cannot be removed. | |
| 40 | + | - Private keys are refused. | |
M gen/ermite/storage/signing.pb.go +113 -11 generated file: collapsed
M go.mod +3 -1
| @@ -13,10 +13,11 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.5.0 | |
| 16 | + | bnema.dev/ermite-api v0.6.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| 20 | + | github.com/ProtonMail/go-crypto v1.4.1 | |
| 20 | 21 | github.com/a-h/templ v0.3.1020 | |
| 21 | 22 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 22 | 23 | github.com/bnema/kvconf v0.1.2 | |
| @@ -71,6 +72,7 @@ require ( | |||
| 71 | 72 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 72 | 73 | github.com/cli/browser v1.3.0 // indirect | |
| 73 | 74 | github.com/clipperhouse/uax29/v2 v2.6.0 // indirect | |
| 75 | + | github.com/cloudflare/circl v1.6.2 // indirect | |
| 74 | 76 | github.com/containerd/errdefs v1.0.0 // indirect | |
| 75 | 77 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 76 | 78 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | |
M go.sum +6 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
| @@ -110,6 +110,11 @@ func (c *Client) Environments() ermiteconnect.EnvironmentServiceClient { | |||
| 110 | 110 | return ermiteconnect.NewEnvironmentServiceClient(c.http, c.base, c.opts...) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | + | // TrustedKeys returns the TrustedKeyService client. | |
| 114 | + | func (c *Client) TrustedKeys() ermiteconnect.TrustedKeyServiceClient { | |
| 115 | + | return ermiteconnect.NewTrustedKeyServiceClient(c.http, c.base, c.opts...) | |
| 116 | + | } | |
| 117 | + | ||
| 113 | 118 | // Server returns the ServerService client. | |
| 114 | 119 | func (c *Client) Server() ermiteconnect.ServerServiceClient { | |
| 115 | 120 | return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...) | |
M internal/adapters/client/client_test.go +3 -3
| @@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, | |
| 147 | - | client.FeatureCIArtifacts, client.FeaturePasskeys, | |
| 147 | + | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 148 | 148 | })) | |
| 149 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | 150 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | - | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, | |
| 151 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 154 | 154 | } | |
M internal/adapters/client/features.go +4 -1
| @@ -45,11 +45,14 @@ const ( | |||
| 45 | 45 | // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`, | |
| 46 | 46 | // `run download`). | |
| 47 | 47 | FeatureCIArtifacts = "ci.artifacts" | |
| 48 | + | // FeatureCommitSignatures is TrustedKeyService and Commit.verification | |
| 49 | + | // (`ermite signing-key trust`). | |
| 50 | + | FeatureCommitSignatures = "commit_signatures" | |
| 48 | 51 | ) | |
| 49 | 52 | ||
| 50 | 53 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 51 | 54 | func UsedFeatures() []string { | |
| 52 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys} | |
| 55 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
A internal/adapters/ermitegit/commitobjects.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ermitegit | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | git "bnema.dev/ermite-git" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var _ ports.CommitObjectReader = (*Adapter)(nil) | |
| 17 | + | ||
| 18 | + | // ReadCommitObjects reads the raw content of commits with ermite-git. Ids that | |
| 19 | + | // are missing, are not commits, or are larger than | |
| 20 | + | // domain.MaxCommitObjectBytes are left out. The read ends at the first commit | |
| 21 | + | // that would take the total over domain.MaxCommitObjectsBytes. It is not routable: the library | |
| 22 | + | // always serves it, and the wrapped reader only the repositories the library | |
| 23 | + | // cannot read. | |
| 24 | + | // | |
| 25 | + | //hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain | |
| 26 | + | func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | |
| 27 | + | if len(ids) > ports.MaxCommitLimit { | |
| 28 | + | return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit) | |
| 29 | + | } | |
| 30 | + | cli := func() (map[domain.ObjectID][]byte, error) { | |
| 31 | + | r, ok := a.cli.(ports.CommitObjectReader) | |
| 32 | + | if !ok { | |
| 33 | + | return nil, fmt.Errorf("read commit objects of %q: the wrapped reader cannot read commit objects", repo) | |
| 34 | + | } | |
| 35 | + | return r.ReadCommitObjects(ctx, repo, ids) | |
| 36 | + | } | |
| 37 | + | return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) { | |
| 38 | + | out := make(map[domain.ObjectID][]byte, len(ids)) | |
| 39 | + | var total int64 | |
| 40 | + | for _, id := range ids { | |
| 41 | + | if err := ctx.Err(); err != nil { | |
| 42 | + | return nil, err | |
| 43 | + | } | |
| 44 | + | if _, done := out[id]; done { | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | gid, err := git.ParseID(id.String()) | |
| 48 | + | if err != nil { | |
| 49 | + | return nil, err | |
| 50 | + | } | |
| 51 | + | typ, size, err := r.ObjectHeader(gid) | |
| 52 | + | if errors.Is(err, git.ErrNotFound) { | |
| 53 | + | continue | |
| 54 | + | } | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes { | |
| 59 | + | continue | |
| 60 | + | } | |
| 61 | + | if total+size > domain.MaxCommitObjectsBytes { | |
| 62 | + | break | |
| 63 | + | } | |
| 64 | + | typ, data, err := r.ReadObject(gid, nil) | |
| 65 | + | if errors.Is(err, git.ErrNotFound) { | |
| 66 | + | continue // removed meanwhile | |
| 67 | + | } | |
| 68 | + | if err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes { | |
| 72 | + | out[id] = data | |
| 73 | + | total += int64(len(data)) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | return out, nil | |
| 77 | + | }, cli) | |
| 78 | + | } | |
A internal/adapters/ermitegit/commitobjects_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ermitegit_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/readertest" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // A repository ermite-git cannot read (alternates) is served by the wrapped | |
| 21 | + | // reader, with the same bytes. | |
| 22 | + | func TestReadCommitObjectsUnsupportedRepositoryFallsBack(t *testing.T) { | |
| 23 | + | ctx := context.Background() | |
| 24 | + | a, g, data := newAdapter(t, ermitegit.DefaultRoutes(), nil) | |
| 25 | + | h := readertest.NewHistory(t, data) | |
| 26 | + | ||
| 27 | + | tip := readertest.ObjectID(t, h.Git("rev-parse", "HEAD")) | |
| 28 | + | ids := []domain.ObjectID{tip, readertest.ObjectID(t, h.Git("rev-parse", "HEAD^{tree}"))} | |
| 29 | + | require.NoError(t, os.WriteFile(filepath.Join(h.Bare, "objects", "info", "alternates"), []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644)) | |
| 30 | + | want, err := g.ReadCommitObjects(ctx, h.Name, ids) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Len(t, want, 1) | |
| 33 | + | got, err := a.ReadCommitObjects(ctx, h.Name, ids) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | assert.Equal(t, want, got) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // The library and git read the same bytes for every commit of a history. | |
| 39 | + | func TestReadCommitObjectsMatchesCLI(t *testing.T) { | |
| 40 | + | ctx := context.Background() | |
| 41 | + | a, g, data := newAdapter(t, ermitegit.DefaultRoutes(), nil) | |
| 42 | + | h := readertest.NewHistory(t, data) | |
| 43 | + | var ids []domain.ObjectID | |
| 44 | + | for _, line := range strings.Fields(h.Git("rev-list", "--all")) { | |
| 45 | + | ids = append(ids, readertest.ObjectID(t, line)) | |
| 46 | + | } | |
| 47 | + | require.NotEmpty(t, ids) | |
| 48 | + | want, err := g.ReadCommitObjects(ctx, h.Name, ids) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | got, err := a.ReadCommitObjects(ctx, h.Name, ids) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | assert.Len(t, got, len(ids)) | |
| 53 | + | assert.Equal(t, want, got) | |
| 54 | + | } | |
M internal/adapters/ermitegit/conformance_test.go +9 -0
| @@ -28,3 +28,12 @@ func TestConformanceDefaultRoutes(t *testing.T) { | |||
| 28 | 28 | return a, data | |
| 29 | 29 | }) | |
| 30 | 30 | } | |
| 31 | + | ||
| 32 | + | // TestCommitObjects runs the shared CommitObjectReader table against the | |
| 33 | + | // adapter. | |
| 34 | + | func TestCommitObjects(t *testing.T) { | |
| 35 | + | readertest.CommitObjects(t, func(t *testing.T) (ports.CommitObjectReader, string) { | |
| 36 | + | a, _, data := newAdapter(t, ermitegit.DefaultRoutes(), nil) | |
| 37 | + | return a, data | |
| 38 | + | }) | |
| 39 | + | } | |
A internal/adapters/gitcli/commitobjects.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var _ ports.CommitObjectReader = (*Git)(nil) | |
| 14 | + | ||
| 15 | + | // ReadCommitObjects reads the raw content of commits with the cat-file pool: | |
| 16 | + | // one `info` per id, then `contents` for the commits of acceptable size. Ids | |
| 17 | + | // that are missing, are not commits, or are larger than | |
| 18 | + | // domain.MaxCommitObjectBytes are left out. The read ends at the first commit | |
| 19 | + | // that would take the total over domain.MaxCommitObjectsBytes (known from | |
| 20 | + | // `info`, before any content is read, so the stream stays consistent). | |
| 21 | + | // | |
| 22 | + | //hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain | |
| 23 | + | func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | |
| 24 | + | if len(ids) > ports.MaxCommitLimit { | |
| 25 | + | return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit) | |
| 26 | + | } | |
| 27 | + | for _, id := range ids { | |
| 28 | + | if id.IsZero() { | |
| 29 | + | return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | dir, err := g.repoDir(repo) | |
| 33 | + | if err != nil { | |
| 34 | + | return nil, err | |
| 35 | + | } | |
| 36 | + | out := make(map[domain.ObjectID][]byte, len(ids)) | |
| 37 | + | err = g.cat.do(ctx, dir, func(c *catConn) error { | |
| 38 | + | clear(out) // a retry on a fresh process starts over | |
| 39 | + | var total int64 | |
| 40 | + | for _, id := range ids { | |
| 41 | + | if err := ctx.Err(); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | if _, done := out[id]; done { | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | h, err := c.info(id.String()) | |
| 48 | + | if notFound(err) { | |
| 49 | + | continue | |
| 50 | + | } | |
| 51 | + | if err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes { | |
| 55 | + | continue | |
| 56 | + | } | |
| 57 | + | if total+h.Size > domain.MaxCommitObjectsBytes { | |
| 58 | + | break | |
| 59 | + | } | |
| 60 | + | _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes) | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | out[id] = data | |
| 65 | + | total += int64(len(data)) | |
| 66 | + | } | |
| 67 | + | return nil | |
| 68 | + | }) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, fmt.Errorf("read commit objects of %q: %w", repo, err) | |
| 71 | + | } | |
| 72 | + | return out, nil | |
| 73 | + | } | |
A internal/adapters/gitcli/commitobjects_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/ports" | |
| 10 | + | "bnema.dev/ermite/internal/readertest" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestReadCommitObjects(t *testing.T) { | |
| 14 | + | readertest.CommitObjects(t, func(t *testing.T) (ports.CommitObjectReader, string) { | |
| 15 | + | g, data := newGit(t) | |
| 16 | + | t.Cleanup(func() { _ = g.Close(context.Background()) }) | |
| 17 | + | return g, data | |
| 18 | + | }) | |
| 19 | + | } | |
A internal/adapters/kvstore/trustedkeys.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "unicode/utf8" | |
| 12 | + | ||
| 13 | + | kv "bnema.dev/ermite-kv" | |
| 14 | + | "google.golang.org/protobuf/proto" | |
| 15 | + | ||
| 16 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | trustedKeyPrefix = "signing/trusted/" | |
| 23 | + | ||
| 24 | + | // maxTrustedKeyIDLen bounds a key id: a fingerprint is 50 bytes at most. | |
| 25 | + | maxTrustedKeyIDLen = 128 | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // TrustedKeys is the ermite-kv backed ports.TrustedKeyStore. It shares the | |
| 29 | + | // database of the Store it comes from. | |
| 30 | + | type TrustedKeys struct { | |
| 31 | + | db *kv.DB | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | var _ ports.TrustedKeyStore = (*TrustedKeys)(nil) | |
| 35 | + | ||
| 36 | + | // TrustedKeys returns the trusted key store over the same database. It is | |
| 37 | + | // valid until the Store is closed. | |
| 38 | + | func (s *Store) TrustedKeys() *TrustedKeys { return &TrustedKeys{db: s.db} } | |
| 39 | + | ||
| 40 | + | func trustedKeyKey(id string) []byte { return []byte(trustedKeyPrefix + id) } | |
| 41 | + | ||
| 42 | + | // ListTrustedKeys returns the stored keys, oldest first, then by id. | |
| 43 | + | func (s *TrustedKeys) ListTrustedKeys(ctx context.Context) ([]domain.TrustedKey, error) { | |
| 44 | + | var out []domain.TrustedKey | |
| 45 | + | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(trustedKeyPrefix)) { | |
| 46 | + | k, err := decodeTrustedKey(item.Value) | |
| 47 | + | if err != nil { | |
| 48 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 49 | + | } | |
| 50 | + | out = append(out, k) | |
| 51 | + | } | |
| 52 | + | slices.SortStableFunc(out, func(a, b domain.TrustedKey) int { | |
| 53 | + | if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 { | |
| 54 | + | return c | |
| 55 | + | } | |
| 56 | + | return strings.Compare(a.ID, b.ID) | |
| 57 | + | }) | |
| 58 | + | return out, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // CreateTrustedKey stores signing/trusted/<id> with an "absent" precondition: | |
| 62 | + | // a taken id yields domain.ErrAlreadyExists. The caller bounds the number of | |
| 63 | + | // keys. | |
| 64 | + | func (s *TrustedKeys) CreateTrustedKey(ctx context.Context, k domain.TrustedKey) error { | |
| 65 | + | val, err := encodeTrustedKey(k) | |
| 66 | + | if err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | var b kv.Batch | |
| 70 | + | b.RequireAbsent(trustedKeyKey(k.ID)) | |
| 71 | + | b.Put(trustedKeyKey(k.ID), val) | |
| 72 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 73 | + | if errors.Is(err, kv.ErrConflict) { | |
| 74 | + | return domain.ErrAlreadyExists | |
| 75 | + | } | |
| 76 | + | return fmt.Errorf("create trusted key: %w", err) | |
| 77 | + | } | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // DeleteTrustedKey removes signing/trusted/<id>, or returns domain.ErrNotFound. | |
| 82 | + | func (s *TrustedKeys) DeleteTrustedKey(ctx context.Context, id string) error { | |
| 83 | + | if !validTrustedKeyID(id) { | |
| 84 | + | return domain.ErrNotFound | |
| 85 | + | } | |
| 86 | + | for range updateAttempts { | |
| 87 | + | item, ok := s.db.Get(ctx, trustedKeyKey(id)) | |
| 88 | + | if !ok { | |
| 89 | + | return domain.ErrNotFound | |
| 90 | + | } | |
| 91 | + | var b kv.Batch | |
| 92 | + | b.RequireVersion(trustedKeyKey(id), item.Version) | |
| 93 | + | b.Delete(trustedKeyKey(id)) | |
| 94 | + | _, err := s.db.Apply(ctx, &b) | |
| 95 | + | if errors.Is(err, kv.ErrConflict) { | |
| 96 | + | continue // changed or removed meanwhile: read again | |
| 97 | + | } | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("delete trusted key: %w", err) | |
| 100 | + | } | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | return fmt.Errorf("delete trusted key: %w", domain.ErrConflict) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // validTrustedKeyID reports whether id can be a key id: a fingerprint, so | |
| 107 | + | // non-empty, bounded, valid UTF-8 text. | |
| 108 | + | func validTrustedKeyID(id string) bool { | |
| 109 | + | return id != "" && len(id) <= maxTrustedKeyIDLen && utf8.ValidString(id) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func encodeTrustedKey(k domain.TrustedKey) ([]byte, error) { | |
| 113 | + | if !validTrustedKeyID(k.ID) { | |
| 114 | + | return nil, fmt.Errorf("%w: trusted key id has %d bytes", domain.ErrInvalid, len(k.ID)) | |
| 115 | + | } | |
| 116 | + | texts := append([]string{string(k.Format), k.Title, k.PublicKey}, k.Emails...) | |
| 117 | + | for _, s := range texts { | |
| 118 | + | if !utf8.ValidString(s) { | |
| 119 | + | return nil, fmt.Errorf("%w: trusted key text is not valid UTF-8", domain.ErrInvalid) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | return mustMarshal(&storagepb.TrustedKey{ | |
| 123 | + | Id: k.ID, | |
| 124 | + | Format: string(k.Format), | |
| 125 | + | Title: k.Title, | |
| 126 | + | PublicKey: k.PublicKey, | |
| 127 | + | Emails: k.Emails, | |
| 128 | + | CreatedAt: encodeTime(k.CreatedAt), | |
| 129 | + | }), nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func decodeTrustedKey(val []byte) (domain.TrustedKey, error) { | |
| 133 | + | var pb storagepb.TrustedKey | |
| 134 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |
| 135 | + | return domain.TrustedKey{}, fmt.Errorf("decode trusted key: %w", err) | |
| 136 | + | } | |
| 137 | + | if !validTrustedKeyID(pb.GetId()) { | |
| 138 | + | return domain.TrustedKey{}, fmt.Errorf("decode trusted key: id has %d bytes", len(pb.GetId())) | |
| 139 | + | } | |
| 140 | + | return domain.TrustedKey{ | |
| 141 | + | ID: pb.GetId(), | |
| 142 | + | Format: domain.SignatureFormat(pb.GetFormat()), | |
| 143 | + | Title: pb.GetTitle(), | |
| 144 | + | PublicKey: pb.GetPublicKey(), | |
| 145 | + | Emails: slices.Clone(pb.GetEmails()), | |
| 146 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 147 | + | }, nil | |
| 148 | + | } | |
A internal/adapters/kvstore/trustedkeys_test.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func newTrustedKey(id string, at time.Time) domain.TrustedKey { | |
| 18 | + | return domain.TrustedKey{ | |
| 19 | + | ID: id, | |
| 20 | + | Format: domain.FormatSSH, | |
| 21 | + | Title: "key " + id, | |
| 22 | + | PublicKey: "ssh-ed25519 AAAA" + id, | |
| 23 | + | Emails: []string{"ada@example.org", "ada@work.example"}, | |
| 24 | + | CreatedAt: at, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestTrustedKeysRoundTrip(t *testing.T) { | |
| 29 | + | ctx := context.Background() | |
| 30 | + | dir := t.TempDir() | |
| 31 | + | s := openStore(t, dir) | |
| 32 | + | ks := s.TrustedKeys() | |
| 33 | + | ||
| 34 | + | k := newTrustedKey("SHA256:abc/def+ghi", created) | |
| 35 | + | require.NoError(t, ks.CreateTrustedKey(ctx, k)) | |
| 36 | + | gpg := domain.TrustedKey{ | |
| 37 | + | ID: strings.Repeat("AB", 20), Format: domain.FormatGPG, Title: "gpg é", | |
| 38 | + | PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxyz\n-----END PGP PUBLIC KEY BLOCK-----\n", | |
| 39 | + | Emails: []string{"grace@example.org"}, CreatedAt: created.Add(time.Hour), | |
| 40 | + | } | |
| 41 | + | require.NoError(t, ks.CreateTrustedKey(ctx, gpg)) | |
| 42 | + | ||
| 43 | + | list, err := ks.ListTrustedKeys(ctx) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Equal(t, []domain.TrustedKey{k, gpg}, list) | |
| 46 | + | assert.False(t, list[0].Builtin) | |
| 47 | + | ||
| 48 | + | // The keys survive a restart. | |
| 49 | + | require.NoError(t, s.Close(ctx)) | |
| 50 | + | list, err = openStore(t, dir).TrustedKeys().ListTrustedKeys(ctx) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | assert.Equal(t, []domain.TrustedKey{k, gpg}, list) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestTrustedKeysEmptyAndInvalid(t *testing.T) { | |
| 56 | + | ctx := context.Background() | |
| 57 | + | ks := openStore(t, t.TempDir()).TrustedKeys() | |
| 58 | + | ||
| 59 | + | list, err := ks.ListTrustedKeys(ctx) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | assert.Empty(t, list) | |
| 62 | + | ||
| 63 | + | assert.ErrorIs(t, ks.CreateTrustedKey(ctx, domain.TrustedKey{Title: "no id"}), domain.ErrInvalid) | |
| 64 | + | assert.ErrorIs(t, ks.CreateTrustedKey(ctx, newTrustedKey(strings.Repeat("x", 129), created)), domain.ErrInvalid) | |
| 65 | + | bad := newTrustedKey("k", created) | |
| 66 | + | bad.Title = "\xff" | |
| 67 | + | assert.ErrorIs(t, ks.CreateTrustedKey(ctx, bad), domain.ErrInvalid) | |
| 68 | + | list, err = ks.ListTrustedKeys(ctx) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | assert.Empty(t, list) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestTrustedKeysDuplicateAndUnknown(t *testing.T) { | |
| 74 | + | ctx := context.Background() | |
| 75 | + | ks := openStore(t, t.TempDir()).TrustedKeys() | |
| 76 | + | k := newTrustedKey("SHA256:one", created) | |
| 77 | + | require.NoError(t, ks.CreateTrustedKey(ctx, k)) | |
| 78 | + | ||
| 79 | + | dup := newTrustedKey("SHA256:one", created.Add(time.Hour)) | |
| 80 | + | dup.Title = "other" | |
| 81 | + | assert.ErrorIs(t, ks.CreateTrustedKey(ctx, dup), domain.ErrAlreadyExists) | |
| 82 | + | list, err := ks.ListTrustedKeys(ctx) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | assert.Equal(t, []domain.TrustedKey{k}, list, "the first key is kept") | |
| 85 | + | ||
| 86 | + | assert.ErrorIs(t, ks.DeleteTrustedKey(ctx, "SHA256:nope"), domain.ErrNotFound) | |
| 87 | + | assert.ErrorIs(t, ks.DeleteTrustedKey(ctx, ""), domain.ErrNotFound) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestTrustedKeysDelete(t *testing.T) { | |
| 91 | + | ctx := context.Background() | |
| 92 | + | ks := openStore(t, t.TempDir()).TrustedKeys() | |
| 93 | + | a, b := newTrustedKey("a", created), newTrustedKey("b", created.Add(time.Hour)) | |
| 94 | + | require.NoError(t, ks.CreateTrustedKey(ctx, a)) | |
| 95 | + | require.NoError(t, ks.CreateTrustedKey(ctx, b)) | |
| 96 | + | ||
| 97 | + | require.NoError(t, ks.DeleteTrustedKey(ctx, "a")) | |
| 98 | + | assert.ErrorIs(t, ks.DeleteTrustedKey(ctx, "a"), domain.ErrNotFound) | |
| 99 | + | list, err := ks.ListTrustedKeys(ctx) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Equal(t, []domain.TrustedKey{b}, list) | |
| 102 | + | ||
| 103 | + | // A deleted id can be trusted again. | |
| 104 | + | require.NoError(t, ks.CreateTrustedKey(ctx, a)) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // List orders by creation time, then by id. | |
| 108 | + | func TestTrustedKeysListOrder(t *testing.T) { | |
| 109 | + | ctx := context.Background() | |
| 110 | + | ks := openStore(t, t.TempDir()).TrustedKeys() | |
| 111 | + | for _, k := range []domain.TrustedKey{ | |
| 112 | + | newTrustedKey("c", created.Add(2*time.Hour)), | |
| 113 | + | newTrustedKey("b", created), | |
| 114 | + | newTrustedKey("a", created), | |
| 115 | + | newTrustedKey("d", created.Add(time.Hour)), | |
| 116 | + | } { | |
| 117 | + | require.NoError(t, ks.CreateTrustedKey(ctx, k)) | |
| 118 | + | } | |
| 119 | + | list, err := ks.ListTrustedKeys(ctx) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | var ids []string | |
| 122 | + | for _, k := range list { | |
| 123 | + | ids = append(ids, k.ID) | |
| 124 | + | } | |
| 125 | + | assert.Equal(t, []string{"a", "b", "d", "c"}, ids) | |
| 126 | + | } | |
M internal/adapters/rpc/api.go +11 -1
| @@ -34,7 +34,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||
| 34 | 34 | // Features are the optional capabilities of the contract that this server | |
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | - | // are. | |
| 37 | + | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | |
| 38 | 38 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"} | |
| 39 | 39 | ||
| 40 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| @@ -92,6 +92,10 @@ type Config struct { | |||
| 92 | 92 | // Same rule as Sessions about typed nil pointers. | |
| 93 | 93 | JobArtifacts ports.JobArtifactService | |
| 94 | 94 | Artifacts ports.ArtifactService | |
| 95 | + | // TrustedKeys serves TrustedKeyService, and GetVersion lists | |
| 96 | + | // "commit_signatures", when set (ADR 0030). Same rule as Sessions about | |
| 97 | + | // typed nil pointers. | |
| 98 | + | TrustedKeys ports.TrustedKeyService | |
| 95 | 99 | } | |
| 96 | 100 | ||
| 97 | 101 | // API is the set of Connect handlers of the contract. | |
| @@ -133,11 +137,17 @@ func New(cfg Config) *API { | |||
| 133 | 137 | if cfg.OIDCKeys != nil { | |
| 134 | 138 | features = append(features, FeatureCIOIDC) | |
| 135 | 139 | } | |
| 140 | + | if cfg.TrustedKeys != nil { | |
| 141 | + | features = append(features, FeatureCommitSignatures) | |
| 142 | + | } | |
| 136 | 143 | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...)) | |
| 137 | 144 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 138 | 145 | if cfg.Sessions != nil { | |
| 139 | 146 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 140 | 147 | } | |
| 148 | + | if cfg.TrustedKeys != nil { | |
| 149 | + | mux.Handle(ermiteconnect.NewTrustedKeyServiceHandler(&trustedKeyService{cfg.TrustedKeys}, opts...)) | |
| 150 | + | } | |
| 141 | 151 | if settings { | |
| 142 | 152 | mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...)) | |
| 143 | 153 | mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...)) | |
M internal/adapters/rpc/convert.go +76 -0
| @@ -227,9 +227,85 @@ func commitToPB(c domain.Commit) *pb.Commit { | |||
| 227 | 227 | for _, p := range c.Parents { | |
| 228 | 228 | out.ParentIds = append(out.ParentIds, p.String()) | |
| 229 | 229 | } | |
| 230 | + | if c.Verification.Status != domain.StatusUnchecked { | |
| 231 | + | out.Verification = verificationToPB(c.Verification) | |
| 232 | + | } | |
| 230 | 233 | return out | |
| 231 | 234 | } | |
| 232 | 235 | ||
| 236 | + | func verificationToPB(v domain.CommitVerification) *pb.CommitVerification { | |
| 237 | + | return &pb.CommitVerification{ | |
| 238 | + | Format: signatureFormatToPB(v.Format), | |
| 239 | + | Status: signatureStatusToPB(v.Status), | |
| 240 | + | Fingerprint: v.Fingerprint, | |
| 241 | + | KeyId: v.KeyID, | |
| 242 | + | KeyTitle: v.KeyTitle, | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func signatureFormatToPB(f domain.SignatureFormat) pb.SignatureFormat { | |
| 247 | + | switch f { | |
| 248 | + | case domain.FormatSSH: | |
| 249 | + | return pb.SignatureFormat_SIGNATURE_FORMAT_SSH | |
| 250 | + | case domain.FormatGPG: | |
| 251 | + | return pb.SignatureFormat_SIGNATURE_FORMAT_GPG | |
| 252 | + | case domain.FormatX509: | |
| 253 | + | return pb.SignatureFormat_SIGNATURE_FORMAT_X509 | |
| 254 | + | case domain.FormatNone: | |
| 255 | + | return pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED | |
| 256 | + | } | |
| 257 | + | return pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // signatureFormatFromPB maps the format of a key to add: only SSH and GPG keys | |
| 261 | + | // can be trusted. | |
| 262 | + | func signatureFormatFromPB(f pb.SignatureFormat) (domain.SignatureFormat, error) { | |
| 263 | + | switch f { | |
| 264 | + | case pb.SignatureFormat_SIGNATURE_FORMAT_SSH: | |
| 265 | + | return domain.FormatSSH, nil | |
| 266 | + | case pb.SignatureFormat_SIGNATURE_FORMAT_GPG: | |
| 267 | + | return domain.FormatGPG, nil | |
| 268 | + | case pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, pb.SignatureFormat_SIGNATURE_FORMAT_X509: | |
| 269 | + | } | |
| 270 | + | return domain.FormatNone, fmt.Errorf("%w: key format must be ssh or gpg", domain.ErrInvalid) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func signatureStatusToPB(s domain.SignatureStatus) pb.SignatureStatus { | |
| 274 | + | switch s { | |
| 275 | + | case domain.StatusUnsigned: | |
| 276 | + | return pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED | |
| 277 | + | case domain.StatusVerified: | |
| 278 | + | return pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED | |
| 279 | + | case domain.StatusUnknownKey: | |
| 280 | + | return pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY | |
| 281 | + | case domain.StatusBadSignature: | |
| 282 | + | return pb.SignatureStatus_SIGNATURE_STATUS_BAD_SIGNATURE | |
| 283 | + | case domain.StatusEmailMismatch: | |
| 284 | + | return pb.SignatureStatus_SIGNATURE_STATUS_EMAIL_MISMATCH | |
| 285 | + | case domain.StatusExpired: | |
| 286 | + | return pb.SignatureStatus_SIGNATURE_STATUS_EXPIRED | |
| 287 | + | case domain.StatusRevoked: | |
| 288 | + | return pb.SignatureStatus_SIGNATURE_STATUS_REVOKED | |
| 289 | + | case domain.StatusUnsupported: | |
| 290 | + | return pb.SignatureStatus_SIGNATURE_STATUS_UNSUPPORTED | |
| 291 | + | case domain.StatusUnchecked: | |
| 292 | + | return pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED | |
| 293 | + | } | |
| 294 | + | return pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func trustedKeyToPB(k domain.TrustedKey) *pb.TrustedKey { | |
| 298 | + | return &pb.TrustedKey{ | |
| 299 | + | Id: k.ID, | |
| 300 | + | Format: signatureFormatToPB(k.Format), | |
| 301 | + | Title: k.Title, | |
| 302 | + | PublicKey: k.PublicKey, | |
| 303 | + | Emails: slices.Clone(k.Emails), | |
| 304 | + | CreatedAt: timestamp(k.CreatedAt), | |
| 305 | + | Builtin: k.Builtin, | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 233 | 309 | func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry { | |
| 234 | 310 | out := &pb.TreeEntry{ | |
| 235 | 311 | Name: e.Name, | |
A internal/adapters/rpc/trusted_keys.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | ||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // FeatureCommitSignatures is listed by GetVersion when the server verifies | |
| 15 | + | // commit signatures: Commit.verification and TrustedKeyService (ADR 0030). | |
| 16 | + | const FeatureCommitSignatures = "commit_signatures" | |
| 17 | + | ||
| 18 | + | // trustedKeyService implements ermiteconnect.TrustedKeyServiceHandler. The | |
| 19 | + | // owner check of add and remove belongs to the use case. | |
| 20 | + | type trustedKeyService struct { | |
| 21 | + | keys ports.TrustedKeyService | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (s *trustedKeyService) ListTrustedKeys(ctx context.Context, _ *connect.Request[pb.ListTrustedKeysRequest]) (*connect.Response[pb.ListTrustedKeysResponse], error) { | |
| 25 | + | list, err := s.keys.ListTrustedKeys(ctx, caller(ctx)) | |
| 26 | + | if err != nil { | |
| 27 | + | return nil, connectError(ctx, err) | |
| 28 | + | } | |
| 29 | + | out := make([]*pb.TrustedKey, 0, len(list)) | |
| 30 | + | for _, k := range list { | |
| 31 | + | out = append(out, trustedKeyToPB(k)) | |
| 32 | + | } | |
| 33 | + | return connect.NewResponse(&pb.ListTrustedKeysResponse{Keys: out}), nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (s *trustedKeyService) AddTrustedKey(ctx context.Context, req *connect.Request[pb.AddTrustedKeyRequest]) (*connect.Response[pb.AddTrustedKeyResponse], error) { | |
| 37 | + | format, err := signatureFormatFromPB(req.Msg.GetFormat()) | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, connectError(ctx, err) | |
| 40 | + | } | |
| 41 | + | k, err := s.keys.AddTrustedKey(ctx, caller(ctx), format, req.Msg.GetTitle(), req.Msg.GetPublicKey(), req.Msg.GetEmails()) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, connectError(ctx, err) | |
| 44 | + | } | |
| 45 | + | return connect.NewResponse(&pb.AddTrustedKeyResponse{Key: trustedKeyToPB(k)}), nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s *trustedKeyService) RemoveTrustedKey(ctx context.Context, req *connect.Request[pb.RemoveTrustedKeyRequest]) (*connect.Response[pb.RemoveTrustedKeyResponse], error) { | |
| 49 | + | if err := s.keys.RemoveTrustedKey(ctx, caller(ctx), req.Msg.GetId()); err != nil { | |
| 50 | + | return nil, connectError(ctx, err) | |
| 51 | + | } | |
| 52 | + | return connect.NewResponse(&pb.RemoveTrustedKeyResponse{}), nil | |
| 53 | + | } | |
A internal/adapters/rpc/trusted_keys_test.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "slices" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 19 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | type statusCase struct { | |
| 25 | + | domain domain.SignatureStatus | |
| 26 | + | pb pb.SignatureStatus | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type formatCase struct { | |
| 30 | + | domain domain.SignatureFormat | |
| 31 | + | pb pb.SignatureFormat | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | var ( | |
| 35 | + | statusCases = []statusCase{ | |
| 36 | + | {domain.StatusUnsigned, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED}, | |
| 37 | + | {domain.StatusVerified, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED}, | |
| 38 | + | {domain.StatusUnknownKey, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY}, | |
| 39 | + | {domain.StatusBadSignature, pb.SignatureStatus_SIGNATURE_STATUS_BAD_SIGNATURE}, | |
| 40 | + | {domain.StatusEmailMismatch, pb.SignatureStatus_SIGNATURE_STATUS_EMAIL_MISMATCH}, | |
| 41 | + | {domain.StatusExpired, pb.SignatureStatus_SIGNATURE_STATUS_EXPIRED}, | |
| 42 | + | {domain.StatusRevoked, pb.SignatureStatus_SIGNATURE_STATUS_REVOKED}, | |
| 43 | + | {domain.StatusUnsupported, pb.SignatureStatus_SIGNATURE_STATUS_UNSUPPORTED}, | |
| 44 | + | } | |
| 45 | + | formatCases = []formatCase{ | |
| 46 | + | {domain.FormatSSH, pb.SignatureFormat_SIGNATURE_FORMAT_SSH}, | |
| 47 | + | {domain.FormatGPG, pb.SignatureFormat_SIGNATURE_FORMAT_GPG}, | |
| 48 | + | {domain.FormatX509, pb.SignatureFormat_SIGNATURE_FORMAT_X509}, | |
| 49 | + | } | |
| 50 | + | ) | |
| 51 | + | ||
| 52 | + | func TestSignatureEnumMappers(t *testing.T) { | |
| 53 | + | t.Parallel() | |
| 54 | + | for _, c := range statusCases { | |
| 55 | + | assert.Equal(t, c.pb, signatureStatusToPB(c.domain), c.domain) | |
| 56 | + | } | |
| 57 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED, signatureStatusToPB(domain.StatusUnchecked)) | |
| 58 | + | for _, c := range formatCases { | |
| 59 | + | assert.Equal(t, c.pb, signatureFormatToPB(c.domain), c.domain) | |
| 60 | + | } | |
| 61 | + | assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, signatureFormatToPB(domain.FormatNone)) | |
| 62 | + | ||
| 63 | + | // Nothing in the contract is unreachable. | |
| 64 | + | for v, n := range pb.SignatureStatus_name { | |
| 65 | + | if v != 0 { | |
| 66 | + | assert.True(t, slices.ContainsFunc(statusCases, func(c statusCase) bool { | |
| 67 | + | return c.pb == pb.SignatureStatus(v) | |
| 68 | + | }), "%s is never produced", n) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | for v, n := range pb.SignatureFormat_name { | |
| 72 | + | if v != 0 { | |
| 73 | + | assert.True(t, slices.ContainsFunc(formatCases, func(c formatCase) bool { | |
| 74 | + | return c.pb == pb.SignatureFormat(v) | |
| 75 | + | }), "%s is never produced", n) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestSignatureFormatFromPB(t *testing.T) { | |
| 81 | + | t.Parallel() | |
| 82 | + | f, err := signatureFormatFromPB(pb.SignatureFormat_SIGNATURE_FORMAT_SSH) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | assert.Equal(t, domain.FormatSSH, f) | |
| 85 | + | f, err = signatureFormatFromPB(pb.SignatureFormat_SIGNATURE_FORMAT_GPG) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | assert.Equal(t, domain.FormatGPG, f) | |
| 88 | + | for _, bad := range []pb.SignatureFormat{pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, pb.SignatureFormat_SIGNATURE_FORMAT_X509, pb.SignatureFormat(99)} { | |
| 89 | + | _, err = signatureFormatFromPB(bad) | |
| 90 | + | assert.ErrorIs(t, err, domain.ErrInvalid, bad) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestCommitToPBVerification(t *testing.T) { | |
| 95 | + | t.Parallel() | |
| 96 | + | t.Run("unchecked sets nothing", func(t *testing.T) { | |
| 97 | + | t.Parallel() | |
| 98 | + | assert.Nil(t, commitToPB(domain.Commit{}).GetVerification()) | |
| 99 | + | }) | |
| 100 | + | t.Run("unsigned", func(t *testing.T) { | |
| 101 | + | t.Parallel() | |
| 102 | + | v := commitToPB(domain.Commit{Verification: domain.CommitVerification{Status: domain.StatusUnsigned}}).GetVerification() | |
| 103 | + | require.NotNil(t, v) | |
| 104 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED, v.GetStatus()) | |
| 105 | + | assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, v.GetFormat()) | |
| 106 | + | }) | |
| 107 | + | t.Run("verified", func(t *testing.T) { | |
| 108 | + | t.Parallel() | |
| 109 | + | v := commitToPB(domain.Commit{Verification: domain.CommitVerification{ | |
| 110 | + | Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:abc", KeyID: "SHA256:abc", KeyTitle: "laptop", | |
| 111 | + | }}).GetVerification() | |
| 112 | + | require.NotNil(t, v) | |
| 113 | + | assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, v.GetFormat()) | |
| 114 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, v.GetStatus()) | |
| 115 | + | assert.Equal(t, "SHA256:abc", v.GetFingerprint()) | |
| 116 | + | assert.Equal(t, "SHA256:abc", v.GetKeyId()) | |
| 117 | + | assert.Equal(t, "laptop", v.GetKeyTitle()) | |
| 118 | + | }) | |
| 119 | + | t.Run("every status", func(t *testing.T) { | |
| 120 | + | t.Parallel() | |
| 121 | + | for _, c := range statusCases { | |
| 122 | + | v := commitToPB(domain.Commit{Verification: domain.CommitVerification{Format: domain.FormatGPG, Status: c.domain}}).GetVerification() | |
| 123 | + | require.NotNil(t, v, c.domain) | |
| 124 | + | assert.Equal(t, c.pb, v.GetStatus()) | |
| 125 | + | } | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestTrustedKeyServiceList(t *testing.T) { | |
| 130 | + | t.Parallel() | |
| 131 | + | created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 132 | + | keys := []domain.TrustedKey{ | |
| 133 | + | {ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"ermite@example.com"}, Builtin: true}, | |
| 134 | + | {ID: "ABCD", Format: domain.FormatGPG, Title: "me", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: created}, | |
| 135 | + | } | |
| 136 | + | tests := []struct { | |
| 137 | + | name string | |
| 138 | + | list []domain.TrustedKey | |
| 139 | + | usecase error | |
| 140 | + | wantCode connect.Code | |
| 141 | + | }{ | |
| 142 | + | {name: "ok", list: keys}, | |
| 143 | + | {name: "empty"}, | |
| 144 | + | {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 145 | + | {name: "unknown error", usecase: errors.New("disk on fire"), wantCode: connect.CodeInternal}, | |
| 146 | + | } | |
| 147 | + | for _, tt := range tests { | |
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |
| 149 | + | t.Parallel() | |
| 150 | + | svc := mocks.NewTrustedKeyService(t) | |
| 151 | + | svc.EXPECT().ListTrustedKeys(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once() | |
| 152 | + | resp, err := (&trustedKeyService{svc}).ListTrustedKeys(ownerCtx(), connect.NewRequest(&pb.ListTrustedKeysRequest{})) | |
| 153 | + | if tt.wantCode != 0 { | |
| 154 | + | requireCode(t, tt.wantCode, err) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Len(t, resp.Msg.GetKeys(), len(tt.list)) | |
| 159 | + | for i, want := range tt.list { | |
| 160 | + | got := resp.Msg.GetKeys()[i] | |
| 161 | + | assert.Equal(t, want.ID, got.GetId()) | |
| 162 | + | assert.Equal(t, signatureFormatToPB(want.Format), got.GetFormat()) | |
| 163 | + | assert.Equal(t, want.Title, got.GetTitle()) | |
| 164 | + | assert.Equal(t, want.PublicKey, got.GetPublicKey()) | |
| 165 | + | assert.Equal(t, want.Emails, got.GetEmails()) | |
| 166 | + | assert.Equal(t, want.Builtin, got.GetBuiltin()) | |
| 167 | + | assert.Equal(t, want.CreatedAt.IsZero(), got.GetCreatedAt() == nil) | |
| 168 | + | if !want.CreatedAt.IsZero() { | |
| 169 | + | assert.True(t, created.Equal(got.GetCreatedAt().AsTime())) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestTrustedKeyServiceAdd(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | stored := domain.TrustedKey{ID: "SHA256:k", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"a@b.c"}} | |
| 179 | + | tests := []struct { | |
| 180 | + | name string | |
| 181 | + | format pb.SignatureFormat | |
| 182 | + | usecase error | |
| 183 | + | callsUC bool | |
| 184 | + | wantCode connect.Code | |
| 185 | + | }{ | |
| 186 | + | {name: "ssh", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true}, | |
| 187 | + | {name: "unspecified format", format: pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, wantCode: connect.CodeInvalidArgument}, | |
| 188 | + | {name: "x509 format", format: pb.SignatureFormat_SIGNATURE_FORMAT_X509, wantCode: connect.CodeInvalidArgument}, | |
| 189 | + | {name: "not the owner", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 190 | + | {name: "invalid key", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument}, | |
| 191 | + | {name: "already listed", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists}, | |
| 192 | + | {name: "too many", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrResourceExhausted, wantCode: connect.CodeResourceExhausted}, | |
| 193 | + | } | |
| 194 | + | for _, tt := range tests { | |
| 195 | + | t.Run(tt.name, func(t *testing.T) { | |
| 196 | + | t.Parallel() | |
| 197 | + | svc := mocks.NewTrustedKeyService(t) | |
| 198 | + | if tt.callsUC { | |
| 199 | + | ret := stored | |
| 200 | + | if tt.usecase != nil { | |
| 201 | + | ret = domain.TrustedKey{} | |
| 202 | + | } | |
| 203 | + | svc.EXPECT().AddTrustedKey(mock.Anything, domain.Owner(), domain.FormatSSH, "laptop", "ssh-ed25519 AAAA", []string{"a@b.c"}).Return(ret, tt.usecase).Once() | |
| 204 | + | } | |
| 205 | + | resp, err := (&trustedKeyService{svc}).AddTrustedKey(ownerCtx(), connect.NewRequest(&pb.AddTrustedKeyRequest{ | |
| 206 | + | Format: tt.format, Title: "laptop", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"a@b.c"}, | |
| 207 | + | })) | |
| 208 | + | if tt.wantCode != 0 { | |
| 209 | + | requireCode(t, tt.wantCode, err) | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | require.NoError(t, err) | |
| 213 | + | assert.Equal(t, "SHA256:k", resp.Msg.GetKey().GetId()) | |
| 214 | + | assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, resp.Msg.GetKey().GetFormat()) | |
| 215 | + | }) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestTrustedKeyServiceRemove(t *testing.T) { | |
| 220 | + | t.Parallel() | |
| 221 | + | tests := []struct { | |
| 222 | + | name string | |
| 223 | + | usecase error | |
| 224 | + | wantCode connect.Code | |
| 225 | + | }{ | |
| 226 | + | {name: "ok"}, | |
| 227 | + | {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 228 | + | {name: "unknown id", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |
| 229 | + | {name: "builtin", usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition}, | |
| 230 | + | } | |
| 231 | + | for _, tt := range tests { | |
| 232 | + | t.Run(tt.name, func(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 234 | + | svc := mocks.NewTrustedKeyService(t) | |
| 235 | + | svc.EXPECT().RemoveTrustedKey(mock.Anything, domain.Owner(), "SHA256:k").Return(tt.usecase).Once() | |
| 236 | + | _, err := (&trustedKeyService{svc}).RemoveTrustedKey(ownerCtx(), connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: "SHA256:k"})) | |
| 237 | + | if tt.wantCode != 0 { | |
| 238 | + | requireCode(t, tt.wantCode, err) | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // GetVersion lists "commit_signatures", and the service is mounted, only when | |
| 247 | + | // the API is built with trusted keys. | |
| 248 | + | func TestAPICommitSignaturesFeature(t *testing.T) { | |
| 249 | + | t.Parallel() | |
| 250 | + | serve := func(cfg Config) (features []string, keys ermiteconnect.TrustedKeyServiceClient) { | |
| 251 | + | cfg.SigningKey = mocks.NewSigningKeyService(t) | |
| 252 | + | srv := httptest.NewServer(New(cfg).Handler()) | |
| 253 | + | t.Cleanup(srv.Close) | |
| 254 | + | c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL) | |
| 255 | + | resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | return resp.Msg.GetFeatures(), ermiteconnect.NewTrustedKeyServiceClient(srv.Client(), srv.URL) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | features, keys := serve(Config{}) | |
| 261 | + | assert.NotContains(t, features, "commit_signatures") | |
| 262 | + | assert.NotContains(t, Features, "commit_signatures", "the shared list is not modified") | |
| 263 | + | _, err := keys.ListTrustedKeys(context.Background(), connect.NewRequest(&pb.ListTrustedKeysRequest{})) | |
| 264 | + | assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err)) | |
| 265 | + | ||
| 266 | + | svc := mocks.NewTrustedKeyService(t) | |
| 267 | + | features, keys = serve(Config{TrustedKeys: svc, Auth: mocks.NewAuthService(t)}) | |
| 268 | + | assert.Contains(t, features, "commit_signatures") | |
| 269 | + | // An anonymous caller reaches the use case, which refuses it. | |
| 270 | + | svc.EXPECT().ListTrustedKeys(mock.Anything, domain.Anonymous()).Return(nil, domain.ErrUnauthenticated).Once() | |
| 271 | + | _, err = keys.ListTrustedKeys(context.Background(), connect.NewRequest(&pb.ListTrustedKeysRequest{})) | |
| 272 | + | assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) | |
| 273 | + | } | |
A internal/adapters/sigverify/gpg.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/ProtonMail/go-crypto/openpgp" | |
| 15 | + | "github.com/ProtonMail/go-crypto/openpgp/armor" | |
| 16 | + | pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" | |
| 17 | + | "github.com/ProtonMail/go-crypto/openpgp/packet" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // acceptedHashes are the digest algorithms of the signatures GPG accepts. The | |
| 24 | + | // library's own reject lists (packet.Config.RejectMessageHashAlgorithms) are | |
| 25 | + | // not applied to detached signatures, so the allowlist is enforced explicitly: | |
| 26 | + | // a signature over MD5, SHA-1, RIPEMD-160 or SHA-224 is a bad signature. | |
| 27 | + | var acceptedHashes = []crypto.Hash{crypto.SHA256, crypto.SHA384, crypto.SHA512, crypto.SHA3_256, crypto.SHA3_512} | |
| 28 | + | ||
| 29 | + | // GPG verifies OpenPGP detached signatures. It ignores the trusted keys that | |
| 30 | + | // are not GPG keys. | |
| 31 | + | // | |
| 32 | + | // Time policy: the committer date is not used. Key, subkey and signature | |
| 33 | + | // expiry are judged at the creation time of the signature, which the | |
| 34 | + | // signature authenticates, so a key that has expired since still verifies | |
| 35 | + | // what it signed while it was valid. Revocation is judged now: a signature by | |
| 36 | + | // a key that is revoked today (key, signing subkey or primary user ID) is | |
| 37 | + | // reported as revoked, whenever it was made. | |
| 38 | + | type GPG struct{} | |
| 39 | + | ||
| 40 | + | var _ ports.SignatureVerifier = GPG{} | |
| 41 | + | ||
| 42 | + | // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time | |
| 43 | + | // policy of GPG. | |
| 44 | + | // | |
| 45 | + | //hexcheck:ignore suspicious-business-logic-in-adapter this is the OpenPGP protocol check the port delegates to the adapter; the trust and email rules stay in domain | |
| 46 | + | func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | |
| 47 | + | v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature} | |
| 48 | + | defer func() { // a parser bug must not take the server down | |
| 49 | + | if recover() != nil { | |
| 50 | + | v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint} | |
| 51 | + | } | |
| 52 | + | }() | |
| 53 | + | if len(signature) > maxSignatureBytes { | |
| 54 | + | return v | |
| 55 | + | } | |
| 56 | + | block, err := armor.Decode(bytes.NewReader(signature)) | |
| 57 | + | if err != nil || block.Type != openpgp.SignatureType { | |
| 58 | + | return v | |
| 59 | + | } | |
| 60 | + | body, err := io.ReadAll(io.LimitReader(block.Body, maxSignatureBytes)) | |
| 61 | + | if err != nil { | |
| 62 | + | return v | |
| 63 | + | } | |
| 64 | + | sig, ok := onlySignature(body) | |
| 65 | + | if !ok { | |
| 66 | + | return v | |
| 67 | + | } | |
| 68 | + | issuer := *sig.IssuerKeyId | |
| 69 | + | v.Fingerprint = fmt.Sprintf("%016X", issuer) | |
| 70 | + | ||
| 71 | + | ring, byFingerprint := gpgKeyring(keys) | |
| 72 | + | created := sig.CreationTime | |
| 73 | + | cfg := &packet.Config{Time: func() time.Time { return created }} | |
| 74 | + | _, signer, err := openpgp.VerifyDetachedSignatureAndHash(ring, bytes.NewReader(payload), bytes.NewReader(body), acceptedHashes, cfg) | |
| 75 | + | if signer == nil && !errors.Is(err, pgperrors.ErrUnknownIssuer) { | |
| 76 | + | // The check failed before naming the signer: look it up for the fingerprint. | |
| 77 | + | if known := ring.KeysById(issuer); len(known) > 0 { | |
| 78 | + | signer = known[0].Entity | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | if signer != nil { | |
| 82 | + | v.Fingerprint = fingerprintHex(signer) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | if signer != nil && (err == nil || errors.Is(err, pgperrors.ErrKeyExpired) || errors.Is(err, pgperrors.ErrSignatureExpired)) && | |
| 86 | + | revokedNow(ring.KeysById(issuer), signer) { | |
| 87 | + | // The library judged revocation at the signature date: a revocation | |
| 88 | + | // published since is only seen with the current time. | |
| 89 | + | v.Status = domain.StatusRevoked | |
| 90 | + | return v | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | switch { | |
| 94 | + | case err == nil: | |
| 95 | + | case errors.Is(err, pgperrors.ErrUnknownIssuer): | |
| 96 | + | v.Status = domain.StatusUnknownKey | |
| 97 | + | return v | |
| 98 | + | case errors.Is(err, pgperrors.ErrKeyExpired), errors.Is(err, pgperrors.ErrSignatureExpired): | |
| 99 | + | v.Status = domain.StatusExpired | |
| 100 | + | return v | |
| 101 | + | case errors.Is(err, pgperrors.ErrKeyRevoked): | |
| 102 | + | v.Status = domain.StatusRevoked | |
| 103 | + | return v | |
| 104 | + | default: | |
| 105 | + | return v | |
| 106 | + | } | |
| 107 | + | if key, ok := byFingerprint[v.Fingerprint]; ok { | |
| 108 | + | return trusted(v, key, committerEmail) | |
| 109 | + | } | |
| 110 | + | v.Status = domain.StatusUnknownKey | |
| 111 | + | return v | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // onlySignature returns the signature packet of body when body holds exactly | |
| 115 | + | // one packet, a signature with an issuer key id and a creation time. One | |
| 116 | + | // packet only: the library verifies the first packet whose issuer it knows, | |
| 117 | + | // so with several the clock and the revocation check could be taken from a | |
| 118 | + | // packet other than the one that verified. | |
| 119 | + | func onlySignature(body []byte) (*packet.Signature, bool) { | |
| 120 | + | r := bytes.NewReader(body) | |
| 121 | + | p, err := packet.Read(r) | |
| 122 | + | if err != nil { | |
| 123 | + | return nil, false | |
| 124 | + | } | |
| 125 | + | if _, err := packet.Read(r); !errors.Is(err, io.EOF) { | |
| 126 | + | return nil, false | |
| 127 | + | } | |
| 128 | + | sig, ok := p.(*packet.Signature) | |
| 129 | + | if !ok || sig.IssuerKeyId == nil || sig.CreationTime.IsZero() { | |
| 130 | + | return nil, false | |
| 131 | + | } | |
| 132 | + | return sig, true | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // revokedNow reports whether the entity, the signing key among keys (the | |
| 136 | + | // primary key or a subkey) or the primary user ID is revoked at the current | |
| 137 | + | // time. | |
| 138 | + | func revokedNow(keys []openpgp.Key, signer *openpgp.Entity) bool { | |
| 139 | + | now := time.Now() | |
| 140 | + | if signer.Revoked(now) { | |
| 141 | + | return true | |
| 142 | + | } | |
| 143 | + | if _, id := signer.PrimarySelfSignature(); id != nil && id.Revoked(now) { | |
| 144 | + | return true | |
| 145 | + | } | |
| 146 | + | for _, k := range keys { | |
| 147 | + | if k.Entity == signer && k.Revoked(now) { | |
| 148 | + | return true | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | return false | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // gpgKeyring builds the keyring of the trusted GPG keys, and indexes the | |
| 155 | + | // trusted keys by the primary fingerprint. Unreadable keys and private keys | |
| 156 | + | // are skipped. | |
| 157 | + | func gpgKeyring(keys []domain.TrustedKey) (openpgp.EntityList, map[string]domain.TrustedKey) { | |
| 158 | + | var ring openpgp.EntityList | |
| 159 | + | byFingerprint := make(map[string]domain.TrustedKey, len(keys)) | |
| 160 | + | for _, k := range keys { | |
| 161 | + | if k.Format != domain.FormatGPG || len(k.PublicKey) > domain.MaxTrustedKeyBytes { | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | entities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(k.PublicKey)) | |
| 165 | + | if err != nil { | |
| 166 | + | continue | |
| 167 | + | } | |
| 168 | + | for _, e := range entities { | |
| 169 | + | if hasPrivateKey(e) { | |
| 170 | + | continue | |
| 171 | + | } | |
| 172 | + | ring = append(ring, e) | |
| 173 | + | byFingerprint[fingerprintHex(e)] = k | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | return ring, byFingerprint | |
| 177 | + | } | |
A internal/adapters/sigverify/gpg_test.go +277 -0
| @@ -0,0 +1,277 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/ProtonMail/go-crypto/openpgp" | |
| 13 | + | "github.com/ProtonMail/go-crypto/openpgp/armor" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/adapters/sigverify" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func TestGPGVerify(t *testing.T) { | |
| 22 | + | const email = "dev@example.org" | |
| 23 | + | g := newGPGHome(t) | |
| 24 | + | for name, algo := range map[string]string{"ed25519": "ed25519", "rsa": "rsa2048"} { | |
| 25 | + | fpr := g.generate(name+" <"+email+">", algo, "never") | |
| 26 | + | pub := g.public(fpr) | |
| 27 | + | otherFpr := g.generate("other-"+name+" <other@example.org>", "ed25519", "never") | |
| 28 | + | otherPub := g.public(otherFpr) | |
| 29 | + | c := g.commit(fpr, email) | |
| 30 | + | ||
| 31 | + | t.Run(name, func(t *testing.T) { | |
| 32 | + | tamperedPayload := append(bytes.Clone(c.payload), '!') | |
| 33 | + | tests := []struct { | |
| 34 | + | name string | |
| 35 | + | payload []byte | |
| 36 | + | signature []byte | |
| 37 | + | keys []domain.TrustedKey | |
| 38 | + | email string | |
| 39 | + | want domain.SignatureStatus | |
| 40 | + | wantFpr string | |
| 41 | + | wantKey bool | |
| 42 | + | }{ | |
| 43 | + | {"verified", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusVerified, fpr, true}, | |
| 44 | + | {"email case is ignored", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, "Dev@EXAMPLE.org", domain.StatusVerified, fpr, true}, | |
| 45 | + | {"verified among other keys", c.payload, c.signature, []domain.TrustedKey{trustedGPG("garbage", email), {ID: "s", Format: domain.FormatSSH, PublicKey: "x"}, trustedGPG(otherPub, email), trustedGPG(pub, email)}, email, domain.StatusVerified, fpr, true}, | |
| 46 | + | {"email mismatch", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, "other@example.org")}, email, domain.StatusEmailMismatch, fpr, true}, | |
| 47 | + | {"unknown key", c.payload, c.signature, []domain.TrustedKey{trustedGPG(otherPub, email)}, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false}, | |
| 48 | + | {"no trusted key", c.payload, c.signature, nil, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false}, | |
| 49 | + | {"ssh keys are ignored", c.payload, c.signature, []domain.TrustedKey{{ID: "x", Format: domain.FormatSSH, PublicKey: pub, Emails: []string{email}}}, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false}, | |
| 50 | + | {"tampered payload", tamperedPayload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusBadSignature, fpr, false}, | |
| 51 | + | {"empty signature", c.payload, nil, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusBadSignature, "", false}, | |
| 52 | + | } | |
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | v := sigverify.GPG{}.Verify(tt.payload, tt.signature, tt.email, c.signedAt, tt.keys) | |
| 56 | + | assert.Equal(t, domain.FormatGPG, v.Format) | |
| 57 | + | assert.Equal(t, tt.want, v.Status) | |
| 58 | + | assert.Equal(t, tt.wantFpr, v.Fingerprint) | |
| 59 | + | if tt.wantKey { | |
| 60 | + | assert.Equal(t, "gpg-id", v.KeyID) | |
| 61 | + | assert.Equal(t, "gpg key", v.KeyTitle) | |
| 62 | + | } else { | |
| 63 | + | assert.Empty(t, v.KeyID) | |
| 64 | + | assert.Empty(t, v.KeyTitle) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // TestGPGVerifyExpiredKey checks that expiry is judged at the creation time of | |
| 73 | + | // the signature, not at the committer date nor now. | |
| 74 | + | func TestGPGVerifyExpiredKey(t *testing.T) { | |
| 75 | + | const email = "dev@example.org" | |
| 76 | + | g := newGPGHome(t) | |
| 77 | + | day := 24 * time.Hour | |
| 78 | + | now := time.Now() | |
| 79 | + | // The key is created 10 days ago, signs 8 and 2 days ago, then gets an | |
| 80 | + | // expiry 3 days after its creation (published 9 days ago): it expired | |
| 81 | + | // 7 days ago, between the two signatures. | |
| 82 | + | fakedGPG := func(at time.Time, args ...string) { | |
| 83 | + | g.gpg("", append([]string{"--faked-system-time", fakedTime(at), "--passphrase", ""}, args...)...) | |
| 84 | + | } | |
| 85 | + | fakedGPG(now.Add(-10*day), "--quick-gen-key", "expiring <"+email+">", "ed25519", "sign", "never") | |
| 86 | + | fpr := g.fingerprint(email) | |
| 87 | + | payload := []byte("payload") | |
| 88 | + | validThen := g.detachSign(fpr, payload, "--faked-system-time", fakedTime(now.Add(-8*day))) | |
| 89 | + | expiredThen := g.detachSign(fpr, payload, "--faked-system-time", fakedTime(now.Add(-2*day))) | |
| 90 | + | fakedGPG(now.Add(-9*day), "--quick-set-expire", fpr, "3d") | |
| 91 | + | keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)} | |
| 92 | + | ||
| 93 | + | tests := []struct { | |
| 94 | + | name string | |
| 95 | + | sig []byte | |
| 96 | + | want domain.SignatureStatus | |
| 97 | + | }{ | |
| 98 | + | {"signed while the key was valid, key expired since", validThen, domain.StatusVerified}, | |
| 99 | + | {"signed after the key expired", expiredThen, domain.StatusExpired}, | |
| 100 | + | } | |
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | // The committer date is irrelevant: any value gives the same status. | |
| 104 | + | for _, committed := range []int64{0, now.Add(-9 * day).Unix(), now.Unix(), now.Add(30 * day).Unix()} { | |
| 105 | + | v := sigverify.GPG{}.Verify(payload, tt.sig, email, committed, keys) | |
| 106 | + | assert.Equal(t, tt.want, v.Status, "committer date %d", committed) | |
| 107 | + | assert.Equal(t, fpr, v.Fingerprint) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // TestGPGVerifyIgnoresCommitterDate: a signature that is valid and a key that | |
| 114 | + | // never expires verify whatever the committer date says, including a date | |
| 115 | + | // before the signature was made. | |
| 116 | + | func TestGPGVerifyIgnoresCommitterDate(t *testing.T) { | |
| 117 | + | const email = "dev@example.org" | |
| 118 | + | g := newGPGHome(t) | |
| 119 | + | fpr := g.generate("dates <"+email+">", "ed25519", "never") | |
| 120 | + | keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)} | |
| 121 | + | c := g.commit(fpr, email) | |
| 122 | + | for _, delta := range []int64{-365 * 24 * 3600, -3600, 0, 3600} { | |
| 123 | + | v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt+delta, keys) | |
| 124 | + | assert.Equal(t, domain.StatusVerified, v.Status, "committer date %+d s", delta) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // TestGPGVerifyWeakHash: the digest algorithm allowlist is enforced; the | |
| 129 | + | // OpenPGP library does not reject a SHA-1 detached signature by itself. | |
| 130 | + | func TestGPGVerifyWeakHash(t *testing.T) { | |
| 131 | + | const email = "dev@example.org" | |
| 132 | + | g := newGPGHome(t) | |
| 133 | + | payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nmessage\n") | |
| 134 | + | for name, algo := range map[string]string{"rsa": "rsa2048", "ed25519": "ed25519"} { | |
| 135 | + | fpr := g.generate("weak-"+name+" <"+email+">", algo, "never") | |
| 136 | + | keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)} | |
| 137 | + | now := time.Now().Unix() | |
| 138 | + | tests := []struct { | |
| 139 | + | digest string | |
| 140 | + | want domain.SignatureStatus | |
| 141 | + | }{ | |
| 142 | + | {"SHA1", domain.StatusBadSignature}, | |
| 143 | + | {"RIPEMD160", domain.StatusBadSignature}, | |
| 144 | + | {"SHA224", domain.StatusBadSignature}, | |
| 145 | + | {"SHA256", domain.StatusVerified}, | |
| 146 | + | {"SHA384", domain.StatusVerified}, | |
| 147 | + | {"SHA512", domain.StatusVerified}, | |
| 148 | + | } | |
| 149 | + | for _, tt := range tests { | |
| 150 | + | t.Run(name+"/"+tt.digest, func(t *testing.T) { | |
| 151 | + | sig := g.detachSign(fpr, payload, "--digest-algo", tt.digest) | |
| 152 | + | v := sigverify.GPG{}.Verify(payload, sig, email, now, keys) | |
| 153 | + | assert.Equal(t, tt.want, v.Status) | |
| 154 | + | if tt.want != domain.StatusVerified { | |
| 155 | + | assert.Empty(t, v.KeyID, "a weak signature never names a trusted key") | |
| 156 | + | } | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // Several signature packets are refused: the library would verify the first | |
| 163 | + | // one whose issuer it knows, while the clock and the revocation check would | |
| 164 | + | // come from another one. | |
| 165 | + | func TestGPGVerifyRefusesSeveralPackets(t *testing.T) { | |
| 166 | + | const email = "dev@example.org" | |
| 167 | + | g := newGPGHome(t) | |
| 168 | + | payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nmessage\n") | |
| 169 | + | unknown := g.generate("unknown <other@example.org>", "ed25519", "never") | |
| 170 | + | trusted := g.generate("trusted <"+email+">", "ed25519", "never") | |
| 171 | + | keys := []domain.TrustedKey{trustedGPG(g.public(trusted), email)} | |
| 172 | + | binary := func(fpr string) []byte { | |
| 173 | + | block, err := armor.Decode(bytes.NewReader(g.detachSign(fpr, payload))) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | b, err := io.ReadAll(block.Body) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | return b | |
| 178 | + | } | |
| 179 | + | var both bytes.Buffer | |
| 180 | + | w, err := armor.Encode(&both, openpgp.SignatureType, nil) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | _, _ = w.Write(binary(unknown)) | |
| 183 | + | _, _ = w.Write(binary(trusted)) | |
| 184 | + | require.NoError(t, w.Close()) | |
| 185 | + | ||
| 186 | + | single := sigverify.GPG{}.Verify(payload, g.detachSign(trusted, payload), email, 0, keys) | |
| 187 | + | require.Equal(t, domain.StatusVerified, single.Status, "control: one packet verifies") | |
| 188 | + | v := sigverify.GPG{}.Verify(payload, both.Bytes(), email, 0, keys) | |
| 189 | + | assert.Equal(t, domain.StatusBadSignature, v.Status) | |
| 190 | + | assert.Empty(t, v.KeyID) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestGPGVerifyRevokedKey(t *testing.T) { | |
| 194 | + | const email = "dev@example.org" | |
| 195 | + | g := newGPGHome(t) | |
| 196 | + | fpr := g.generate("revoked <"+email+">", "ed25519", "never") | |
| 197 | + | c := g.commit(fpr, email) | |
| 198 | + | g.revoke(fpr) | |
| 199 | + | pub := g.public(fpr) // carries the revocation | |
| 200 | + | ||
| 201 | + | v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(pub, email)}) | |
| 202 | + | assert.Equal(t, domain.StatusRevoked, v.Status) | |
| 203 | + | assert.Equal(t, fpr, v.Fingerprint) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestGPGVerifySubkey(t *testing.T) { | |
| 207 | + | const email = "dev@example.org" | |
| 208 | + | g := newGPGHome(t) | |
| 209 | + | fpr := g.generate("subkey <"+email+">", "ed25519", "never") | |
| 210 | + | // A signing subkey; the primary key is certify-only for gpg when asked so. | |
| 211 | + | g.gpg("", "--passphrase", "", "--quick-add-key", fpr, "ed25519", "sign", "never") | |
| 212 | + | out := g.gpg("", "--with-colons", "--list-keys", "--with-subkey-fingerprints", fpr) | |
| 213 | + | var sub string | |
| 214 | + | var next bool | |
| 215 | + | for _, l := range strings.Split(out, "\n") { | |
| 216 | + | if strings.HasPrefix(l, "sub:") { | |
| 217 | + | next = true | |
| 218 | + | } else if next && strings.HasPrefix(l, "fpr:") { | |
| 219 | + | sub = strings.Split(l, ":")[9] | |
| 220 | + | break | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | require.NotEmpty(t, sub) | |
| 224 | + | c := g.commit(sub+"!", email) | |
| 225 | + | ||
| 226 | + | v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(g.public(fpr), email)}) | |
| 227 | + | assert.Equal(t, domain.StatusVerified, v.Status) | |
| 228 | + | assert.Equal(t, fpr, v.Fingerprint, "the fingerprint is the one of the primary key") | |
| 229 | + | assert.Equal(t, "gpg-id", v.KeyID) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestGPGVerifyHostileInput(t *testing.T) { | |
| 233 | + | const email = "dev@example.org" | |
| 234 | + | g := newGPGHome(t) | |
| 235 | + | fpr := g.generate("hostile <"+email+">", "ed25519", "never") | |
| 236 | + | pub := g.public(fpr) | |
| 237 | + | c := g.commit(fpr, email) | |
| 238 | + | keys := []domain.TrustedKey{trustedGPG(pub, email)} | |
| 239 | + | ||
| 240 | + | huge := []byte("-----BEGIN PGP SIGNATURE-----\n\n" + strings.Repeat("AAAA\n", 20<<10) + "-----END PGP SIGNATURE-----\n") | |
| 241 | + | tests := []struct { | |
| 242 | + | name string | |
| 243 | + | sig []byte | |
| 244 | + | }{ | |
| 245 | + | {"not armored", []byte("hello")}, | |
| 246 | + | {"wrong armor type", []byte(strings.ReplaceAll(string(c.signature), "PGP SIGNATURE", "PGP MESSAGE"))}, | |
| 247 | + | {"empty armor", []byte("-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n")}, | |
| 248 | + | {"a public key instead", []byte(pub)}, | |
| 249 | + | {"larger than 64 KiB", huge}, | |
| 250 | + | } | |
| 251 | + | for _, tt := range tests { | |
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |
| 253 | + | var v domain.CommitVerification | |
| 254 | + | require.NotPanics(t, func() { v = sigverify.GPG{}.Verify(c.payload, tt.sig, email, c.signedAt, keys) }) | |
| 255 | + | assert.Equal(t, domain.StatusBadSignature, v.Status) | |
| 256 | + | assert.Equal(t, domain.FormatGPG, v.Format) | |
| 257 | + | }) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | t.Run("every truncation of a valid signature", func(t *testing.T) { | |
| 261 | + | for i := range c.signature { | |
| 262 | + | require.NotPanics(t, func() { sigverify.GPG{}.Verify(c.payload, c.signature[:i], email, c.signedAt, keys) }) | |
| 263 | + | } | |
| 264 | + | }) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // TestGPGVerifyIgnoresPrivateKeys: a private key stored as trusted (it cannot | |
| 268 | + | // be added through the inspector) does not take part in verification. | |
| 269 | + | func TestGPGVerifyIgnoresPrivateKeys(t *testing.T) { | |
| 270 | + | const email = "dev@example.org" | |
| 271 | + | g := newGPGHome(t) | |
| 272 | + | fpr := g.generate("private <"+email+">", "ed25519", "never") | |
| 273 | + | c := g.commit(fpr, email) | |
| 274 | + | ||
| 275 | + | v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(g.private(fpr), email)}) | |
| 276 | + | assert.Equal(t, domain.StatusUnknownKey, v.Status) | |
| 277 | + | } | |
A internal/adapters/sigverify/helpers_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // need skips the test when a tool is missing, except in CI where it must exist. | |
| 21 | + | func need(t *testing.T, tool string) string { | |
| 22 | + | t.Helper() | |
| 23 | + | path, err := exec.LookPath(tool) | |
| 24 | + | if err != nil { | |
| 25 | + | if os.Getenv("CI") != "" { | |
| 26 | + | t.Fatalf("%s not found: CI must have it", tool) | |
| 27 | + | } | |
| 28 | + | t.Skipf("%s not found: install it to check signatures against real tools", tool) | |
| 29 | + | } | |
| 30 | + | return path | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // run runs a command and returns its stdout; a failure fails the test with | |
| 34 | + | // its output. | |
| 35 | + | func run(t *testing.T, dir string, env []string, stdin string, name string, args ...string) string { | |
| 36 | + | t.Helper() | |
| 37 | + | cmd := exec.Command(name, args...) | |
| 38 | + | cmd.Dir = dir | |
| 39 | + | cmd.Env = append(os.Environ(), env...) | |
| 40 | + | cmd.Stdin = strings.NewReader(stdin) | |
| 41 | + | var stdout, stderr bytes.Buffer | |
| 42 | + | cmd.Stdout, cmd.Stderr = &stdout, &stderr | |
| 43 | + | require.NoError(t, cmd.Run(), "%s %v: %s", name, args, stderr.String()) | |
| 44 | + | return stdout.String() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // signedCommit is a commit as the verifier sees it. | |
| 48 | + | type signedCommit struct { | |
| 49 | + | payload, signature []byte | |
| 50 | + | signedAt int64 | |
| 51 | + | email string | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // newRepo makes an empty Git repository with a committer identity. | |
| 55 | + | func newRepo(t *testing.T) string { | |
| 56 | + | t.Helper() | |
| 57 | + | need(t, "git") | |
| 58 | + | dir := t.TempDir() | |
| 59 | + | run(t, dir, nil, "", "git", "init", "-q") | |
| 60 | + | return dir | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // commit makes a signed empty commit in dir, with extra git config, and | |
| 64 | + | // returns it split the way ermite reads it. | |
| 65 | + | func commit(t *testing.T, dir, email string, env []string, config ...string) signedCommit { | |
| 66 | + | t.Helper() | |
| 67 | + | now := time.Now().Unix() | |
| 68 | + | args := []string{"-c", "user.name=Test", "-c", "user.email=" + email} | |
| 69 | + | for _, c := range config { | |
| 70 | + | args = append(args, "-c", c) | |
| 71 | + | } | |
| 72 | + | args = append(args, "commit", "-q", "--allow-empty", "-S", "-m", "signed commit") | |
| 73 | + | env = append(env, | |
| 74 | + | "GIT_COMMITTER_DATE="+strconv.FormatInt(now, 10)+" +0000", | |
| 75 | + | "GIT_AUTHOR_DATE="+strconv.FormatInt(now, 10)+" +0000", | |
| 76 | + | ) | |
| 77 | + | run(t, dir, env, "", "git", args...) | |
| 78 | + | raw := run(t, dir, nil, "", "git", "cat-file", "commit", "HEAD") | |
| 79 | + | payload, signature, ok := domain.SplitCommitSignature([]byte(raw)) | |
| 80 | + | require.True(t, ok, "the commit must be signed:\n%s", raw) | |
| 81 | + | return signedCommit{payload: payload, signature: signature, signedAt: now, email: email} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // sshKey makes an SSH key pair and returns the private key path and the | |
| 85 | + | // public key line. | |
| 86 | + | func sshKey(t *testing.T, typ string, extra ...string) (private, public string) { | |
| 87 | + | t.Helper() | |
| 88 | + | need(t, "ssh-keygen") | |
| 89 | + | private = filepath.Join(t.TempDir(), "key") | |
| 90 | + | args := append([]string{"-q", "-t", typ, "-N", "", "-C", "test comment", "-f", private}, extra...) | |
| 91 | + | run(t, "", nil, "", "ssh-keygen", args...) | |
| 92 | + | pub, err := os.ReadFile(private + ".pub") | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | return private, strings.TrimSpace(string(pub)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // sshCommit makes a commit signed by an SSH private key. | |
| 98 | + | func sshCommit(t *testing.T, private, email string) signedCommit { | |
| 99 | + | t.Helper() | |
| 100 | + | return commit(t, newRepo(t), email, nil, "gpg.format=ssh", "user.signingkey="+private) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // gpgHome is a throw-away GnuPG home directory. It lives in /tmp: the agent | |
| 104 | + | // socket path is limited to 108 bytes. | |
| 105 | + | type gpgHome struct { | |
| 106 | + | t *testing.T | |
| 107 | + | home string | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func newGPGHome(t *testing.T) gpgHome { | |
| 111 | + | t.Helper() | |
| 112 | + | need(t, "gpg") | |
| 113 | + | home, err := os.MkdirTemp("", "sigverify-gpg") | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | require.NoError(t, os.Chmod(home, 0o700)) | |
| 116 | + | t.Cleanup(func() { | |
| 117 | + | _ = exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run() | |
| 118 | + | _ = os.RemoveAll(home) | |
| 119 | + | }) | |
| 120 | + | return gpgHome{t: t, home: home} | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (g gpgHome) gpg(stdin string, args ...string) string { | |
| 124 | + | g.t.Helper() | |
| 125 | + | return run(g.t, "", nil, stdin, "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // generate makes a key without passphrase and returns its fingerprint. expire | |
| 129 | + | // is a gpg expiration like "never" or "2d". | |
| 130 | + | func (g gpgHome) generate(uid, algo, expire string) string { | |
| 131 | + | g.t.Helper() | |
| 132 | + | g.gpg("", "--passphrase", "", "--quick-gen-key", uid, algo, "sign", expire) | |
| 133 | + | return g.fingerprint(uid) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // fingerprint returns the primary key fingerprint of the key of uid. | |
| 137 | + | func (g gpgHome) fingerprint(uid string) string { | |
| 138 | + | g.t.Helper() | |
| 139 | + | out := g.gpg("", "--with-colons", "--fingerprint", "--list-keys", uid) | |
| 140 | + | for _, l := range strings.Split(out, "\n") { | |
| 141 | + | if strings.HasPrefix(l, "fpr:") { | |
| 142 | + | return strings.Split(l, ":")[9] | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | g.t.Fatalf("no fingerprint in %q", out) | |
| 146 | + | return "" | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // public exports the public keys, in one armor block. | |
| 150 | + | func (g gpgHome) public(fpr ...string) string { | |
| 151 | + | g.t.Helper() | |
| 152 | + | return g.gpg("", append([]string{"--armor", "--export"}, fpr...)...) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (g gpgHome) private(fpr string) string { | |
| 156 | + | g.t.Helper() | |
| 157 | + | return g.gpg("", "--armor", "--export-secret-keys", fpr) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // revoke publishes a revocation certificate of the key in the keyring. | |
| 161 | + | func (g gpgHome) revoke(fpr string) { | |
| 162 | + | g.t.Helper() | |
| 163 | + | cert := filepath.Join(g.home, "revoke.asc") | |
| 164 | + | // --gen-revoke asks questions: it cannot run in batch mode. | |
| 165 | + | run(g.t, "", nil, "y\n0\n\ny\n", "gpg", "--homedir", g.home, "--no-tty", "--yes", "--command-fd", "0", "--output", cert, "--gen-revoke", fpr) | |
| 166 | + | g.gpg("", "--import", cert) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // detachSign signs data with the key (armored detached signature). extra are | |
| 170 | + | // gpg options such as "--digest-algo", "SHA1" or "--faked-system-time", "<t>!". | |
| 171 | + | func (g gpgHome) detachSign(fpr string, data []byte, extra ...string) []byte { | |
| 172 | + | g.t.Helper() | |
| 173 | + | args := append(append([]string{}, extra...), "--armor", "--detach-sign", "-u", fpr) | |
| 174 | + | return []byte(run(g.t, "", nil, string(data), "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...)) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // fakedTime formats a time for gpg's --faked-system-time. | |
| 178 | + | func fakedTime(t time.Time) string { return t.UTC().Format("20060102T150405") + "!" } | |
| 179 | + | ||
| 180 | + | // commit makes a commit signed by the key. | |
| 181 | + | func (g gpgHome) commit(fpr, email string) signedCommit { | |
| 182 | + | g.t.Helper() | |
| 183 | + | return commit(g.t, newRepo(g.t), email, []string{"GNUPGHOME=" + g.home}, "gpg.format=openpgp", "user.signingkey="+fpr) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func trustedSSH(public string, emails ...string) domain.TrustedKey { | |
| 187 | + | return domain.TrustedKey{ID: "ssh-id", Format: domain.FormatSSH, Title: "ssh key", PublicKey: public, Emails: emails} | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func trustedGPG(public string, emails ...string) domain.TrustedKey { | |
| 191 | + | return domain.TrustedKey{ID: "gpg-id", Format: domain.FormatGPG, Title: "gpg key", PublicKey: public, Emails: emails} | |
| 192 | + | } | |
A internal/adapters/sigverify/inspector.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/ProtonMail/go-crypto/openpgp" | |
| 13 | + | "github.com/ProtonMail/go-crypto/openpgp/armor" | |
| 14 | + | "golang.org/x/crypto/ssh" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Inspector parses the public keys to trust. The zero value is ready. | |
| 21 | + | type Inspector struct{} | |
| 22 | + | ||
| 23 | + | var _ ports.KeyInspector = Inspector{} | |
| 24 | + | ||
| 25 | + | // Inspect implements ports.KeyInspector. | |
| 26 | + | func (Inspector) Inspect(format domain.SignatureFormat, publicKey string) (id, canonical string, err error) { | |
| 27 | + | if len(publicKey) > domain.MaxTrustedKeyBytes { | |
| 28 | + | return "", "", fmt.Errorf("%w: public key larger than %d bytes", domain.ErrInvalid, domain.MaxTrustedKeyBytes) | |
| 29 | + | } | |
| 30 | + | defer func() { // a parser bug must not take the server down | |
| 31 | + | if recover() != nil { | |
| 32 | + | id, canonical, err = "", "", fmt.Errorf("%w: unreadable public key", domain.ErrInvalid) | |
| 33 | + | } | |
| 34 | + | }() | |
| 35 | + | switch format { | |
| 36 | + | case domain.FormatSSH: | |
| 37 | + | return inspectSSH(publicKey) | |
| 38 | + | case domain.FormatGPG: | |
| 39 | + | return inspectGPG(publicKey) | |
| 40 | + | } | |
| 41 | + | return "", "", fmt.Errorf("%w: key format %q, want ssh or gpg", domain.ErrInvalid, format) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func inspectSSH(publicKey string) (id, canonical string, err error) { | |
| 45 | + | pk, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(publicKey)) | |
| 46 | + | if err != nil { | |
| 47 | + | return "", "", fmt.Errorf("%w: not an SSH public key (want one authorized_keys line)", domain.ErrInvalid) | |
| 48 | + | } | |
| 49 | + | if len(bytes.TrimSpace(rest)) != 0 { | |
| 50 | + | return "", "", fmt.Errorf("%w: give exactly one SSH public key", domain.ErrInvalid) | |
| 51 | + | } | |
| 52 | + | if _, isCert := pk.(*ssh.Certificate); isCert { | |
| 53 | + | return "", "", fmt.Errorf("%w: SSH certificates cannot be trusted, give the public key", domain.ErrInvalid) | |
| 54 | + | } | |
| 55 | + | return ssh.FingerprintSHA256(pk), strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))), nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func inspectGPG(publicKey string) (id, canonical string, err error) { | |
| 59 | + | if strings.Count(publicKey, "-----BEGIN PGP") != 1 { | |
| 60 | + | return "", "", fmt.Errorf("%w: give exactly one armored OpenPGP public key block", domain.ErrInvalid) | |
| 61 | + | } | |
| 62 | + | ring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(publicKey)) | |
| 63 | + | if err != nil { | |
| 64 | + | return "", "", fmt.Errorf("%w: not an armored OpenPGP public key", domain.ErrInvalid) | |
| 65 | + | } | |
| 66 | + | if len(ring) != 1 { | |
| 67 | + | return "", "", fmt.Errorf("%w: give exactly one OpenPGP key, got %d", domain.ErrInvalid, len(ring)) | |
| 68 | + | } | |
| 69 | + | e := ring[0] | |
| 70 | + | if hasPrivateKey(e) { | |
| 71 | + | return "", "", fmt.Errorf("%w: this is a private key; export the public key instead", domain.ErrInvalid) | |
| 72 | + | } | |
| 73 | + | var out bytes.Buffer | |
| 74 | + | w, err := armor.Encode(&out, openpgp.PublicKeyType, nil) | |
| 75 | + | if err != nil { | |
| 76 | + | return "", "", errors.New("armor the public key") | |
| 77 | + | } | |
| 78 | + | if err := e.Serialize(w); err != nil { | |
| 79 | + | return "", "", fmt.Errorf("%w: unreadable OpenPGP key", domain.ErrInvalid) | |
| 80 | + | } | |
| 81 | + | if err := w.Close(); err != nil { | |
| 82 | + | return "", "", errors.New("armor the public key") | |
| 83 | + | } | |
| 84 | + | return fingerprintHex(e), out.String(), nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func hasPrivateKey(e *openpgp.Entity) bool { | |
| 88 | + | if e.PrivateKey != nil { | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | for _, s := range e.Subkeys { | |
| 92 | + | if s.PrivateKey != nil { | |
| 93 | + | return true | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // fingerprintHex is the primary key fingerprint of e in upper-case hex. | |
| 100 | + | func fingerprintHex(e *openpgp.Entity) string { | |
| 101 | + | return strings.ToUpper(hex.EncodeToString(e.PrimaryKey.Fingerprint)) | |
| 102 | + | } | |
A internal/adapters/sigverify/inspector_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/adapters/sigverify" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestInspectSSH(t *testing.T) { | |
| 18 | + | priv, pub := sshKey(t, "ed25519") | |
| 19 | + | _, rsaPub := sshKey(t, "rsa", "-b", "2048") | |
| 20 | + | privText, err := os.ReadFile(priv) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | canonical := strings.Join(strings.Fields(pub)[:2], " ") | |
| 23 | + | ||
| 24 | + | id, got, err := sigverify.Inspector{}.Inspect(domain.FormatSSH, pub) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | assert.Equal(t, fingerprint(t, pub), id) | |
| 27 | + | assert.Equal(t, canonical, got, "the comment is dropped") | |
| 28 | + | ||
| 29 | + | id2, got2, err := sigverify.Inspector{}.Inspect(domain.FormatSSH, " "+canonical+"\n\n") | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | assert.Equal(t, id, id2) | |
| 32 | + | assert.Equal(t, canonical, got2) | |
| 33 | + | ||
| 34 | + | _, got, err = sigverify.Inspector{}.Inspect(domain.FormatSSH, rsaPub) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | assert.True(t, strings.HasPrefix(got, "ssh-rsa ")) | |
| 37 | + | ||
| 38 | + | tests := []struct { | |
| 39 | + | name, key string | |
| 40 | + | }{ | |
| 41 | + | {"empty", ""}, | |
| 42 | + | {"garbage", "hello world"}, | |
| 43 | + | {"private key", string(privText)}, | |
| 44 | + | {"two keys", pub + "\n" + rsaPub + "\n"}, | |
| 45 | + | {"truncated", canonical[:len(canonical)-10]}, | |
| 46 | + | {"larger than the limit", strings.Repeat("a", domain.MaxTrustedKeyBytes+1)}, | |
| 47 | + | } | |
| 48 | + | for _, tt := range tests { | |
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |
| 50 | + | _, _, err := sigverify.Inspector{}.Inspect(domain.FormatSSH, tt.key) | |
| 51 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 52 | + | assert.NotContains(t, err.Error(), "PRIVATE KEY") | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestInspectGPG(t *testing.T) { | |
| 58 | + | g := newGPGHome(t) | |
| 59 | + | fpr := g.generate("inspect <dev@example.org>", "ed25519", "never") | |
| 60 | + | pub := g.public(fpr) | |
| 61 | + | other := g.generate("two <two@example.org>", "ed25519", "never") | |
| 62 | + | ||
| 63 | + | id, canonical, err := sigverify.Inspector{}.Inspect(domain.FormatGPG, pub) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | assert.Equal(t, strings.ToUpper(fpr), id) | |
| 66 | + | assert.True(t, strings.HasPrefix(canonical, "-----BEGIN PGP PUBLIC KEY BLOCK-----")) | |
| 67 | + | ||
| 68 | + | // The canonical form is stable and verifies signatures like the original. | |
| 69 | + | id2, canonical2, err := sigverify.Inspector{}.Inspect(domain.FormatGPG, canonical) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.Equal(t, id, id2) | |
| 72 | + | assert.Equal(t, canonical, canonical2) | |
| 73 | + | c := g.commit(fpr, "dev@example.org") | |
| 74 | + | v := sigverify.GPG{}.Verify(c.payload, c.signature, "dev@example.org", c.signedAt, []domain.TrustedKey{trustedGPG(canonical, "dev@example.org")}) | |
| 75 | + | assert.Equal(t, domain.StatusVerified, v.Status) | |
| 76 | + | ||
| 77 | + | tests := []struct { | |
| 78 | + | name, key string | |
| 79 | + | }{ | |
| 80 | + | {"empty", ""}, | |
| 81 | + | {"garbage", "hello world"}, | |
| 82 | + | {"private key", g.private(fpr)}, | |
| 83 | + | {"two keys in one block", g.public(fpr, other)}, | |
| 84 | + | {"two blocks", pub + "\n" + g.public(other)}, | |
| 85 | + | {"a signature", string(c.signature)}, | |
| 86 | + | {"an ssh key", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA"}, | |
| 87 | + | {"larger than the limit", strings.Repeat("a", domain.MaxTrustedKeyBytes+1)}, | |
| 88 | + | } | |
| 89 | + | for _, tt := range tests { | |
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |
| 91 | + | _, _, err := sigverify.Inspector{}.Inspect(domain.FormatGPG, tt.key) | |
| 92 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 93 | + | assert.NotContains(t, err.Error(), "PRIVATE KEY") | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestInspectUnknownFormat(t *testing.T) { | |
| 99 | + | for _, f := range []domain.SignatureFormat{domain.FormatNone, domain.FormatX509, "pgp"} { | |
| 100 | + | _, _, err := sigverify.Inspector{}.Inspect(f, "x") | |
| 101 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 102 | + | } | |
| 103 | + | } | |
A internal/adapters/sigverify/ssh.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "crypto/sha512" | |
| 9 | + | "encoding/base64" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "golang.org/x/crypto/ssh" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // SSHSIG constants (PROTOCOL.sshsig); the namespace is the one Git signs with. | |
| 19 | + | const ( | |
| 20 | + | sshMagic = "SSHSIG" | |
| 21 | + | sshVersion = 1 | |
| 22 | + | sshNamespace = "git" | |
| 23 | + | sshArmorHead = "-----BEGIN SSH SIGNATURE-----" | |
| 24 | + | sshArmorTail = "-----END SSH SIGNATURE-----" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // SSH verifies SSH signatures (gpg.format=ssh). It ignores the trusted keys | |
| 28 | + | // that are not SSH keys. | |
| 29 | + | type SSH struct{} | |
| 30 | + | ||
| 31 | + | var _ ports.SignatureVerifier = SSH{} | |
| 32 | + | ||
| 33 | + | // sshSignedData is what the signature covers after the magic. | |
| 34 | + | type sshSignedData struct { | |
| 35 | + | Namespace string | |
| 36 | + | Reserved string | |
| 37 | + | HashAlgorithm string | |
| 38 | + | Hash string | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // sshSigBlob is the signature file after the magic. | |
| 42 | + | type sshSigBlob struct { | |
| 43 | + | Version uint32 | |
| 44 | + | PublicKey string | |
| 45 | + | Namespace string | |
| 46 | + | Reserved string | |
| 47 | + | HashAlgorithm string | |
| 48 | + | Signature string | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Verify implements ports.SignatureVerifier. | |
| 52 | + | // | |
| 53 | + | //hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain | |
| 54 | + | func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | |
| 55 | + | v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature} | |
| 56 | + | defer func() { // a parser bug must not take the server down | |
| 57 | + | if recover() != nil { | |
| 58 | + | v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint} | |
| 59 | + | } | |
| 60 | + | }() | |
| 61 | + | if len(signature) > maxSignatureBytes { | |
| 62 | + | return v | |
| 63 | + | } | |
| 64 | + | blob, ok := decodeSSHArmor(signature) | |
| 65 | + | if !ok || !bytes.HasPrefix(blob, []byte(sshMagic)) { | |
| 66 | + | return v | |
| 67 | + | } | |
| 68 | + | var sb sshSigBlob | |
| 69 | + | if ssh.Unmarshal(blob[len(sshMagic):], &sb) != nil { | |
| 70 | + | return v | |
| 71 | + | } | |
| 72 | + | pk, err := ssh.ParsePublicKey([]byte(sb.PublicKey)) | |
| 73 | + | if err != nil { | |
| 74 | + | return v | |
| 75 | + | } | |
| 76 | + | v.Fingerprint = ssh.FingerprintSHA256(pk) | |
| 77 | + | if sb.Version != sshVersion || sb.Namespace != sshNamespace { | |
| 78 | + | return v | |
| 79 | + | } | |
| 80 | + | var sum []byte | |
| 81 | + | switch sb.HashAlgorithm { | |
| 82 | + | case "sha256": | |
| 83 | + | h := sha256.Sum256(payload) | |
| 84 | + | sum = h[:] | |
| 85 | + | case "sha512": | |
| 86 | + | h := sha512.Sum512(payload) | |
| 87 | + | sum = h[:] | |
| 88 | + | default: | |
| 89 | + | return v | |
| 90 | + | } | |
| 91 | + | var sf struct { | |
| 92 | + | Format string | |
| 93 | + | Blob string | |
| 94 | + | Rest []byte `ssh:"rest"` | |
| 95 | + | } | |
| 96 | + | if ssh.Unmarshal([]byte(sb.Signature), &sf) != nil { | |
| 97 | + | return v | |
| 98 | + | } | |
| 99 | + | if pk.Type() == ssh.KeyAlgoRSA && sf.Format != ssh.KeyAlgoRSASHA256 && sf.Format != ssh.KeyAlgoRSASHA512 { | |
| 100 | + | return v // ssh-rsa is SHA-1 | |
| 101 | + | } | |
| 102 | + | msg := append([]byte(sshMagic), ssh.Marshal(sshSignedData{ | |
| 103 | + | Namespace: sb.Namespace, Reserved: sb.Reserved, HashAlgorithm: sb.HashAlgorithm, Hash: string(sum), | |
| 104 | + | })...) | |
| 105 | + | if pk.Verify(msg, &ssh.Signature{Format: sf.Format, Blob: []byte(sf.Blob), Rest: sf.Rest}) != nil { | |
| 106 | + | return v | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | want := pk.Marshal() | |
| 110 | + | for _, k := range keys { | |
| 111 | + | if k.Format != domain.FormatSSH { | |
| 112 | + | continue | |
| 113 | + | } | |
| 114 | + | tk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey)) | |
| 115 | + | if err != nil || !bytes.Equal(tk.Marshal(), want) { | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | return trusted(v, k, committerEmail) | |
| 119 | + | } | |
| 120 | + | v.Status = domain.StatusUnknownKey | |
| 121 | + | return v | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // decodeSSHArmor returns the binary signature file of an armored SSH | |
| 125 | + | // signature. | |
| 126 | + | func decodeSSHArmor(armored []byte) ([]byte, bool) { | |
| 127 | + | lines := strings.Split(strings.TrimSpace(string(armored)), "\n") | |
| 128 | + | if len(lines) < 3 || strings.TrimSpace(lines[0]) != sshArmorHead || strings.TrimSpace(lines[len(lines)-1]) != sshArmorTail { | |
| 129 | + | return nil, false | |
| 130 | + | } | |
| 131 | + | var b strings.Builder | |
| 132 | + | for _, l := range lines[1 : len(lines)-1] { | |
| 133 | + | b.WriteString(strings.TrimSpace(l)) | |
| 134 | + | } | |
| 135 | + | raw, err := base64.StdEncoding.DecodeString(b.String()) | |
| 136 | + | return raw, err == nil | |
| 137 | + | } | |
A internal/adapters/sigverify/ssh_test.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "crypto/ed25519" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | "golang.org/x/crypto/ssh" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/sigverify" | |
| 16 | + | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func fingerprint(t *testing.T, authorizedKey string) string { | |
| 21 | + | t.Helper() | |
| 22 | + | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKey)) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | return ssh.FingerprintSHA256(pk) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestSSHVerify(t *testing.T) { | |
| 28 | + | const email = "dev@example.org" | |
| 29 | + | keys := map[string]struct{ private, public string }{} | |
| 30 | + | for name, args := range map[string][]string{ | |
| 31 | + | "ed25519": {"ed25519"}, | |
| 32 | + | "rsa": {"rsa", "-b", "2048"}, | |
| 33 | + | "ecdsa": {"ecdsa", "-b", "256"}, | |
| 34 | + | } { | |
| 35 | + | priv, pub := sshKey(t, args[0], args[1:]...) | |
| 36 | + | keys[name] = struct{ private, public string }{priv, pub} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | for name, key := range keys { | |
| 40 | + | t.Run(name, func(t *testing.T) { | |
| 41 | + | c := sshCommit(t, key.private, email) | |
| 42 | + | fp := fingerprint(t, key.public) | |
| 43 | + | other := keys["ed25519"] | |
| 44 | + | if name == "ed25519" { | |
| 45 | + | other = keys["rsa"] | |
| 46 | + | } | |
| 47 | + | tamperedPayload := append(bytes.Clone(c.payload), '!') | |
| 48 | + | tamperedSig := bytes.Replace(c.signature, []byte("\n"), []byte("\nAAAA"), 2) | |
| 49 | + | ||
| 50 | + | tests := []struct { | |
| 51 | + | name string | |
| 52 | + | payload []byte | |
| 53 | + | signature []byte | |
| 54 | + | keys []domain.TrustedKey | |
| 55 | + | email string | |
| 56 | + | want domain.SignatureStatus | |
| 57 | + | wantKey bool | |
| 58 | + | }{ | |
| 59 | + | {"verified", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusVerified, true}, | |
| 60 | + | {"verified with the canonical key", c.payload, c.signature, []domain.TrustedKey{trustedSSH(strings.Join(strings.Fields(key.public)[:2], " "), email)}, email, domain.StatusVerified, true}, | |
| 61 | + | {"email case is ignored", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, "DEV@Example.ORG", domain.StatusVerified, true}, | |
| 62 | + | {"verified among other keys", c.payload, c.signature, []domain.TrustedKey{trustedSSH(other.public, email), trustedGPG("not a key", email), trustedSSH(key.public, email)}, email, domain.StatusVerified, true}, | |
| 63 | + | {"email mismatch", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, "other@example.org")}, email, domain.StatusEmailMismatch, true}, | |
| 64 | + | {"unknown key", c.payload, c.signature, []domain.TrustedKey{trustedSSH(other.public, email)}, email, domain.StatusUnknownKey, false}, | |
| 65 | + | {"no trusted key", c.payload, c.signature, nil, email, domain.StatusUnknownKey, false}, | |
| 66 | + | {"gpg keys are ignored", c.payload, c.signature, []domain.TrustedKey{{ID: "x", Format: domain.FormatGPG, PublicKey: key.public, Emails: []string{email}}}, email, domain.StatusUnknownKey, false}, | |
| 67 | + | {"tampered payload", tamperedPayload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false}, | |
| 68 | + | {"tampered signature", c.payload, tamperedSig, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false}, | |
| 69 | + | {"empty signature", c.payload, nil, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false}, | |
| 70 | + | } | |
| 71 | + | for _, tt := range tests { | |
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |
| 73 | + | v := sigverify.SSH{}.Verify(tt.payload, tt.signature, tt.email, c.signedAt, tt.keys) | |
| 74 | + | assert.Equal(t, domain.FormatSSH, v.Format) | |
| 75 | + | assert.Equal(t, tt.want, v.Status) | |
| 76 | + | if tt.wantKey { | |
| 77 | + | assert.Equal(t, "ssh-id", v.KeyID) | |
| 78 | + | assert.Equal(t, "ssh key", v.KeyTitle) | |
| 79 | + | } else { | |
| 80 | + | assert.Empty(t, v.KeyID) | |
| 81 | + | assert.Empty(t, v.KeyTitle) | |
| 82 | + | } | |
| 83 | + | if tt.want == domain.StatusVerified || tt.want == domain.StatusEmailMismatch || tt.want == domain.StatusUnknownKey { | |
| 84 | + | assert.Equal(t, fp, v.Fingerprint) | |
| 85 | + | } | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestSSHVerifyWrongNamespace(t *testing.T) { | |
| 93 | + | const email = "dev@example.org" | |
| 94 | + | priv, pub := sshKey(t, "ed25519") | |
| 95 | + | payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b> 1 +0000\n\nmsg\n") | |
| 96 | + | // Reading stdin, ssh-keygen -Y sign writes the armored signature to stdout. | |
| 97 | + | sig := run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "file", "-f", priv) | |
| 98 | + | require.Contains(t, sig, "BEGIN SSH SIGNATURE") | |
| 99 | + | ||
| 100 | + | v := sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)}) | |
| 101 | + | assert.Equal(t, domain.StatusBadSignature, v.Status) | |
| 102 | + | assert.Equal(t, fingerprint(t, pub), v.Fingerprint, "the key is still named") | |
| 103 | + | ||
| 104 | + | // The same signing in namespace git verifies: the namespace is the only change. | |
| 105 | + | sig = run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "git", "-f", priv) | |
| 106 | + | v = sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)}) | |
| 107 | + | assert.Equal(t, domain.StatusVerified, v.Status) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestSSHVerifyHostileInput(t *testing.T) { | |
| 111 | + | const email = "dev@example.org" | |
| 112 | + | _, pub := sshKey(t, "ed25519") | |
| 113 | + | keys := []domain.TrustedKey{trustedSSH(pub, email)} | |
| 114 | + | valid := func() []byte { | |
| 115 | + | s, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize)) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | sig, err := s.Sign([]byte("payload")) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | return []byte(sig) | |
| 120 | + | }() | |
| 121 | + | ||
| 122 | + | huge := []byte("-----BEGIN SSH SIGNATURE-----\n" + strings.Repeat("A", 70<<10) + "\n-----END SSH SIGNATURE-----\n") | |
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | sig []byte | |
| 126 | + | }{ | |
| 127 | + | {"not armored", []byte("hello")}, | |
| 128 | + | {"no end line", bytes.TrimSuffix(valid, []byte("-----END SSH SIGNATURE-----\n"))}, | |
| 129 | + | {"bad base64", []byte("-----BEGIN SSH SIGNATURE-----\n!!!!\n-----END SSH SIGNATURE-----\n")}, | |
| 130 | + | {"wrong magic", []byte("-----BEGIN SSH SIGNATURE-----\nQUJDREVGR0g=\n-----END SSH SIGNATURE-----\n")}, | |
| 131 | + | {"magic only", []byte("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n")}, | |
| 132 | + | {"larger than 64 KiB", huge}, | |
| 133 | + | } | |
| 134 | + | for _, tt := range tests { | |
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |
| 136 | + | var v domain.CommitVerification | |
| 137 | + | require.NotPanics(t, func() { v = sigverify.SSH{}.Verify([]byte("payload"), tt.sig, email, 0, keys) }) | |
| 138 | + | assert.Equal(t, domain.StatusBadSignature, v.Status) | |
| 139 | + | assert.Equal(t, domain.FormatSSH, v.Format) | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | t.Run("every truncation of a valid signature", func(t *testing.T) { | |
| 144 | + | for i := range valid { | |
| 145 | + | require.NotPanics(t, func() { sigverify.SSH{}.Verify([]byte("payload"), valid[:i], email, 0, keys) }) | |
| 146 | + | } | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // TestSSHVerifyInstanceKey checks the signature of the commits ermite itself | |
| 151 | + | // creates (ADR 0022) against the public key it publishes. | |
| 152 | + | func TestSSHVerifyInstanceKey(t *testing.T) { | |
| 153 | + | const email = "ermite@example.org" | |
| 154 | + | signer, err := sshsig.New(bytes.Repeat([]byte{5}, ed25519.SeedSize)) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor E <ermite@example.org> 1 +0000\ncommitter E <ermite@example.org> 1 +0000\n\nmsg\n") | |
| 157 | + | sig, err := signer.Sign(payload) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | ||
| 160 | + | key := domain.TrustedKey{ID: signer.PublicKey().Fingerprint, Format: domain.FormatSSH, Title: "instance", PublicKey: signer.PublicKey().AuthorizedKey, Emails: []string{email}, Builtin: true} | |
| 161 | + | v := sigverify.Verifier{}.Verify(payload, []byte(sig), email, 1, []domain.TrustedKey{key}) | |
| 162 | + | assert.Equal(t, domain.CommitVerification{ | |
| 163 | + | Format: domain.FormatSSH, Status: domain.StatusVerified, | |
| 164 | + | Fingerprint: signer.PublicKey().Fingerprint, KeyID: key.ID, KeyTitle: "instance", | |
| 165 | + | }, v) | |
| 166 | + | ||
| 167 | + | v = sigverify.Verifier{}.Verify(append(payload, 'x'), []byte(sig), email, 1, []domain.TrustedKey{key}) | |
| 168 | + | assert.Equal(t, domain.StatusBadSignature, v.Status) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // TestSSHVerifyRSASignatureFormat checks that the RSA signature ssh-keygen | |
| 172 | + | // makes (rsa-sha2-512) verifies; the legacy SHA-1 format is refused by the | |
| 173 | + | // verifier. | |
| 174 | + | func TestSSHVerifyRSASignatureFormat(t *testing.T) { | |
| 175 | + | const email = "dev@example.org" | |
| 176 | + | priv, pub := sshKey(t, "rsa", "-b", "2048") | |
| 177 | + | payload := []byte("payload\n") | |
| 178 | + | sig := run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "git", "-f", priv) | |
| 179 | + | v := sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)}) | |
| 180 | + | assert.Equal(t, domain.StatusVerified, v.Status) | |
| 181 | + | } | |
A internal/adapters/sigverify/verifier.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package sigverify checks commit signatures against the trusted keys | |
| 4 | + | // (ADR 0030). It is pure Go: no gpg or ssh-keygen binary is run. SSH | |
| 5 | + | // signatures (SSHSIG, gpg.format=ssh) and OpenPGP detached signatures are | |
| 6 | + | // verified; X.509 signatures are recognized and reported as unsupported. | |
| 7 | + | // | |
| 8 | + | // Every verifier is a stateless value, safe for concurrent use, and never | |
| 9 | + | // panics on hostile input: a malformed signature is a bad_signature status. | |
| 10 | + | package sigverify | |
| 11 | + | ||
| 12 | + | import ( | |
| 13 | + | "bytes" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // maxSignatureBytes bounds the signature a verifier parses; a larger one is | |
| 20 | + | // reported as bad_signature without being read. | |
| 21 | + | const maxSignatureBytes = 64 << 10 | |
| 22 | + | ||
| 23 | + | // Verifier dispatches a signature to the verifier of its format. | |
| 24 | + | type Verifier struct{} | |
| 25 | + | ||
| 26 | + | var _ ports.SignatureVerifier = Verifier{} | |
| 27 | + | ||
| 28 | + | // Verify implements ports.SignatureVerifier. An x509 signature is | |
| 29 | + | // unsupported; an unrecognized armor is a bad signature without format. | |
| 30 | + | func (Verifier) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification { | |
| 31 | + | switch domain.SignatureFormatOf(bytes.TrimLeft(signature, " \t\r\n")) { | |
| 32 | + | case domain.FormatSSH: | |
| 33 | + | return SSH{}.Verify(payload, signature, committerEmail, signedAt, keys) | |
| 34 | + | case domain.FormatGPG: | |
| 35 | + | return GPG{}.Verify(payload, signature, committerEmail, signedAt, keys) | |
| 36 | + | case domain.FormatX509: | |
| 37 | + | return domain.CommitVerification{Format: domain.FormatX509, Status: domain.StatusUnsupported} | |
| 38 | + | } | |
| 39 | + | return domain.CommitVerification{Format: domain.FormatNone, Status: domain.StatusBadSignature} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // trusted completes the verification of a signature whose cryptography is | |
| 43 | + | // valid and whose key is trusted: verified when the key may sign for the | |
| 44 | + | // committer email, email_mismatch otherwise. | |
| 45 | + | // | |
| 46 | + | //hexcheck:ignore suspicious-business-logic-in-adapter the email rule is domain.TrustedKey.TrustsEmail; this only maps it to the port's status | |
| 47 | + | func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification { | |
| 48 | + | v.KeyID, v.KeyTitle = key.ID, key.Title | |
| 49 | + | if key.TrustsEmail(committerEmail) { | |
| 50 | + | v.Status = domain.StatusVerified | |
| 51 | + | } else { | |
| 52 | + | v.Status = domain.StatusEmailMismatch | |
| 53 | + | } | |
| 54 | + | return v | |
| 55 | + | } | |
A internal/adapters/sigverify/verifier_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package sigverify_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/adapters/sigverify" | |
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestVerifierDispatch(t *testing.T) { | |
| 15 | + | const email = "dev@example.org" | |
| 16 | + | priv, sshPub := sshKey(t, "ed25519") | |
| 17 | + | sshC := sshCommit(t, priv, email) | |
| 18 | + | g := newGPGHome(t) | |
| 19 | + | fpr := g.generate("dispatch <"+email+">", "ed25519", "never") | |
| 20 | + | gpgC := g.commit(fpr, email) | |
| 21 | + | keys := []domain.TrustedKey{trustedSSH(sshPub, email), trustedGPG(g.public(fpr), email)} | |
| 22 | + | ||
| 23 | + | tests := []struct { | |
| 24 | + | name string | |
| 25 | + | payload []byte | |
| 26 | + | signature []byte | |
| 27 | + | wantFormat domain.SignatureFormat | |
| 28 | + | wantStatus domain.SignatureStatus | |
| 29 | + | }{ | |
| 30 | + | {"ssh", sshC.payload, sshC.signature, domain.FormatSSH, domain.StatusVerified}, | |
| 31 | + | {"gpg", gpgC.payload, gpgC.signature, domain.FormatGPG, domain.StatusVerified}, | |
| 32 | + | {"ssh signature of another commit", append(sshC.payload[:len(sshC.payload):len(sshC.payload)], 0), sshC.signature, domain.FormatSSH, domain.StatusBadSignature}, | |
| 33 | + | {"x509", sshC.payload, []byte("-----BEGIN SIGNED MESSAGE-----\nMIIB\n-----END SIGNED MESSAGE-----\n"), domain.FormatX509, domain.StatusUnsupported}, | |
| 34 | + | {"unknown armor", sshC.payload, []byte("-----BEGIN SOMETHING-----\nAAAA\n-----END SOMETHING-----\n"), domain.FormatNone, domain.StatusBadSignature}, | |
| 35 | + | {"empty", sshC.payload, nil, domain.FormatNone, domain.StatusBadSignature}, | |
| 36 | + | } | |
| 37 | + | for _, tt := range tests { | |
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | v := sigverify.Verifier{}.Verify(tt.payload, tt.signature, email, sshC.signedAt, keys) | |
| 40 | + | assert.Equal(t, tt.wantFormat, v.Format) | |
| 41 | + | assert.Equal(t, tt.wantStatus, v.Status) | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
M internal/adapters/web/commit.go +7 -0
| @@ -46,6 +46,10 @@ type CommitPage struct { | |||
| 46 | 46 | Invisible bool | |
| 47 | 47 | // Checks is the combined state of the CI checks of the commit. | |
| 48 | 48 | Checks ChecksSummary | |
| 49 | + | // Sig is the signature badge, nil when the commit was not checked. | |
| 50 | + | Sig *SignatureBadge | |
| 51 | + | // Verification is what is known of the signing key, for the details. | |
| 52 | + | Verification domain.CommitVerification | |
| 49 | 53 | } | |
| 50 | 54 | ||
| 51 | 55 | // DiffView is how a page lays its diff out: the layouts in the page and the | |
| @@ -121,6 +125,9 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 121 | 125 | Adds: res.Stats.Additions, | |
| 122 | 126 | Dels: res.Stats.Deletions, | |
| 123 | 127 | Files: s.hl.files(ctx, res.Diff.Files), | |
| 128 | + | ||
| 129 | + | Sig: signatureBadge(c.Verification, true), | |
| 130 | + | Verification: c.Verification, | |
| 124 | 131 | } | |
| 125 | 132 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid)) | |
| 126 | 133 | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { | |
M internal/adapters/web/components.templ +14 -0
| @@ -193,6 +193,9 @@ templ CommitRows(rows []CommitRow) { | |||
| 193 | 193 | if c.Merge { | |
| 194 | 194 | <span class="pill">merge</span> | |
| 195 | 195 | } | |
| 196 | + | if c.Sig != nil { | |
| 197 | + | @signaturePill(c.Sig) | |
| 198 | + | } | |
| 196 | 199 | for _, b := range c.Branches { | |
| 197 | 200 | <span class="pill">{ b }</span> | |
| 198 | 201 | } | |
| @@ -208,3 +211,14 @@ templ CommitRows(rows []CommitRow) { | |||
| 208 | 211 | </tbody> | |
| 209 | 212 | </table> | |
| 210 | 213 | } | |
| 214 | + | ||
| 215 | + | // signaturePill is the badge of a signed commit: the protocol and the verdict | |
| 216 | + | // as text, the reason in the tooltip. | |
| 217 | + | templ signaturePill(b *SignatureBadge) { | |
| 218 | + | <span class={ "pill sig", b.Class } title={ b.Title }> | |
| 219 | + | if b.Proto != "" { | |
| 220 | + | <span class="sig-proto">{ b.Proto }</span> | |
| 221 | + | } | |
| 222 | + | { b.Label } | |
| 223 | + | </span> | |
| 224 | + | } | |
M internal/adapters/web/components_templ.go +106 -6 generated file: collapsed
M internal/adapters/web/diff.templ +15 -0
| @@ -40,6 +40,21 @@ templ Commit(l Layout, p CommitPage) { | |||
| 40 | 40 | { " · diff against the first parent" } | |
| 41 | 41 | } | |
| 42 | 42 | </dd> | |
| 43 | + | if p.Sig != nil { | |
| 44 | + | <dt>signature</dt> | |
| 45 | + | <dd> | |
| 46 | + | @signaturePill(p.Sig) | |
| 47 | + | if p.Verification.Fingerprint != "" { | |
| 48 | + | <code>{ p.Verification.Fingerprint }</code> | |
| 49 | + | } | |
| 50 | + | if p.Verification.KeyTitle != "" { | |
| 51 | + | <span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span> | |
| 52 | + | } | |
| 53 | + | if p.Sig != nil && p.Sig.Class != "sig-none" { | |
| 54 | + | <span class="sig-why dim">{ p.Sig.Title }</span> | |
| 55 | + | } | |
| 56 | + | </dd> | |
| 57 | + | } | |
| 43 | 58 | if p.Checks.Total > 0 { | |
| 44 | 59 | <dt>checks</dt> | |
| 45 | 60 | <dd>@CommitChecks(p.Checks)</dd> | |
M internal/adapters/web/diff_templ.go +555 -484
| @@ -202,8 +202,79 @@ func Commit(l Layout, p CommitPage) templ.Component { | |||
| 202 | 202 | if templ_7745c5c3_Err != nil { | |
| 203 | 203 | return templ_7745c5c3_Err | |
| 204 | 204 | } | |
| 205 | + | if p.Sig != nil { | |
| 206 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<dt>signature</dt><dd>") | |
| 207 | + | if templ_7745c5c3_Err != nil { | |
| 208 | + | return templ_7745c5c3_Err | |
| 209 | + | } | |
| 210 | + | templ_7745c5c3_Err = signaturePill(p.Sig).Render(ctx, templ_7745c5c3_Buffer) | |
| 211 | + | if templ_7745c5c3_Err != nil { | |
| 212 | + | return templ_7745c5c3_Err | |
| 213 | + | } | |
| 214 | + | if p.Verification.Fingerprint != "" { | |
| 215 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<code>") | |
| 216 | + | if templ_7745c5c3_Err != nil { | |
| 217 | + | return templ_7745c5c3_Err | |
| 218 | + | } | |
| 219 | + | var templ_7745c5c3_Var10 string | |
| 220 | + | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(p.Verification.Fingerprint) | |
| 221 | + | if templ_7745c5c3_Err != nil { | |
| 222 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 48, Col: 41} | |
| 223 | + | } | |
| 224 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |
| 225 | + | if templ_7745c5c3_Err != nil { | |
| 226 | + | return templ_7745c5c3_Err | |
| 227 | + | } | |
| 228 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</code> ") | |
| 229 | + | if templ_7745c5c3_Err != nil { | |
| 230 | + | return templ_7745c5c3_Err | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | if p.Verification.KeyTitle != "" { | |
| 234 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"dim\">") | |
| 235 | + | if templ_7745c5c3_Err != nil { | |
| 236 | + | return templ_7745c5c3_Err | |
| 237 | + | } | |
| 238 | + | var templ_7745c5c3_Var11 string | |
| 239 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("· " + visibleText(p.Verification.KeyTitle)) | |
| 240 | + | if templ_7745c5c3_Err != nil { | |
| 241 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 51, Col: 71} | |
| 242 | + | } | |
| 243 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |
| 244 | + | if templ_7745c5c3_Err != nil { | |
| 245 | + | return templ_7745c5c3_Err | |
| 246 | + | } | |
| 247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span> ") | |
| 248 | + | if templ_7745c5c3_Err != nil { | |
| 249 | + | return templ_7745c5c3_Err | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | if p.Sig != nil && p.Sig.Class != "sig-none" { | |
| 253 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"sig-why dim\">") | |
| 254 | + | if templ_7745c5c3_Err != nil { | |
| 255 | + | return templ_7745c5c3_Err | |
| 256 | + | } | |
| 257 | + | var templ_7745c5c3_Var12 string | |
| 258 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(p.Sig.Title) | |
| 259 | + | if templ_7745c5c3_Err != nil { | |
| 260 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 46} | |
| 261 | + | } | |
| 262 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 263 | + | if templ_7745c5c3_Err != nil { | |
| 264 | + | return templ_7745c5c3_Err | |
| 265 | + | } | |
| 266 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span>") | |
| 267 | + | if templ_7745c5c3_Err != nil { | |
| 268 | + | return templ_7745c5c3_Err | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</dd>") | |
| 272 | + | if templ_7745c5c3_Err != nil { | |
| 273 | + | return templ_7745c5c3_Err | |
| 274 | + | } | |
| 275 | + | } | |
| 205 | 276 | if p.Checks.Total > 0 { | |
| 206 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<dt>checks</dt><dd>") | |
| 277 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<dt>checks</dt><dd>") | |
| 207 | 278 | if templ_7745c5c3_Err != nil { | |
| 208 | 279 | return templ_7745c5c3_Err | |
| 209 | 280 | } | |
| @@ -211,80 +282,80 @@ func Commit(l Layout, p CommitPage) templ.Component { | |||
| 211 | 282 | if templ_7745c5c3_Err != nil { | |
| 212 | 283 | return templ_7745c5c3_Err | |
| 213 | 284 | } | |
| 214 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</dd>") | |
| 285 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</dd>") | |
| 215 | 286 | if templ_7745c5c3_Err != nil { | |
| 216 | 287 | return templ_7745c5c3_Err | |
| 217 | 288 | } | |
| 218 | 289 | } | |
| 219 | 290 | if len(p.Refs) > 0 { | |
| 220 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>refs</dt><dd>") | |
| 291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<dt>refs</dt><dd>") | |
| 221 | 292 | if templ_7745c5c3_Err != nil { | |
| 222 | 293 | return templ_7745c5c3_Err | |
| 223 | 294 | } | |
| 224 | 295 | for _, r := range p.Refs { | |
| 225 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"pill\">") | |
| 296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"pill\">") | |
| 226 | 297 | if templ_7745c5c3_Err != nil { | |
| 227 | 298 | return templ_7745c5c3_Err | |
| 228 | 299 | } | |
| 229 | - | var templ_7745c5c3_Var10 string | |
| 230 | - | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r) | |
| 300 | + | var templ_7745c5c3_Var13 string | |
| 301 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r) | |
| 231 | 302 | if templ_7745c5c3_Err != nil { | |
| 232 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 51, Col: 29} | |
| 303 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 66, Col: 29} | |
| 233 | 304 | } | |
| 234 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |
| 305 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 235 | 306 | if templ_7745c5c3_Err != nil { | |
| 236 | 307 | return templ_7745c5c3_Err | |
| 237 | 308 | } | |
| 238 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span>") | |
| 309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>") | |
| 239 | 310 | if templ_7745c5c3_Err != nil { | |
| 240 | 311 | return templ_7745c5c3_Err | |
| 241 | 312 | } | |
| 242 | 313 | } | |
| 243 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</dd>") | |
| 314 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</dd>") | |
| 244 | 315 | if templ_7745c5c3_Err != nil { | |
| 245 | 316 | return templ_7745c5c3_Err | |
| 246 | 317 | } | |
| 247 | 318 | } | |
| 248 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</dl></header><div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">") | |
| 319 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</dl></header><div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">") | |
| 249 | 320 | if templ_7745c5c3_Err != nil { | |
| 250 | 321 | return templ_7745c5c3_Err | |
| 251 | 322 | } | |
| 252 | - | var templ_7745c5c3_Var11 string | |
| 253 | - | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files")) | |
| 323 | + | var templ_7745c5c3_Var14 string | |
| 324 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files")) | |
| 254 | 325 | if templ_7745c5c3_Err != nil { | |
| 255 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 58, Col: 56} | |
| 326 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 73, Col: 56} | |
| 256 | 327 | } | |
| 257 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |
| 328 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 258 | 329 | if templ_7745c5c3_Err != nil { | |
| 259 | 330 | return templ_7745c5c3_Err | |
| 260 | 331 | } | |
| 261 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " · <span class=\"plus\">+") | |
| 332 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " · <span class=\"plus\">+") | |
| 262 | 333 | if templ_7745c5c3_Err != nil { | |
| 263 | 334 | return templ_7745c5c3_Err | |
| 264 | 335 | } | |
| 265 | - | var templ_7745c5c3_Var12 string | |
| 266 | - | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds)) | |
| 336 | + | var templ_7745c5c3_Var15 string | |
| 337 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds)) | |
| 267 | 338 | if templ_7745c5c3_Err != nil { | |
| 268 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 58, Col: 96} | |
| 339 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 73, Col: 96} | |
| 269 | 340 | } | |
| 270 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 341 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 271 | 342 | if templ_7745c5c3_Err != nil { | |
| 272 | 343 | return templ_7745c5c3_Err | |
| 273 | 344 | } | |
| 274 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span> <span class=\"minus\">-") | |
| 345 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span> <span class=\"minus\">-") | |
| 275 | 346 | if templ_7745c5c3_Err != nil { | |
| 276 | 347 | return templ_7745c5c3_Err | |
| 277 | 348 | } | |
| 278 | - | var templ_7745c5c3_Var13 string | |
| 279 | - | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels)) | |
| 349 | + | var templ_7745c5c3_Var16 string | |
| 350 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels)) | |
| 280 | 351 | if templ_7745c5c3_Err != nil { | |
| 281 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 58, Col: 141} | |
| 352 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 73, Col: 141} | |
| 282 | 353 | } | |
| 283 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 354 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 284 | 355 | if templ_7745c5c3_Err != nil { | |
| 285 | 356 | return templ_7745c5c3_Err | |
| 286 | 357 | } | |
| 287 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></span> <span class=\"grow\"></span>") | |
| 358 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span></span> <span class=\"grow\"></span>") | |
| 288 | 359 | if templ_7745c5c3_Err != nil { | |
| 289 | 360 | return templ_7745c5c3_Err | |
| 290 | 361 | } | |
| @@ -300,87 +371,87 @@ func Commit(l Layout, p CommitPage) templ.Component { | |||
| 300 | 371 | if templ_7745c5c3_Err != nil { | |
| 301 | 372 | return templ_7745c5c3_Err | |
| 302 | 373 | } | |
| 303 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>") | |
| 374 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>") | |
| 304 | 375 | if templ_7745c5c3_Err != nil { | |
| 305 | 376 | return templ_7745c5c3_Err | |
| 306 | 377 | } | |
| 307 | 378 | if p.Invisible { | |
| 308 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\"><U+XXXX></span> markers, never applied.</p>") | |
| 379 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\"><U+XXXX></span> markers, never applied.</p>") | |
| 309 | 380 | if templ_7745c5c3_Err != nil { | |
| 310 | 381 | return templ_7745c5c3_Err | |
| 311 | 382 | } | |
| 312 | 383 | } | |
| 313 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") | |
| 384 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ") | |
| 314 | 385 | if templ_7745c5c3_Err != nil { | |
| 315 | 386 | return templ_7745c5c3_Err | |
| 316 | 387 | } | |
| 317 | 388 | if p.Note != "" { | |
| 318 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<p class=\"notice\">") | |
| 389 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<p class=\"notice\">") | |
| 319 | 390 | if templ_7745c5c3_Err != nil { | |
| 320 | 391 | return templ_7745c5c3_Err | |
| 321 | 392 | } | |
| 322 | - | var templ_7745c5c3_Var14 string | |
| 323 | - | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 393 | + | var templ_7745c5c3_Var17 string | |
| 394 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 324 | 395 | if templ_7745c5c3_Err != nil { | |
| 325 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 71, Col: 29} | |
| 396 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 86, Col: 29} | |
| 326 | 397 | } | |
| 327 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 398 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 328 | 399 | if templ_7745c5c3_Err != nil { | |
| 329 | 400 | return templ_7745c5c3_Err | |
| 330 | 401 | } | |
| 331 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</p>") | |
| 402 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p>") | |
| 332 | 403 | if templ_7745c5c3_Err != nil { | |
| 333 | 404 | return templ_7745c5c3_Err | |
| 334 | 405 | } | |
| 335 | 406 | } | |
| 336 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " <div id=\"diffs\"") | |
| 407 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " <div id=\"diffs\"") | |
| 337 | 408 | if templ_7745c5c3_Err != nil { | |
| 338 | 409 | return templ_7745c5c3_Err | |
| 339 | 410 | } | |
| 340 | 411 | if p.Both { | |
| 341 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " data-diff=\"") | |
| 412 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " data-diff=\"") | |
| 342 | 413 | if templ_7745c5c3_Err != nil { | |
| 343 | 414 | return templ_7745c5c3_Err | |
| 344 | 415 | } | |
| 345 | - | var templ_7745c5c3_Var15 string | |
| 346 | - | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View) | |
| 416 | + | var templ_7745c5c3_Var18 string | |
| 417 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View) | |
| 347 | 418 | if templ_7745c5c3_Err != nil { | |
| 348 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 76, Col: 22} | |
| 419 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 91, Col: 22} | |
| 349 | 420 | } | |
| 350 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 421 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) | |
| 351 | 422 | if templ_7745c5c3_Err != nil { | |
| 352 | 423 | return templ_7745c5c3_Err | |
| 353 | 424 | } | |
| 354 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"") | |
| 425 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"") | |
| 355 | 426 | if templ_7745c5c3_Err != nil { | |
| 356 | 427 | return templ_7745c5c3_Err | |
| 357 | 428 | } | |
| 358 | 429 | } else { | |
| 359 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " data-view=\"") | |
| 430 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " data-view=\"") | |
| 360 | 431 | if templ_7745c5c3_Err != nil { | |
| 361 | 432 | return templ_7745c5c3_Err | |
| 362 | 433 | } | |
| 363 | - | var templ_7745c5c3_Var16 string | |
| 364 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View) | |
| 434 | + | var templ_7745c5c3_Var19 string | |
| 435 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View) | |
| 365 | 436 | if templ_7745c5c3_Err != nil { | |
| 366 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 78, Col: 22} | |
| 437 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 93, Col: 22} | |
| 367 | 438 | } | |
| 368 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 439 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) | |
| 369 | 440 | if templ_7745c5c3_Err != nil { | |
| 370 | 441 | return templ_7745c5c3_Err | |
| 371 | 442 | } | |
| 372 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"") | |
| 443 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"") | |
| 373 | 444 | if templ_7745c5c3_Err != nil { | |
| 374 | 445 | return templ_7745c5c3_Err | |
| 375 | 446 | } | |
| 376 | 447 | } | |
| 377 | 448 | if p.Both && !p.Pinned { | |
| 378 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"") | |
| 449 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"") | |
| 379 | 450 | if templ_7745c5c3_Err != nil { | |
| 380 | 451 | return templ_7745c5c3_Err | |
| 381 | 452 | } | |
| 382 | 453 | } | |
| 383 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">") | |
| 454 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">") | |
| 384 | 455 | if templ_7745c5c3_Err != nil { | |
| 385 | 456 | return templ_7745c5c3_Err | |
| 386 | 457 | } | |
| @@ -391,40 +462,40 @@ func Commit(l Layout, p CommitPage) templ.Component { | |||
| 391 | 462 | } | |
| 392 | 463 | } | |
| 393 | 464 | if len(p.Files) == 0 { | |
| 394 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"note\">no changed files") | |
| 465 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<p class=\"note\">no changed files") | |
| 395 | 466 | if templ_7745c5c3_Err != nil { | |
| 396 | 467 | return templ_7745c5c3_Err | |
| 397 | 468 | } | |
| 398 | - | var templ_7745c5c3_Var17 string | |
| 399 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 469 | + | var templ_7745c5c3_Var20 string | |
| 470 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 400 | 471 | if templ_7745c5c3_Err != nil { | |
| 401 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 88, Col: 41} | |
| 472 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 103, Col: 41} | |
| 402 | 473 | } | |
| 403 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 474 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 404 | 475 | if templ_7745c5c3_Err != nil { | |
| 405 | 476 | return templ_7745c5c3_Err | |
| 406 | 477 | } | |
| 407 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ") | |
| 478 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " ") | |
| 408 | 479 | if templ_7745c5c3_Err != nil { | |
| 409 | 480 | return templ_7745c5c3_Err | |
| 410 | 481 | } | |
| 411 | 482 | if p.Merge { | |
| 412 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "against the first parent.") | |
| 483 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "against the first parent.") | |
| 413 | 484 | if templ_7745c5c3_Err != nil { | |
| 414 | 485 | return templ_7745c5c3_Err | |
| 415 | 486 | } | |
| 416 | 487 | } else { | |
| 417 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "in this commit.") | |
| 488 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "in this commit.") | |
| 418 | 489 | if templ_7745c5c3_Err != nil { | |
| 419 | 490 | return templ_7745c5c3_Err | |
| 420 | 491 | } | |
| 421 | 492 | } | |
| 422 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p>") | |
| 493 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</p>") | |
| 423 | 494 | if templ_7745c5c3_Err != nil { | |
| 424 | 495 | return templ_7745c5c3_Err | |
| 425 | 496 | } | |
| 426 | 497 | } | |
| 427 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div>") | |
| 498 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div>") | |
| 428 | 499 | if templ_7745c5c3_Err != nil { | |
| 429 | 500 | return templ_7745c5c3_Err | |
| 430 | 501 | } | |
| @@ -457,106 +528,106 @@ func viewLink(p DiffView, view, label, key, href string) templ.Component { | |||
| 457 | 528 | }() | |
| 458 | 529 | } | |
| 459 | 530 | ctx = templ.InitializeContext(ctx) | |
| 460 | - | templ_7745c5c3_Var18 := templ.GetChildren(ctx) | |
| 461 | - | if templ_7745c5c3_Var18 == nil { | |
| 462 | - | templ_7745c5c3_Var18 = templ.NopComponent | |
| 531 | + | templ_7745c5c3_Var21 := templ.GetChildren(ctx) | |
| 532 | + | if templ_7745c5c3_Var21 == nil { | |
| 533 | + | templ_7745c5c3_Var21 = templ.NopComponent | |
| 463 | 534 | } | |
| 464 | 535 | ctx = templ.ClearChildren(ctx) | |
| 465 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"") | |
| 536 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"") | |
| 466 | 537 | if templ_7745c5c3_Err != nil { | |
| 467 | 538 | return templ_7745c5c3_Err | |
| 468 | 539 | } | |
| 469 | - | var templ_7745c5c3_Var19 templ.SafeURL | |
| 470 | - | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) | |
| 540 | + | var templ_7745c5c3_Var22 templ.SafeURL | |
| 541 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) | |
| 471 | 542 | if templ_7745c5c3_Err != nil { | |
| 472 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 105, Col: 28} | |
| 543 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 120, Col: 28} | |
| 473 | 544 | } | |
| 474 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 545 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 475 | 546 | if templ_7745c5c3_Err != nil { | |
| 476 | 547 | return templ_7745c5c3_Err | |
| 477 | 548 | } | |
| 478 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" class=\"btn vbtn\" data-view=\"") | |
| 549 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"btn vbtn\" data-view=\"") | |
| 479 | 550 | if templ_7745c5c3_Err != nil { | |
| 480 | 551 | return templ_7745c5c3_Err | |
| 481 | 552 | } | |
| 482 | - | var templ_7745c5c3_Var20 string | |
| 483 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(view) | |
| 553 | + | var templ_7745c5c3_Var23 string | |
| 554 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(view) | |
| 484 | 555 | if templ_7745c5c3_Err != nil { | |
| 485 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 107, Col: 18} | |
| 556 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 122, Col: 18} | |
| 486 | 557 | } | |
| 487 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) | |
| 558 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) | |
| 488 | 559 | if templ_7745c5c3_Err != nil { | |
| 489 | 560 | return templ_7745c5c3_Err | |
| 490 | 561 | } | |
| 491 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" aria-keyshortcuts=\"") | |
| 562 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-keyshortcuts=\"") | |
| 492 | 563 | if templ_7745c5c3_Err != nil { | |
| 493 | 564 | return templ_7745c5c3_Err | |
| 494 | 565 | } | |
| 495 | - | var templ_7745c5c3_Var21 string | |
| 496 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(key) | |
| 566 | + | var templ_7745c5c3_Var24 string | |
| 567 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(key) | |
| 497 | 568 | if templ_7745c5c3_Err != nil { | |
| 498 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 108, Col: 25} | |
| 569 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 123, Col: 25} | |
| 499 | 570 | } | |
| 500 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) | |
| 571 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) | |
| 501 | 572 | if templ_7745c5c3_Err != nil { | |
| 502 | 573 | return templ_7745c5c3_Err | |
| 503 | 574 | } | |
| 504 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"") | |
| 575 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"") | |
| 505 | 576 | if templ_7745c5c3_Err != nil { | |
| 506 | 577 | return templ_7745c5c3_Err | |
| 507 | 578 | } | |
| 508 | 579 | if p.View == view { | |
| 509 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"true\"") | |
| 580 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " aria-current=\"true\"") | |
| 510 | 581 | if templ_7745c5c3_Err != nil { | |
| 511 | 582 | return templ_7745c5c3_Err | |
| 512 | 583 | } | |
| 513 | 584 | } | |
| 514 | 585 | if p.Both { | |
| 515 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " data-fire=\"") | |
| 586 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " data-fire=\"") | |
| 516 | 587 | if templ_7745c5c3_Err != nil { | |
| 517 | 588 | return templ_7745c5c3_Err | |
| 518 | 589 | } | |
| 519 | - | var templ_7745c5c3_Var22 string | |
| 520 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(view) | |
| 590 | + | var templ_7745c5c3_Var25 string | |
| 591 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(view) | |
| 521 | 592 | if templ_7745c5c3_Err != nil { | |
| 522 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 113, Col: 19} | |
| 593 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 128, Col: 19} | |
| 523 | 594 | } | |
| 524 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) | |
| 595 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) | |
| 525 | 596 | if templ_7745c5c3_Err != nil { | |
| 526 | 597 | return templ_7745c5c3_Err | |
| 527 | 598 | } | |
| 528 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" :aria-current=\"q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false\"") | |
| 599 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" :aria-current=\"q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false\"") | |
| 529 | 600 | if templ_7745c5c3_Err != nil { | |
| 530 | 601 | return templ_7745c5c3_Err | |
| 531 | 602 | } | |
| 532 | 603 | } | |
| 533 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">") | |
| 604 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, ">") | |
| 534 | 605 | if templ_7745c5c3_Err != nil { | |
| 535 | 606 | return templ_7745c5c3_Err | |
| 536 | 607 | } | |
| 537 | - | var templ_7745c5c3_Var23 string | |
| 538 | - | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(label) | |
| 608 | + | var templ_7745c5c3_Var26 string | |
| 609 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(label) | |
| 539 | 610 | if templ_7745c5c3_Err != nil { | |
| 540 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 116, Col: 9} | |
| 611 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 131, Col: 9} | |
| 541 | 612 | } | |
| 542 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 613 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 543 | 614 | if templ_7745c5c3_Err != nil { | |
| 544 | 615 | return templ_7745c5c3_Err | |
| 545 | 616 | } | |
| 546 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<kbd aria-hidden=\"true\">") | |
| 617 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<kbd aria-hidden=\"true\">") | |
| 547 | 618 | if templ_7745c5c3_Err != nil { | |
| 548 | 619 | return templ_7745c5c3_Err | |
| 549 | 620 | } | |
| 550 | - | var templ_7745c5c3_Var24 string | |
| 551 | - | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(key) | |
| 621 | + | var templ_7745c5c3_Var27 string | |
| 622 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(key) | |
| 552 | 623 | if templ_7745c5c3_Err != nil { | |
| 553 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 116, Col: 40} | |
| 624 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 131, Col: 40} | |
| 554 | 625 | } | |
| 555 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 626 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 556 | 627 | if templ_7745c5c3_Err != nil { | |
| 557 | 628 | return templ_7745c5c3_Err | |
| 558 | 629 | } | |
| 559 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</kbd></a>") | |
| 630 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</kbd></a>") | |
| 560 | 631 | if templ_7745c5c3_Err != nil { | |
| 561 | 632 | return templ_7745c5c3_Err | |
| 562 | 633 | } | |
| @@ -582,12 +653,12 @@ func collapseButton() templ.Component { | |||
| 582 | 653 | }() | |
| 583 | 654 | } | |
| 584 | 655 | ctx = templ.InitializeContext(ctx) | |
| 585 | - | templ_7745c5c3_Var25 := templ.GetChildren(ctx) | |
| 586 | - | if templ_7745c5c3_Var25 == nil { | |
| 587 | - | templ_7745c5c3_Var25 = templ.NopComponent | |
| 656 | + | templ_7745c5c3_Var28 := templ.GetChildren(ctx) | |
| 657 | + | if templ_7745c5c3_Var28 == nil { | |
| 658 | + | templ_7745c5c3_Var28 = templ.NopComponent | |
| 588 | 659 | } | |
| 589 | 660 | ctx = templ.ClearChildren(ctx) | |
| 590 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>") | |
| 661 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>") | |
| 591 | 662 | if templ_7745c5c3_Err != nil { | |
| 592 | 663 | return templ_7745c5c3_Err | |
| 593 | 664 | } | |
| @@ -611,83 +682,83 @@ func personView(pr Person) templ.Component { | |||
| 611 | 682 | }() | |
| 612 | 683 | } | |
| 613 | 684 | ctx = templ.InitializeContext(ctx) | |
| 614 | - | templ_7745c5c3_Var26 := templ.GetChildren(ctx) | |
| 615 | - | if templ_7745c5c3_Var26 == nil { | |
| 616 | - | templ_7745c5c3_Var26 = templ.NopComponent | |
| 685 | + | templ_7745c5c3_Var29 := templ.GetChildren(ctx) | |
| 686 | + | if templ_7745c5c3_Var29 == nil { | |
| 687 | + | templ_7745c5c3_Var29 = templ.NopComponent | |
| 617 | 688 | } | |
| 618 | 689 | ctx = templ.ClearChildren(ctx) | |
| 619 | - | var templ_7745c5c3_Var27 string | |
| 620 | - | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name) | |
| 690 | + | var templ_7745c5c3_Var30 string | |
| 691 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name) | |
| 621 | 692 | if templ_7745c5c3_Err != nil { | |
| 622 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 128, Col: 10} | |
| 693 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 143, Col: 10} | |
| 623 | 694 | } | |
| 624 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 695 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 625 | 696 | if templ_7745c5c3_Err != nil { | |
| 626 | 697 | return templ_7745c5c3_Err | |
| 627 | 698 | } | |
| 628 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ") | |
| 699 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " ") | |
| 629 | 700 | if templ_7745c5c3_Err != nil { | |
| 630 | 701 | return templ_7745c5c3_Err | |
| 631 | 702 | } | |
| 632 | 703 | if pr.WhenAt != "" { | |
| 633 | - | var templ_7745c5c3_Var28 string | |
| 634 | - | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 704 | + | var templ_7745c5c3_Var31 string | |
| 705 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 635 | 706 | if templ_7745c5c3_Err != nil { | |
| 636 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 10} | |
| 707 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 145, Col: 10} | |
| 637 | 708 | } | |
| 638 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) | |
| 709 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 639 | 710 | if templ_7745c5c3_Err != nil { | |
| 640 | 711 | return templ_7745c5c3_Err | |
| 641 | 712 | } | |
| 642 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<time datetime=\"") | |
| 713 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<time datetime=\"") | |
| 643 | 714 | if templ_7745c5c3_Err != nil { | |
| 644 | 715 | return templ_7745c5c3_Err | |
| 645 | 716 | } | |
| 646 | - | var templ_7745c5c3_Var29 string | |
| 647 | - | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt) | |
| 717 | + | var templ_7745c5c3_Var32 string | |
| 718 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt) | |
| 648 | 719 | if templ_7745c5c3_Err != nil { | |
| 649 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 38} | |
| 720 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 145, Col: 38} | |
| 650 | 721 | } | |
| 651 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) | |
| 722 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) | |
| 652 | 723 | if templ_7745c5c3_Err != nil { | |
| 653 | 724 | return templ_7745c5c3_Err | |
| 654 | 725 | } | |
| 655 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" title=\"") | |
| 726 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" title=\"") | |
| 656 | 727 | if templ_7745c5c3_Err != nil { | |
| 657 | 728 | return templ_7745c5c3_Err | |
| 658 | 729 | } | |
| 659 | - | var templ_7745c5c3_Var30 string | |
| 660 | - | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs) | |
| 730 | + | var templ_7745c5c3_Var33 string | |
| 731 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs) | |
| 661 | 732 | if templ_7745c5c3_Err != nil { | |
| 662 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 55} | |
| 733 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 145, Col: 55} | |
| 663 | 734 | } | |
| 664 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) | |
| 735 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) | |
| 665 | 736 | if templ_7745c5c3_Err != nil { | |
| 666 | 737 | return templ_7745c5c3_Err | |
| 667 | 738 | } | |
| 668 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">") | |
| 739 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">") | |
| 669 | 740 | if templ_7745c5c3_Err != nil { | |
| 670 | 741 | return templ_7745c5c3_Err | |
| 671 | 742 | } | |
| 672 | - | var templ_7745c5c3_Var31 string | |
| 673 | - | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When) | |
| 743 | + | var templ_7745c5c3_Var34 string | |
| 744 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When) | |
| 674 | 745 | if templ_7745c5c3_Err != nil { | |
| 675 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 67} | |
| 746 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 145, Col: 67} | |
| 676 | 747 | } | |
| 677 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 748 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 678 | 749 | if templ_7745c5c3_Err != nil { | |
| 679 | 750 | return templ_7745c5c3_Err | |
| 680 | 751 | } | |
| 681 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</time>") | |
| 752 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</time>") | |
| 682 | 753 | if templ_7745c5c3_Err != nil { | |
| 683 | 754 | return templ_7745c5c3_Err | |
| 684 | 755 | } | |
| 685 | - | var templ_7745c5c3_Var32 string | |
| 686 | - | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs) | |
| 756 | + | var templ_7745c5c3_Var35 string | |
| 757 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs) | |
| 687 | 758 | if templ_7745c5c3_Err != nil { | |
| 688 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 93} | |
| 759 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 145, Col: 93} | |
| 689 | 760 | } | |
| 690 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 761 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 691 | 762 | if templ_7745c5c3_Err != nil { | |
| 692 | 763 | return templ_7745c5c3_Err | |
| 693 | 764 | } | |
| @@ -712,25 +783,25 @@ func commitRail(p CommitPage) templ.Component { | |||
| 712 | 783 | }() | |
| 713 | 784 | } | |
| 714 | 785 | ctx = templ.InitializeContext(ctx) | |
| 715 | - | templ_7745c5c3_Var33 := templ.GetChildren(ctx) | |
| 716 | - | if templ_7745c5c3_Var33 == nil { | |
| 717 | - | templ_7745c5c3_Var33 = templ.NopComponent | |
| 786 | + | templ_7745c5c3_Var36 := templ.GetChildren(ctx) | |
| 787 | + | if templ_7745c5c3_Var36 == nil { | |
| 788 | + | templ_7745c5c3_Var36 = templ.NopComponent | |
| 718 | 789 | } | |
| 719 | 790 | ctx = templ.ClearChildren(ctx) | |
| 720 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">") | |
| 791 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">") | |
| 721 | 792 | if templ_7745c5c3_Err != nil { | |
| 722 | 793 | return templ_7745c5c3_Err | |
| 723 | 794 | } | |
| 724 | - | var templ_7745c5c3_Var34 string | |
| 725 | - | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files))) | |
| 795 | + | var templ_7745c5c3_Var37 string | |
| 796 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files))) | |
| 726 | 797 | if templ_7745c5c3_Err != nil { | |
| 727 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 136, Col: 65} | |
| 798 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 65} | |
| 728 | 799 | } | |
| 729 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 800 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 730 | 801 | if templ_7745c5c3_Err != nil { | |
| 731 | 802 | return templ_7745c5c3_Err | |
| 732 | 803 | } | |
| 733 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></h2>") | |
| 804 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span></h2>") | |
| 734 | 805 | if templ_7745c5c3_Err != nil { | |
| 735 | 806 | return templ_7745c5c3_Err | |
| 736 | 807 | } | |
| @@ -738,7 +809,7 @@ func commitRail(p CommitPage) templ.Component { | |||
| 738 | 809 | if templ_7745c5c3_Err != nil { | |
| 739 | 810 | return templ_7745c5c3_Err | |
| 740 | 811 | } | |
| 741 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</section>") | |
| 812 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</section>") | |
| 742 | 813 | if templ_7745c5c3_Err != nil { | |
| 743 | 814 | return templ_7745c5c3_Err | |
| 744 | 815 | } | |
| @@ -764,236 +835,236 @@ func FileList(files []DiffFile) templ.Component { | |||
| 764 | 835 | }() | |
| 765 | 836 | } | |
| 766 | 837 | ctx = templ.InitializeContext(ctx) | |
| 767 | - | templ_7745c5c3_Var35 := templ.GetChildren(ctx) | |
| 768 | - | if templ_7745c5c3_Var35 == nil { | |
| 769 | - | templ_7745c5c3_Var35 = templ.NopComponent | |
| 838 | + | templ_7745c5c3_Var38 := templ.GetChildren(ctx) | |
| 839 | + | if templ_7745c5c3_Var38 == nil { | |
| 840 | + | templ_7745c5c3_Var38 = templ.NopComponent | |
| 770 | 841 | } | |
| 771 | 842 | ctx = templ.ClearChildren(ctx) | |
| 772 | 843 | if len(files) > fileFilterMin { | |
| 773 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>") | |
| 844 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>") | |
| 774 | 845 | if templ_7745c5c3_Err != nil { | |
| 775 | 846 | return templ_7745c5c3_Err | |
| 776 | 847 | } | |
| 777 | 848 | } | |
| 778 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<ol class=\"files\" aria-label=\"Changed files\">") | |
| 849 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<ol class=\"files\" aria-label=\"Changed files\">") | |
| 779 | 850 | if templ_7745c5c3_Err != nil { | |
| 780 | 851 | return templ_7745c5c3_Err | |
| 781 | 852 | } | |
| 782 | 853 | for _, f := range files { | |
| 783 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<li><a href=\"") | |
| 854 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<li><a href=\"") | |
| 784 | 855 | if templ_7745c5c3_Err != nil { | |
| 785 | 856 | return templ_7745c5c3_Err | |
| 786 | 857 | } | |
| 787 | - | var templ_7745c5c3_Var36 templ.SafeURL | |
| 788 | - | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID)) | |
| 858 | + | var templ_7745c5c3_Var39 templ.SafeURL | |
| 859 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID)) | |
| 789 | 860 | if templ_7745c5c3_Err != nil { | |
| 790 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 161, Col: 37} | |
| 861 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 176, Col: 37} | |
| 791 | 862 | } | |
| 792 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 863 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 793 | 864 | if templ_7745c5c3_Err != nil { | |
| 794 | 865 | return templ_7745c5c3_Err | |
| 795 | 866 | } | |
| 796 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" title=\"") | |
| 867 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" title=\"") | |
| 797 | 868 | if templ_7745c5c3_Err != nil { | |
| 798 | 869 | return templ_7745c5c3_Err | |
| 799 | 870 | } | |
| 800 | - | var templ_7745c5c3_Var37 string | |
| 801 | - | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path) | |
| 871 | + | var templ_7745c5c3_Var40 string | |
| 872 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path) | |
| 802 | 873 | if templ_7745c5c3_Err != nil { | |
| 803 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 162, Col: 19} | |
| 874 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 19} | |
| 804 | 875 | } | |
| 805 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) | |
| 876 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) | |
| 806 | 877 | if templ_7745c5c3_Err != nil { | |
| 807 | 878 | return templ_7745c5c3_Err | |
| 808 | 879 | } | |
| 809 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"") | |
| 880 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"") | |
| 810 | 881 | if templ_7745c5c3_Err != nil { | |
| 811 | 882 | return templ_7745c5c3_Err | |
| 812 | 883 | } | |
| 813 | - | var templ_7745c5c3_Var38 string | |
| 814 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status) | |
| 884 | + | var templ_7745c5c3_Var41 string | |
| 885 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status) | |
| 815 | 886 | if templ_7745c5c3_Err != nil { | |
| 816 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 40} | |
| 887 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 180, Col: 40} | |
| 817 | 888 | } | |
| 818 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) | |
| 889 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41) | |
| 819 | 890 | if templ_7745c5c3_Err != nil { | |
| 820 | 891 | return templ_7745c5c3_Err | |
| 821 | 892 | } | |
| 822 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" title=\"") | |
| 893 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" title=\"") | |
| 823 | 894 | if templ_7745c5c3_Err != nil { | |
| 824 | 895 | return templ_7745c5c3_Err | |
| 825 | 896 | } | |
| 826 | - | var templ_7745c5c3_Var39 string | |
| 827 | - | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status)) | |
| 897 | + | var templ_7745c5c3_Var42 string | |
| 898 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status)) | |
| 828 | 899 | if templ_7745c5c3_Err != nil { | |
| 829 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 71} | |
| 900 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 180, Col: 71} | |
| 830 | 901 | } | |
| 831 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) | |
| 902 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) | |
| 832 | 903 | if templ_7745c5c3_Err != nil { | |
| 833 | 904 | return templ_7745c5c3_Err | |
| 834 | 905 | } | |
| 835 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">") | |
| 906 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">") | |
| 836 | 907 | if templ_7745c5c3_Err != nil { | |
| 837 | 908 | return templ_7745c5c3_Err | |
| 838 | 909 | } | |
| 839 | - | var templ_7745c5c3_Var40 string | |
| 840 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 910 | + | var templ_7745c5c3_Var43 string | |
| 911 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 841 | 912 | if templ_7745c5c3_Err != nil { | |
| 842 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 84} | |
| 913 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 180, Col: 84} | |
| 843 | 914 | } | |
| 844 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 915 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 845 | 916 | if templ_7745c5c3_Err != nil { | |
| 846 | 917 | return templ_7745c5c3_Err | |
| 847 | 918 | } | |
| 848 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"nm\"><span class=\"p\">") | |
| 919 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"nm\"><span class=\"p\">") | |
| 849 | 920 | if templ_7745c5c3_Err != nil { | |
| 850 | 921 | return templ_7745c5c3_Err | |
| 851 | 922 | } | |
| 852 | - | var templ_7745c5c3_Var41 string | |
| 853 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name) | |
| 923 | + | var templ_7745c5c3_Var44 string | |
| 924 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name) | |
| 854 | 925 | if templ_7745c5c3_Err != nil { | |
| 855 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 46} | |
| 926 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 181, Col: 46} | |
| 856 | 927 | } | |
| 857 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 928 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 858 | 929 | if templ_7745c5c3_Err != nil { | |
| 859 | 930 | return templ_7745c5c3_Err | |
| 860 | 931 | } | |
| 861 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span>") | |
| 932 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span>") | |
| 862 | 933 | if templ_7745c5c3_Err != nil { | |
| 863 | 934 | return templ_7745c5c3_Err | |
| 864 | 935 | } | |
| 865 | - | var templ_7745c5c3_Var42 string | |
| 866 | - | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 936 | + | var templ_7745c5c3_Var45 string | |
| 937 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 867 | 938 | if templ_7745c5c3_Err != nil { | |
| 868 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 60} | |
| 939 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 181, Col: 60} | |
| 869 | 940 | } | |
| 870 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 941 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 871 | 942 | if templ_7745c5c3_Err != nil { | |
| 872 | 943 | return templ_7745c5c3_Err | |
| 873 | 944 | } | |
| 874 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<span class=\"d\"><bdi>") | |
| 945 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"d\"><bdi>") | |
| 875 | 946 | if templ_7745c5c3_Err != nil { | |
| 876 | 947 | return templ_7745c5c3_Err | |
| 877 | 948 | } | |
| 878 | - | var templ_7745c5c3_Var43 string | |
| 879 | - | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir) | |
| 949 | + | var templ_7745c5c3_Var46 string | |
| 950 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir) | |
| 880 | 951 | if templ_7745c5c3_Err != nil { | |
| 881 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 90} | |
| 952 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 181, Col: 90} | |
| 882 | 953 | } | |
| 883 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 954 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 884 | 955 | if templ_7745c5c3_Err != nil { | |
| 885 | 956 | return templ_7745c5c3_Err | |
| 886 | 957 | } | |
| 887 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</bdi></span></span> ") | |
| 958 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</bdi></span></span> ") | |
| 888 | 959 | if templ_7745c5c3_Err != nil { | |
| 889 | 960 | return templ_7745c5c3_Err | |
| 890 | 961 | } | |
| 891 | 962 | if f.OpenNotes > 0 { | |
| 892 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"cn\" title=\"") | |
| 963 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<span class=\"cn\" title=\"") | |
| 893 | 964 | if templ_7745c5c3_Err != nil { | |
| 894 | 965 | return templ_7745c5c3_Err | |
| 895 | 966 | } | |
| 896 | - | var templ_7745c5c3_Var44 string | |
| 897 | - | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments")) | |
| 967 | + | var templ_7745c5c3_Var47 string | |
| 968 | + | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments")) | |
| 898 | 969 | if templ_7745c5c3_Err != nil { | |
| 899 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 83} | |
| 970 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 183, Col: 83} | |
| 900 | 971 | } | |
| 901 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) | |
| 972 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47) | |
| 902 | 973 | if templ_7745c5c3_Err != nil { | |
| 903 | 974 | return templ_7745c5c3_Err | |
| 904 | 975 | } | |
| 905 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">") | |
| 976 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">") | |
| 906 | 977 | if templ_7745c5c3_Err != nil { | |
| 907 | 978 | return templ_7745c5c3_Err | |
| 908 | 979 | } | |
| 909 | - | var templ_7745c5c3_Var45 string | |
| 910 | - | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes)) | |
| 980 | + | var templ_7745c5c3_Var48 string | |
| 981 | + | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes)) | |
| 911 | 982 | if templ_7745c5c3_Err != nil { | |
| 912 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 105} | |
| 983 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 183, Col: 105} | |
| 913 | 984 | } | |
| 914 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 985 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 915 | 986 | if templ_7745c5c3_Err != nil { | |
| 916 | 987 | return templ_7745c5c3_Err | |
| 917 | 988 | } | |
| 918 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ") | |
| 989 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> ") | |
| 919 | 990 | if templ_7745c5c3_Err != nil { | |
| 920 | 991 | return templ_7745c5c3_Err | |
| 921 | 992 | } | |
| 922 | 993 | } else if f.Notes > 0 { | |
| 923 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"cn done\" title=\"") | |
| 994 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"cn done\" title=\"") | |
| 924 | 995 | if templ_7745c5c3_Err != nil { | |
| 925 | 996 | return templ_7745c5c3_Err | |
| 926 | 997 | } | |
| 927 | - | var templ_7745c5c3_Var46 string | |
| 928 | - | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments")) | |
| 998 | + | var templ_7745c5c3_Var49 string | |
| 999 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments")) | |
| 929 | 1000 | if templ_7745c5c3_Err != nil { | |
| 930 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 170, Col: 92} | |
| 1001 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 185, Col: 92} | |
| 931 | 1002 | } | |
| 932 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) | |
| 1003 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49) | |
| 933 | 1004 | if templ_7745c5c3_Err != nil { | |
| 934 | 1005 | return templ_7745c5c3_Err | |
| 935 | 1006 | } | |
| 936 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\">") | |
| 1007 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">") | |
| 937 | 1008 | if templ_7745c5c3_Err != nil { | |
| 938 | 1009 | return templ_7745c5c3_Err | |
| 939 | 1010 | } | |
| 940 | - | var templ_7745c5c3_Var47 string | |
| 941 | - | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes)) | |
| 1011 | + | var templ_7745c5c3_Var50 string | |
| 1012 | + | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes)) | |
| 942 | 1013 | if templ_7745c5c3_Err != nil { | |
| 943 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 170, Col: 110} | |
| 1014 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 185, Col: 110} | |
| 944 | 1015 | } | |
| 945 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 1016 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 946 | 1017 | if templ_7745c5c3_Err != nil { | |
| 947 | 1018 | return templ_7745c5c3_Err | |
| 948 | 1019 | } | |
| 949 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> ") | |
| 1020 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ") | |
| 950 | 1021 | if templ_7745c5c3_Err != nil { | |
| 951 | 1022 | return templ_7745c5c3_Err | |
| 952 | 1023 | } | |
| 953 | 1024 | } | |
| 954 | 1025 | if f.Bin { | |
| 955 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"n mute\">bin</span>") | |
| 1026 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"n mute\">bin</span>") | |
| 956 | 1027 | if templ_7745c5c3_Err != nil { | |
| 957 | 1028 | return templ_7745c5c3_Err | |
| 958 | 1029 | } | |
| 959 | 1030 | } else { | |
| 960 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"n\"><span class=\"plus\">+") | |
| 1031 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<span class=\"n\"><span class=\"plus\">+") | |
| 961 | 1032 | if templ_7745c5c3_Err != nil { | |
| 962 | 1033 | return templ_7745c5c3_Err | |
| 963 | 1034 | } | |
| 964 | - | var templ_7745c5c3_Var48 string | |
| 965 | - | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 1035 | + | var templ_7745c5c3_Var51 string | |
| 1036 | + | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 966 | 1037 | if templ_7745c5c3_Err != nil { | |
| 967 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 55} | |
| 1038 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 190, Col: 55} | |
| 968 | 1039 | } | |
| 969 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 1040 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 970 | 1041 | if templ_7745c5c3_Err != nil { | |
| 971 | 1042 | return templ_7745c5c3_Err | |
| 972 | 1043 | } | |
| 973 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> <span class=\"minus\">-") | |
| 1044 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> <span class=\"minus\">-") | |
| 974 | 1045 | if templ_7745c5c3_Err != nil { | |
| 975 | 1046 | return templ_7745c5c3_Err | |
| 976 | 1047 | } | |
| 977 | - | var templ_7745c5c3_Var49 string | |
| 978 | - | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 1048 | + | var templ_7745c5c3_Var52 string | |
| 1049 | + | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 979 | 1050 | if templ_7745c5c3_Err != nil { | |
| 980 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 99} | |
| 1051 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 190, Col: 99} | |
| 981 | 1052 | } | |
| 982 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1053 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 983 | 1054 | if templ_7745c5c3_Err != nil { | |
| 984 | 1055 | return templ_7745c5c3_Err | |
| 985 | 1056 | } | |
| 986 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span></span>") | |
| 1057 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span></span>") | |
| 987 | 1058 | if templ_7745c5c3_Err != nil { | |
| 988 | 1059 | return templ_7745c5c3_Err | |
| 989 | 1060 | } | |
| 990 | 1061 | } | |
| 991 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a></li>") | |
| 1062 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</a></li>") | |
| 992 | 1063 | if templ_7745c5c3_Err != nil { | |
| 993 | 1064 | return templ_7745c5c3_Err | |
| 994 | 1065 | } | |
| 995 | 1066 | } | |
| 996 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</ol>") | |
| 1067 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</ol>") | |
| 997 | 1068 | if templ_7745c5c3_Err != nil { | |
| 998 | 1069 | return templ_7745c5c3_Err | |
| 999 | 1070 | } | |
| @@ -1021,270 +1092,270 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1021 | 1092 | }() | |
| 1022 | 1093 | } | |
| 1023 | 1094 | ctx = templ.InitializeContext(ctx) | |
| 1024 | - | templ_7745c5c3_Var50 := templ.GetChildren(ctx) | |
| 1025 | - | if templ_7745c5c3_Var50 == nil { | |
| 1026 | - | templ_7745c5c3_Var50 = templ.NopComponent | |
| 1095 | + | templ_7745c5c3_Var53 := templ.GetChildren(ctx) | |
| 1096 | + | if templ_7745c5c3_Var53 == nil { | |
| 1097 | + | templ_7745c5c3_Var53 = templ.NopComponent | |
| 1027 | 1098 | } | |
| 1028 | 1099 | ctx = templ.ClearChildren(ctx) | |
| 1029 | - | var templ_7745c5c3_Var51 = []any{"dfile", "lnw-" + itoa(f.Digits)} | |
| 1030 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) | |
| 1100 | + | var templ_7745c5c3_Var54 = []any{"dfile", "lnw-" + itoa(f.Digits)} | |
| 1101 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var54...) | |
| 1031 | 1102 | if templ_7745c5c3_Err != nil { | |
| 1032 | 1103 | return templ_7745c5c3_Err | |
| 1033 | 1104 | } | |
| 1034 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<details class=\"") | |
| 1105 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<details class=\"") | |
| 1035 | 1106 | if templ_7745c5c3_Err != nil { | |
| 1036 | 1107 | return templ_7745c5c3_Err | |
| 1037 | 1108 | } | |
| 1038 | - | var templ_7745c5c3_Var52 string | |
| 1039 | - | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var51).String()) | |
| 1109 | + | var templ_7745c5c3_Var55 string | |
| 1110 | + | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var54).String()) | |
| 1040 | 1111 | if templ_7745c5c3_Err != nil { | |
| 1041 | 1112 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1042 | 1113 | } | |
| 1043 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) | |
| 1114 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) | |
| 1044 | 1115 | if templ_7745c5c3_Err != nil { | |
| 1045 | 1116 | return templ_7745c5c3_Err | |
| 1046 | 1117 | } | |
| 1047 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" id=\"") | |
| 1118 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" id=\"") | |
| 1048 | 1119 | if templ_7745c5c3_Err != nil { | |
| 1049 | 1120 | return templ_7745c5c3_Err | |
| 1050 | 1121 | } | |
| 1051 | - | var templ_7745c5c3_Var53 string | |
| 1052 | - | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID) | |
| 1122 | + | var templ_7745c5c3_Var56 string | |
| 1123 | + | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID) | |
| 1053 | 1124 | if templ_7745c5c3_Err != nil { | |
| 1054 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 190, Col: 11} | |
| 1125 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 205, Col: 11} | |
| 1055 | 1126 | } | |
| 1056 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) | |
| 1127 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) | |
| 1057 | 1128 | if templ_7745c5c3_Err != nil { | |
| 1058 | 1129 | return templ_7745c5c3_Err | |
| 1059 | 1130 | } | |
| 1060 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" data-file") | |
| 1131 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" data-file") | |
| 1061 | 1132 | if templ_7745c5c3_Err != nil { | |
| 1062 | 1133 | return templ_7745c5c3_Err | |
| 1063 | 1134 | } | |
| 1064 | 1135 | if f.Open { | |
| 1065 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " open") | |
| 1136 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " open") | |
| 1066 | 1137 | if templ_7745c5c3_Err != nil { | |
| 1067 | 1138 | return templ_7745c5c3_Err | |
| 1068 | 1139 | } | |
| 1069 | 1140 | } | |
| 1070 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "><summary><span class=\"st\">") | |
| 1141 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "><summary><span class=\"st\">") | |
| 1071 | 1142 | if templ_7745c5c3_Err != nil { | |
| 1072 | 1143 | return templ_7745c5c3_Err | |
| 1073 | 1144 | } | |
| 1074 | - | var templ_7745c5c3_Var54 string | |
| 1075 | - | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 1145 | + | var templ_7745c5c3_Var57 string | |
| 1146 | + | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 1076 | 1147 | if templ_7745c5c3_Err != nil { | |
| 1077 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 30} | |
| 1148 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 30} | |
| 1078 | 1149 | } | |
| 1079 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 1150 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1080 | 1151 | if templ_7745c5c3_Err != nil { | |
| 1081 | 1152 | return templ_7745c5c3_Err | |
| 1082 | 1153 | } | |
| 1083 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span>") | |
| 1154 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span>") | |
| 1084 | 1155 | if templ_7745c5c3_Err != nil { | |
| 1085 | 1156 | return templ_7745c5c3_Err | |
| 1086 | 1157 | } | |
| 1087 | - | var templ_7745c5c3_Var55 string | |
| 1088 | - | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1158 | + | var templ_7745c5c3_Var58 string | |
| 1159 | + | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1089 | 1160 | if templ_7745c5c3_Err != nil { | |
| 1090 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 44} | |
| 1161 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 44} | |
| 1091 | 1162 | } | |
| 1092 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1163 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1093 | 1164 | if templ_7745c5c3_Err != nil { | |
| 1094 | 1165 | return templ_7745c5c3_Err | |
| 1095 | 1166 | } | |
| 1096 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"p\">") | |
| 1167 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<span class=\"p\">") | |
| 1097 | 1168 | if templ_7745c5c3_Err != nil { | |
| 1098 | 1169 | return templ_7745c5c3_Err | |
| 1099 | 1170 | } | |
| 1100 | - | var templ_7745c5c3_Var56 string | |
| 1101 | - | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path) | |
| 1171 | + | var templ_7745c5c3_Var59 string | |
| 1172 | + | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path) | |
| 1102 | 1173 | if templ_7745c5c3_Err != nil { | |
| 1103 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 70} | |
| 1174 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 70} | |
| 1104 | 1175 | } | |
| 1105 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1176 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1106 | 1177 | if templ_7745c5c3_Err != nil { | |
| 1107 | 1178 | return templ_7745c5c3_Err | |
| 1108 | 1179 | } | |
| 1109 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span>") | |
| 1180 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span>") | |
| 1110 | 1181 | if templ_7745c5c3_Err != nil { | |
| 1111 | 1182 | return templ_7745c5c3_Err | |
| 1112 | 1183 | } | |
| 1113 | - | var templ_7745c5c3_Var57 string | |
| 1114 | - | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1184 | + | var templ_7745c5c3_Var60 string | |
| 1185 | + | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1115 | 1186 | if templ_7745c5c3_Err != nil { | |
| 1116 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 84} | |
| 1187 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 84} | |
| 1117 | 1188 | } | |
| 1118 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1189 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1119 | 1190 | if templ_7745c5c3_Err != nil { | |
| 1120 | 1191 | return templ_7745c5c3_Err | |
| 1121 | 1192 | } | |
| 1122 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"plus\">+") | |
| 1193 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<span class=\"plus\">+") | |
| 1123 | 1194 | if templ_7745c5c3_Err != nil { | |
| 1124 | 1195 | return templ_7745c5c3_Err | |
| 1125 | 1196 | } | |
| 1126 | - | var templ_7745c5c3_Var58 string | |
| 1127 | - | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 1197 | + | var templ_7745c5c3_Var61 string | |
| 1198 | + | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 1128 | 1199 | if templ_7745c5c3_Err != nil { | |
| 1129 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 119} | |
| 1200 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 119} | |
| 1130 | 1201 | } | |
| 1131 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1202 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) | |
| 1132 | 1203 | if templ_7745c5c3_Err != nil { | |
| 1133 | 1204 | return templ_7745c5c3_Err | |
| 1134 | 1205 | } | |
| 1135 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span>") | |
| 1206 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span>") | |
| 1136 | 1207 | if templ_7745c5c3_Err != nil { | |
| 1137 | 1208 | return templ_7745c5c3_Err | |
| 1138 | 1209 | } | |
| 1139 | - | var templ_7745c5c3_Var59 string | |
| 1140 | - | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1210 | + | var templ_7745c5c3_Var62 string | |
| 1211 | + | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1141 | 1212 | if templ_7745c5c3_Err != nil { | |
| 1142 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 133} | |
| 1213 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 133} | |
| 1143 | 1214 | } | |
| 1144 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1215 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1145 | 1216 | if templ_7745c5c3_Err != nil { | |
| 1146 | 1217 | return templ_7745c5c3_Err | |
| 1147 | 1218 | } | |
| 1148 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<span class=\"minus\">-") | |
| 1219 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<span class=\"minus\">-") | |
| 1149 | 1220 | if templ_7745c5c3_Err != nil { | |
| 1150 | 1221 | return templ_7745c5c3_Err | |
| 1151 | 1222 | } | |
| 1152 | - | var templ_7745c5c3_Var60 string | |
| 1153 | - | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 1223 | + | var templ_7745c5c3_Var63 string | |
| 1224 | + | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 1154 | 1225 | if templ_7745c5c3_Err != nil { | |
| 1155 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 169} | |
| 1226 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 169} | |
| 1156 | 1227 | } | |
| 1157 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1228 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1158 | 1229 | if templ_7745c5c3_Err != nil { | |
| 1159 | 1230 | return templ_7745c5c3_Err | |
| 1160 | 1231 | } | |
| 1161 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</span> ") | |
| 1232 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</span> ") | |
| 1162 | 1233 | if templ_7745c5c3_Err != nil { | |
| 1163 | 1234 | return templ_7745c5c3_Err | |
| 1164 | 1235 | } | |
| 1165 | 1236 | if f.Note != "" { | |
| 1166 | - | var templ_7745c5c3_Var61 string | |
| 1167 | - | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1237 | + | var templ_7745c5c3_Var64 string | |
| 1238 | + | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1168 | 1239 | if templ_7745c5c3_Err != nil { | |
| 1169 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 9} | |
| 1240 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 214, Col: 9} | |
| 1170 | 1241 | } | |
| 1171 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) | |
| 1242 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) | |
| 1172 | 1243 | if templ_7745c5c3_Err != nil { | |
| 1173 | 1244 | return templ_7745c5c3_Err | |
| 1174 | 1245 | } | |
| 1175 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<span class=\"note\">") | |
| 1246 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<span class=\"note\">") | |
| 1176 | 1247 | if templ_7745c5c3_Err != nil { | |
| 1177 | 1248 | return templ_7745c5c3_Err | |
| 1178 | 1249 | } | |
| 1179 | - | var templ_7745c5c3_Var62 string | |
| 1180 | - | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1250 | + | var templ_7745c5c3_Var65 string | |
| 1251 | + | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1181 | 1252 | if templ_7745c5c3_Err != nil { | |
| 1182 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 38} | |
| 1253 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 214, Col: 38} | |
| 1183 | 1254 | } | |
| 1184 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1255 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 1185 | 1256 | if templ_7745c5c3_Err != nil { | |
| 1186 | 1257 | return templ_7745c5c3_Err | |
| 1187 | 1258 | } | |
| 1188 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span>") | |
| 1259 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span>") | |
| 1189 | 1260 | if templ_7745c5c3_Err != nil { | |
| 1190 | 1261 | return templ_7745c5c3_Err | |
| 1191 | 1262 | } | |
| 1192 | 1263 | } | |
| 1193 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</summary> ") | |
| 1264 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</summary> ") | |
| 1194 | 1265 | if templ_7745c5c3_Err != nil { | |
| 1195 | 1266 | return templ_7745c5c3_Err | |
| 1196 | 1267 | } | |
| 1197 | 1268 | if f.Lazy { | |
| 1198 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ") | |
| 1269 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " ") | |
| 1199 | 1270 | if templ_7745c5c3_Err != nil { | |
| 1200 | 1271 | return templ_7745c5c3_Err | |
| 1201 | 1272 | } | |
| 1202 | - | var templ_7745c5c3_Var63 = []any{"dlazy", "dlazy-" + itoa(f.Height)} | |
| 1203 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...) | |
| 1273 | + | var templ_7745c5c3_Var66 = []any{"dlazy", "dlazy-" + itoa(f.Height)} | |
| 1274 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var66...) | |
| 1204 | 1275 | if templ_7745c5c3_Err != nil { | |
| 1205 | 1276 | return templ_7745c5c3_Err | |
| 1206 | 1277 | } | |
| 1207 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<div class=\"") | |
| 1278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<div class=\"") | |
| 1208 | 1279 | if templ_7745c5c3_Err != nil { | |
| 1209 | 1280 | return templ_7745c5c3_Err | |
| 1210 | 1281 | } | |
| 1211 | - | var templ_7745c5c3_Var64 string | |
| 1212 | - | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var63).String()) | |
| 1282 | + | var templ_7745c5c3_Var67 string | |
| 1283 | + | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var66).String()) | |
| 1213 | 1284 | if templ_7745c5c3_Err != nil { | |
| 1214 | 1285 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1215 | 1286 | } | |
| 1216 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) | |
| 1287 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67) | |
| 1217 | 1288 | if templ_7745c5c3_Err != nil { | |
| 1218 | 1289 | return templ_7745c5c3_Err | |
| 1219 | 1290 | } | |
| 1220 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" data-src=\"") | |
| 1291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" data-src=\"") | |
| 1221 | 1292 | if templ_7745c5c3_Err != nil { | |
| 1222 | 1293 | return templ_7745c5c3_Err | |
| 1223 | 1294 | } | |
| 1224 | - | var templ_7745c5c3_Var65 string | |
| 1225 | - | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src) | |
| 1295 | + | var templ_7745c5c3_Var68 string | |
| 1296 | + | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src) | |
| 1226 | 1297 | if templ_7745c5c3_Err != nil { | |
| 1227 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 69} | |
| 1298 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 221, Col: 69} | |
| 1228 | 1299 | } | |
| 1229 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65) | |
| 1300 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68) | |
| 1230 | 1301 | if templ_7745c5c3_Err != nil { | |
| 1231 | 1302 | return templ_7745c5c3_Err | |
| 1232 | 1303 | } | |
| 1233 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\" data-notes=\"") | |
| 1304 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" data-notes=\"") | |
| 1234 | 1305 | if templ_7745c5c3_Err != nil { | |
| 1235 | 1306 | return templ_7745c5c3_Err | |
| 1236 | 1307 | } | |
| 1237 | - | var templ_7745c5c3_Var66 string | |
| 1238 | - | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments) | |
| 1308 | + | var templ_7745c5c3_Var69 string | |
| 1309 | + | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments) | |
| 1239 | 1310 | if templ_7745c5c3_Err != nil { | |
| 1240 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 95} | |
| 1311 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 221, Col: 95} | |
| 1241 | 1312 | } | |
| 1242 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66) | |
| 1313 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) | |
| 1243 | 1314 | if templ_7745c5c3_Err != nil { | |
| 1244 | 1315 | return templ_7745c5c3_Err | |
| 1245 | 1316 | } | |
| 1246 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\"><a href=\"") | |
| 1317 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\"><a href=\"") | |
| 1247 | 1318 | if templ_7745c5c3_Err != nil { | |
| 1248 | 1319 | return templ_7745c5c3_Err | |
| 1249 | 1320 | } | |
| 1250 | - | var templ_7745c5c3_Var67 templ.SafeURL | |
| 1251 | - | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID)) | |
| 1321 | + | var templ_7745c5c3_Var70 templ.SafeURL | |
| 1322 | + | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID)) | |
| 1252 | 1323 | if templ_7745c5c3_Err != nil { | |
| 1253 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 65} | |
| 1324 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 222, Col: 65} | |
| 1254 | 1325 | } | |
| 1255 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) | |
| 1326 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1256 | 1327 | if templ_7745c5c3_Err != nil { | |
| 1257 | 1328 | return templ_7745c5c3_Err | |
| 1258 | 1329 | } | |
| 1259 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">show this file</a></div>") | |
| 1330 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\">show this file</a></div>") | |
| 1260 | 1331 | if templ_7745c5c3_Err != nil { | |
| 1261 | 1332 | return templ_7745c5c3_Err | |
| 1262 | 1333 | } | |
| 1263 | 1334 | } else if len(f.Hunks) > 0 { | |
| 1264 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<div class=\"dscroll\">") | |
| 1335 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<div class=\"dscroll\">") | |
| 1265 | 1336 | if templ_7745c5c3_Err != nil { | |
| 1266 | 1337 | return templ_7745c5c3_Err | |
| 1267 | 1338 | } | |
| 1268 | 1339 | if p.ShowUnified { | |
| 1269 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<table class=\"dt uni\" aria-label=\"") | |
| 1340 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<table class=\"dt uni\" aria-label=\"") | |
| 1270 | 1341 | if templ_7745c5c3_Err != nil { | |
| 1271 | 1342 | return templ_7745c5c3_Err | |
| 1272 | 1343 | } | |
| 1273 | - | var templ_7745c5c3_Var68 string | |
| 1274 | - | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified") | |
| 1344 | + | var templ_7745c5c3_Var71 string | |
| 1345 | + | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified") | |
| 1275 | 1346 | if templ_7745c5c3_Err != nil { | |
| 1276 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 60} | |
| 1347 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 227, Col: 60} | |
| 1277 | 1348 | } | |
| 1278 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68) | |
| 1349 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71) | |
| 1279 | 1350 | if templ_7745c5c3_Err != nil { | |
| 1280 | 1351 | return templ_7745c5c3_Err | |
| 1281 | 1352 | } | |
| 1282 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">") | |
| 1353 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\">") | |
| 1283 | 1354 | if templ_7745c5c3_Err != nil { | |
| 1284 | 1355 | return templ_7745c5c3_Err | |
| 1285 | 1356 | } | |
| 1286 | 1357 | for _, h := range f.Hunks { | |
| 1287 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<tbody>") | |
| 1358 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<tbody>") | |
| 1288 | 1359 | if templ_7745c5c3_Err != nil { | |
| 1289 | 1360 | return templ_7745c5c3_Err | |
| 1290 | 1361 | } | |
| @@ -1294,43 +1365,43 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1294 | 1365 | return templ_7745c5c3_Err | |
| 1295 | 1366 | } | |
| 1296 | 1367 | } | |
| 1297 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<tr class=\"hunk\"><td colspan=\"4\">") | |
| 1368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<tr class=\"hunk\"><td colspan=\"4\">") | |
| 1298 | 1369 | if templ_7745c5c3_Err != nil { | |
| 1299 | 1370 | return templ_7745c5c3_Err | |
| 1300 | 1371 | } | |
| 1301 | - | var templ_7745c5c3_Var69 string | |
| 1302 | - | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1372 | + | var templ_7745c5c3_Var72 string | |
| 1373 | + | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1303 | 1374 | if templ_7745c5c3_Err != nil { | |
| 1304 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 218, Col: 51} | |
| 1375 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 233, Col: 51} | |
| 1305 | 1376 | } | |
| 1306 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) | |
| 1377 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) | |
| 1307 | 1378 | if templ_7745c5c3_Err != nil { | |
| 1308 | 1379 | return templ_7745c5c3_Err | |
| 1309 | 1380 | } | |
| 1310 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td></tr>") | |
| 1381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</td></tr>") | |
| 1311 | 1382 | if templ_7745c5c3_Err != nil { | |
| 1312 | 1383 | return templ_7745c5c3_Err | |
| 1313 | 1384 | } | |
| 1314 | 1385 | for _, ln := range h.Lines { | |
| 1315 | - | var templ_7745c5c3_Var70 = []any{ln.Kind} | |
| 1316 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var70...) | |
| 1386 | + | var templ_7745c5c3_Var73 = []any{ln.Kind} | |
| 1387 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var73...) | |
| 1317 | 1388 | if templ_7745c5c3_Err != nil { | |
| 1318 | 1389 | return templ_7745c5c3_Err | |
| 1319 | 1390 | } | |
| 1320 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<tr class=\"") | |
| 1391 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<tr class=\"") | |
| 1321 | 1392 | if templ_7745c5c3_Err != nil { | |
| 1322 | 1393 | return templ_7745c5c3_Err | |
| 1323 | 1394 | } | |
| 1324 | - | var templ_7745c5c3_Var71 string | |
| 1325 | - | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var70).String()) | |
| 1395 | + | var templ_7745c5c3_Var74 string | |
| 1396 | + | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var73).String()) | |
| 1326 | 1397 | if templ_7745c5c3_Err != nil { | |
| 1327 | 1398 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1328 | 1399 | } | |
| 1329 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71) | |
| 1400 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74) | |
| 1330 | 1401 | if templ_7745c5c3_Err != nil { | |
| 1331 | 1402 | return templ_7745c5c3_Err | |
| 1332 | 1403 | } | |
| 1333 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\"><td class=\"ln\">") | |
| 1404 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\"><td class=\"ln\">") | |
| 1334 | 1405 | if templ_7745c5c3_Err != nil { | |
| 1335 | 1406 | return templ_7745c5c3_Err | |
| 1336 | 1407 | } | |
| @@ -1338,7 +1409,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1338 | 1409 | if templ_7745c5c3_Err != nil { | |
| 1339 | 1410 | return templ_7745c5c3_Err | |
| 1340 | 1411 | } | |
| 1341 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</td><td class=\"ln\">") | |
| 1412 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</td><td class=\"ln\">") | |
| 1342 | 1413 | if templ_7745c5c3_Err != nil { | |
| 1343 | 1414 | return templ_7745c5c3_Err | |
| 1344 | 1415 | } | |
| @@ -1346,20 +1417,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1346 | 1417 | if templ_7745c5c3_Err != nil { | |
| 1347 | 1418 | return templ_7745c5c3_Err | |
| 1348 | 1419 | } | |
| 1349 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</td><td class=\"m\">") | |
| 1420 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</td><td class=\"m\">") | |
| 1350 | 1421 | if templ_7745c5c3_Err != nil { | |
| 1351 | 1422 | return templ_7745c5c3_Err | |
| 1352 | 1423 | } | |
| 1353 | - | var templ_7745c5c3_Var72 string | |
| 1354 | - | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1424 | + | var templ_7745c5c3_Var75 string | |
| 1425 | + | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1355 | 1426 | if templ_7745c5c3_Err != nil { | |
| 1356 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 223, Col: 33} | |
| 1427 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 238, Col: 33} | |
| 1357 | 1428 | } | |
| 1358 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) | |
| 1429 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) | |
| 1359 | 1430 | if templ_7745c5c3_Err != nil { | |
| 1360 | 1431 | return templ_7745c5c3_Err | |
| 1361 | 1432 | } | |
| 1362 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"tx chroma\">") | |
| 1433 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</td><td class=\"tx chroma\">") | |
| 1363 | 1434 | if templ_7745c5c3_Err != nil { | |
| 1364 | 1435 | return templ_7745c5c3_Err | |
| 1365 | 1436 | } | |
| @@ -1367,12 +1438,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1367 | 1438 | if templ_7745c5c3_Err != nil { | |
| 1368 | 1439 | return templ_7745c5c3_Err | |
| 1369 | 1440 | } | |
| 1370 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</td></tr>") | |
| 1441 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>") | |
| 1371 | 1442 | if templ_7745c5c3_Err != nil { | |
| 1372 | 1443 | return templ_7745c5c3_Err | |
| 1373 | 1444 | } | |
| 1374 | 1445 | if len(ln.Notes) > 0 || ln.Form != nil { | |
| 1375 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>") | |
| 1446 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>") | |
| 1376 | 1447 | if templ_7745c5c3_Err != nil { | |
| 1377 | 1448 | return templ_7745c5c3_Err | |
| 1378 | 1449 | } | |
| @@ -1380,19 +1451,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1380 | 1451 | if templ_7745c5c3_Err != nil { | |
| 1381 | 1452 | return templ_7745c5c3_Err | |
| 1382 | 1453 | } | |
| 1383 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</td></tr>") | |
| 1454 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</td></tr>") | |
| 1384 | 1455 | if templ_7745c5c3_Err != nil { | |
| 1385 | 1456 | return templ_7745c5c3_Err | |
| 1386 | 1457 | } | |
| 1387 | 1458 | } | |
| 1388 | 1459 | } | |
| 1389 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</tbody> ") | |
| 1460 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</tbody> ") | |
| 1390 | 1461 | if templ_7745c5c3_Err != nil { | |
| 1391 | 1462 | return templ_7745c5c3_Err | |
| 1392 | 1463 | } | |
| 1393 | 1464 | } | |
| 1394 | 1465 | if f.Tail != nil { | |
| 1395 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<tbody>") | |
| 1466 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<tbody>") | |
| 1396 | 1467 | if templ_7745c5c3_Err != nil { | |
| 1397 | 1468 | return templ_7745c5c3_Err | |
| 1398 | 1469 | } | |
| @@ -1400,36 +1471,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1400 | 1471 | if templ_7745c5c3_Err != nil { | |
| 1401 | 1472 | return templ_7745c5c3_Err | |
| 1402 | 1473 | } | |
| 1403 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</tbody>") | |
| 1474 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</tbody>") | |
| 1404 | 1475 | if templ_7745c5c3_Err != nil { | |
| 1405 | 1476 | return templ_7745c5c3_Err | |
| 1406 | 1477 | } | |
| 1407 | 1478 | } | |
| 1408 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</table>") | |
| 1479 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</table>") | |
| 1409 | 1480 | if templ_7745c5c3_Err != nil { | |
| 1410 | 1481 | return templ_7745c5c3_Err | |
| 1411 | 1482 | } | |
| 1412 | 1483 | } | |
| 1413 | 1484 | if p.ShowSplit { | |
| 1414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<table class=\"dt spl\" aria-label=\"") | |
| 1485 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<table class=\"dt spl\" aria-label=\"") | |
| 1415 | 1486 | if templ_7745c5c3_Err != nil { | |
| 1416 | 1487 | return templ_7745c5c3_Err | |
| 1417 | 1488 | } | |
| 1418 | - | var templ_7745c5c3_Var73 string | |
| 1419 | - | templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split") | |
| 1489 | + | var templ_7745c5c3_Var76 string | |
| 1490 | + | templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split") | |
| 1420 | 1491 | if templ_7745c5c3_Err != nil { | |
| 1421 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 243, Col: 58} | |
| 1492 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 258, Col: 58} | |
| 1422 | 1493 | } | |
| 1423 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73) | |
| 1494 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76) | |
| 1424 | 1495 | if templ_7745c5c3_Err != nil { | |
| 1425 | 1496 | return templ_7745c5c3_Err | |
| 1426 | 1497 | } | |
| 1427 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ") | |
| 1498 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ") | |
| 1428 | 1499 | if templ_7745c5c3_Err != nil { | |
| 1429 | 1500 | return templ_7745c5c3_Err | |
| 1430 | 1501 | } | |
| 1431 | 1502 | for _, h := range f.Hunks { | |
| 1432 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tbody>") | |
| 1503 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "<tbody>") | |
| 1433 | 1504 | if templ_7745c5c3_Err != nil { | |
| 1434 | 1505 | return templ_7745c5c3_Err | |
| 1435 | 1506 | } | |
| @@ -1439,25 +1510,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1439 | 1510 | return templ_7745c5c3_Err | |
| 1440 | 1511 | } | |
| 1441 | 1512 | } | |
| 1442 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"hunk\"><td colspan=\"6\">") | |
| 1513 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tr class=\"hunk\"><td colspan=\"6\">") | |
| 1443 | 1514 | if templ_7745c5c3_Err != nil { | |
| 1444 | 1515 | return templ_7745c5c3_Err | |
| 1445 | 1516 | } | |
| 1446 | - | var templ_7745c5c3_Var74 string | |
| 1447 | - | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1517 | + | var templ_7745c5c3_Var77 string | |
| 1518 | + | templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1448 | 1519 | if templ_7745c5c3_Err != nil { | |
| 1449 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 250, Col: 51} | |
| 1520 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 265, Col: 51} | |
| 1450 | 1521 | } | |
| 1451 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) | |
| 1522 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) | |
| 1452 | 1523 | if templ_7745c5c3_Err != nil { | |
| 1453 | 1524 | return templ_7745c5c3_Err | |
| 1454 | 1525 | } | |
| 1455 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>") | |
| 1526 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</td></tr>") | |
| 1456 | 1527 | if templ_7745c5c3_Err != nil { | |
| 1457 | 1528 | return templ_7745c5c3_Err | |
| 1458 | 1529 | } | |
| 1459 | 1530 | for _, row := range h.Split { | |
| 1460 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<tr>") | |
| 1531 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<tr>") | |
| 1461 | 1532 | if templ_7745c5c3_Err != nil { | |
| 1462 | 1533 | return templ_7745c5c3_Err | |
| 1463 | 1534 | } | |
| @@ -1469,12 +1540,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1469 | 1540 | if templ_7745c5c3_Err != nil { | |
| 1470 | 1541 | return templ_7745c5c3_Err | |
| 1471 | 1542 | } | |
| 1472 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</tr>") | |
| 1543 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</tr>") | |
| 1473 | 1544 | if templ_7745c5c3_Err != nil { | |
| 1474 | 1545 | return templ_7745c5c3_Err | |
| 1475 | 1546 | } | |
| 1476 | 1547 | if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil { | |
| 1477 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<tr class=\"fnote\"><td colspan=\"6\">") | |
| 1548 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<tr class=\"fnote\"><td colspan=\"6\">") | |
| 1478 | 1549 | if templ_7745c5c3_Err != nil { | |
| 1479 | 1550 | return templ_7745c5c3_Err | |
| 1480 | 1551 | } | |
| @@ -1482,19 +1553,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1482 | 1553 | if templ_7745c5c3_Err != nil { | |
| 1483 | 1554 | return templ_7745c5c3_Err | |
| 1484 | 1555 | } | |
| 1485 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td></tr>") | |
| 1556 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</td></tr>") | |
| 1486 | 1557 | if templ_7745c5c3_Err != nil { | |
| 1487 | 1558 | return templ_7745c5c3_Err | |
| 1488 | 1559 | } | |
| 1489 | 1560 | } | |
| 1490 | 1561 | } | |
| 1491 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</tbody> ") | |
| 1562 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</tbody> ") | |
| 1492 | 1563 | if templ_7745c5c3_Err != nil { | |
| 1493 | 1564 | return templ_7745c5c3_Err | |
| 1494 | 1565 | } | |
| 1495 | 1566 | } | |
| 1496 | 1567 | if f.Tail != nil { | |
| 1497 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "<tbody>") | |
| 1568 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tbody>") | |
| 1498 | 1569 | if templ_7745c5c3_Err != nil { | |
| 1499 | 1570 | return templ_7745c5c3_Err | |
| 1500 | 1571 | } | |
| @@ -1502,48 +1573,48 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1502 | 1573 | if templ_7745c5c3_Err != nil { | |
| 1503 | 1574 | return templ_7745c5c3_Err | |
| 1504 | 1575 | } | |
| 1505 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</tbody>") | |
| 1576 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</tbody>") | |
| 1506 | 1577 | if templ_7745c5c3_Err != nil { | |
| 1507 | 1578 | return templ_7745c5c3_Err | |
| 1508 | 1579 | } | |
| 1509 | 1580 | } | |
| 1510 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</table>") | |
| 1581 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</table>") | |
| 1511 | 1582 | if templ_7745c5c3_Err != nil { | |
| 1512 | 1583 | return templ_7745c5c3_Err | |
| 1513 | 1584 | } | |
| 1514 | 1585 | } | |
| 1515 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</div>") | |
| 1586 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</div>") | |
| 1516 | 1587 | if templ_7745c5c3_Err != nil { | |
| 1517 | 1588 | return templ_7745c5c3_Err | |
| 1518 | 1589 | } | |
| 1519 | 1590 | } else { | |
| 1520 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<p class=\"note pad\">") | |
| 1591 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<p class=\"note pad\">") | |
| 1521 | 1592 | if templ_7745c5c3_Err != nil { | |
| 1522 | 1593 | return templ_7745c5c3_Err | |
| 1523 | 1594 | } | |
| 1524 | 1595 | if f.Note != "" { | |
| 1525 | - | var templ_7745c5c3_Var75 string | |
| 1526 | - | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1596 | + | var templ_7745c5c3_Var78 string | |
| 1597 | + | templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1527 | 1598 | if templ_7745c5c3_Err != nil { | |
| 1528 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 275, Col: 13} | |
| 1599 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 290, Col: 13} | |
| 1529 | 1600 | } | |
| 1530 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) | |
| 1601 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) | |
| 1531 | 1602 | if templ_7745c5c3_Err != nil { | |
| 1532 | 1603 | return templ_7745c5c3_Err | |
| 1533 | 1604 | } | |
| 1534 | 1605 | } else { | |
| 1535 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "no textual change.") | |
| 1606 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "no textual change.") | |
| 1536 | 1607 | if templ_7745c5c3_Err != nil { | |
| 1537 | 1608 | return templ_7745c5c3_Err | |
| 1538 | 1609 | } | |
| 1539 | 1610 | } | |
| 1540 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</p>") | |
| 1611 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</p>") | |
| 1541 | 1612 | if templ_7745c5c3_Err != nil { | |
| 1542 | 1613 | return templ_7745c5c3_Err | |
| 1543 | 1614 | } | |
| 1544 | 1615 | } | |
| 1545 | 1616 | if len(f.Loose) > 0 { | |
| 1546 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<div class=\"dloose\">") | |
| 1617 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<div class=\"dloose\">") | |
| 1547 | 1618 | if templ_7745c5c3_Err != nil { | |
| 1548 | 1619 | return templ_7745c5c3_Err | |
| 1549 | 1620 | } | |
| @@ -1553,12 +1624,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1553 | 1624 | return templ_7745c5c3_Err | |
| 1554 | 1625 | } | |
| 1555 | 1626 | } | |
| 1556 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</div>") | |
| 1627 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</div>") | |
| 1557 | 1628 | if templ_7745c5c3_Err != nil { | |
| 1558 | 1629 | return templ_7745c5c3_Err | |
| 1559 | 1630 | } | |
| 1560 | 1631 | } | |
| 1561 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</details>") | |
| 1632 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</details>") | |
| 1562 | 1633 | if templ_7745c5c3_Err != nil { | |
| 1563 | 1634 | return templ_7745c5c3_Err | |
| 1564 | 1635 | } | |
| @@ -1584,9 +1655,9 @@ func MRFiles(p MRFilesPage) templ.Component { | |||
| 1584 | 1655 | }() | |
| 1585 | 1656 | } | |
| 1586 | 1657 | ctx = templ.InitializeContext(ctx) | |
| 1587 | - | templ_7745c5c3_Var76 := templ.GetChildren(ctx) | |
| 1588 | - | if templ_7745c5c3_Var76 == nil { | |
| 1589 | - | templ_7745c5c3_Var76 = templ.NopComponent | |
| 1658 | + | templ_7745c5c3_Var79 := templ.GetChildren(ctx) | |
| 1659 | + | if templ_7745c5c3_Var79 == nil { | |
| 1660 | + | templ_7745c5c3_Var79 = templ.NopComponent | |
| 1590 | 1661 | } | |
| 1591 | 1662 | ctx = templ.ClearChildren(ctx) | |
| 1592 | 1663 | for _, f := range p.Files { | |
| @@ -1617,12 +1688,12 @@ func MRFilesMoved() templ.Component { | |||
| 1617 | 1688 | }() | |
| 1618 | 1689 | } | |
| 1619 | 1690 | ctx = templ.InitializeContext(ctx) | |
| 1620 | - | templ_7745c5c3_Var77 := templ.GetChildren(ctx) | |
| 1621 | - | if templ_7745c5c3_Var77 == nil { | |
| 1622 | - | templ_7745c5c3_Var77 = templ.NopComponent | |
| 1691 | + | templ_7745c5c3_Var80 := templ.GetChildren(ctx) | |
| 1692 | + | if templ_7745c5c3_Var80 == nil { | |
| 1693 | + | templ_7745c5c3_Var80 = templ.NopComponent | |
| 1623 | 1694 | } | |
| 1624 | 1695 | ctx = templ.ClearChildren(ctx) | |
| 1625 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>") | |
| 1696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>") | |
| 1626 | 1697 | if templ_7745c5c3_Err != nil { | |
| 1627 | 1698 | return templ_7745c5c3_Err | |
| 1628 | 1699 | } | |
| @@ -1649,64 +1720,64 @@ func gapRow(g DiffGap, cols int) templ.Component { | |||
| 1649 | 1720 | }() | |
| 1650 | 1721 | } | |
| 1651 | 1722 | ctx = templ.InitializeContext(ctx) | |
| 1652 | - | templ_7745c5c3_Var78 := templ.GetChildren(ctx) | |
| 1653 | - | if templ_7745c5c3_Var78 == nil { | |
| 1654 | - | templ_7745c5c3_Var78 = templ.NopComponent | |
| 1723 | + | templ_7745c5c3_Var81 := templ.GetChildren(ctx) | |
| 1724 | + | if templ_7745c5c3_Var81 == nil { | |
| 1725 | + | templ_7745c5c3_Var81 = templ.NopComponent | |
| 1655 | 1726 | } | |
| 1656 | 1727 | ctx = templ.ClearChildren(ctx) | |
| 1657 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"gap\" data-js data-gap=\"") | |
| 1728 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<tr class=\"gap\" data-js data-gap=\"") | |
| 1658 | 1729 | if templ_7745c5c3_Err != nil { | |
| 1659 | 1730 | return templ_7745c5c3_Err | |
| 1660 | 1731 | } | |
| 1661 | - | var templ_7745c5c3_Var79 string | |
| 1662 | - | templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key) | |
| 1732 | + | var templ_7745c5c3_Var82 string | |
| 1733 | + | templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key) | |
| 1663 | 1734 | if templ_7745c5c3_Err != nil { | |
| 1664 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 309, Col: 41} | |
| 1735 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 324, Col: 41} | |
| 1665 | 1736 | } | |
| 1666 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79) | |
| 1737 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82) | |
| 1667 | 1738 | if templ_7745c5c3_Err != nil { | |
| 1668 | 1739 | return templ_7745c5c3_Err | |
| 1669 | 1740 | } | |
| 1670 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\"><td colspan=\"") | |
| 1741 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\"><td colspan=\"") | |
| 1671 | 1742 | if templ_7745c5c3_Err != nil { | |
| 1672 | 1743 | return templ_7745c5c3_Err | |
| 1673 | 1744 | } | |
| 1674 | - | var templ_7745c5c3_Var80 string | |
| 1675 | - | templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols)) | |
| 1745 | + | var templ_7745c5c3_Var83 string | |
| 1746 | + | templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols)) | |
| 1676 | 1747 | if templ_7745c5c3_Err != nil { | |
| 1677 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 310, Col: 26} | |
| 1748 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 325, Col: 26} | |
| 1678 | 1749 | } | |
| 1679 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80) | |
| 1750 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83) | |
| 1680 | 1751 | if templ_7745c5c3_Err != nil { | |
| 1681 | 1752 | return templ_7745c5c3_Err | |
| 1682 | 1753 | } | |
| 1683 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "\"><button type=\"button\" data-src=\"") | |
| 1754 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "\"><button type=\"button\" data-src=\"") | |
| 1684 | 1755 | if templ_7745c5c3_Err != nil { | |
| 1685 | 1756 | return templ_7745c5c3_Err | |
| 1686 | 1757 | } | |
| 1687 | - | var templ_7745c5c3_Var81 string | |
| 1688 | - | templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src) | |
| 1758 | + | var templ_7745c5c3_Var84 string | |
| 1759 | + | templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src) | |
| 1689 | 1760 | if templ_7745c5c3_Err != nil { | |
| 1690 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 311, Col: 41} | |
| 1761 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 326, Col: 41} | |
| 1691 | 1762 | } | |
| 1692 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81) | |
| 1763 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84) | |
| 1693 | 1764 | if templ_7745c5c3_Err != nil { | |
| 1694 | 1765 | return templ_7745c5c3_Err | |
| 1695 | 1766 | } | |
| 1696 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\"><span aria-hidden=\"true\">⋯</span> ") | |
| 1767 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\"><span aria-hidden=\"true\">⋯</span> ") | |
| 1697 | 1768 | if templ_7745c5c3_Err != nil { | |
| 1698 | 1769 | return templ_7745c5c3_Err | |
| 1699 | 1770 | } | |
| 1700 | - | var templ_7745c5c3_Var82 string | |
| 1701 | - | templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label()) | |
| 1771 | + | var templ_7745c5c3_Var85 string | |
| 1772 | + | templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label()) | |
| 1702 | 1773 | if templ_7745c5c3_Err != nil { | |
| 1703 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 312, Col: 51} | |
| 1774 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 327, Col: 51} | |
| 1704 | 1775 | } | |
| 1705 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) | |
| 1776 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) | |
| 1706 | 1777 | if templ_7745c5c3_Err != nil { | |
| 1707 | 1778 | return templ_7745c5c3_Err | |
| 1708 | 1779 | } | |
| 1709 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</button></td></tr>") | |
| 1780 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</button></td></tr>") | |
| 1710 | 1781 | if templ_7745c5c3_Err != nil { | |
| 1711 | 1782 | return templ_7745c5c3_Err | |
| 1712 | 1783 | } | |
| @@ -1732,56 +1803,56 @@ func DiffContext(c ContextRows) templ.Component { | |||
| 1732 | 1803 | }() | |
| 1733 | 1804 | } | |
| 1734 | 1805 | ctx = templ.InitializeContext(ctx) | |
| 1735 | - | templ_7745c5c3_Var83 := templ.GetChildren(ctx) | |
| 1736 | - | if templ_7745c5c3_Var83 == nil { | |
| 1737 | - | templ_7745c5c3_Var83 = templ.NopComponent | |
| 1806 | + | templ_7745c5c3_Var86 := templ.GetChildren(ctx) | |
| 1807 | + | if templ_7745c5c3_Var86 == nil { | |
| 1808 | + | templ_7745c5c3_Var86 = templ.NopComponent | |
| 1738 | 1809 | } | |
| 1739 | 1810 | ctx = templ.ClearChildren(ctx) | |
| 1740 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<table><tbody data-for=\"uni\">") | |
| 1811 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<table><tbody data-for=\"uni\">") | |
| 1741 | 1812 | if templ_7745c5c3_Err != nil { | |
| 1742 | 1813 | return templ_7745c5c3_Err | |
| 1743 | 1814 | } | |
| 1744 | 1815 | for _, ln := range c.Lines { | |
| 1745 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<tr class=\"ctx\"><td class=\"ln\">") | |
| 1816 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"ctx\"><td class=\"ln\">") | |
| 1746 | 1817 | if templ_7745c5c3_Err != nil { | |
| 1747 | 1818 | return templ_7745c5c3_Err | |
| 1748 | 1819 | } | |
| 1749 | - | var templ_7745c5c3_Var84 string | |
| 1750 | - | templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old) | |
| 1820 | + | var templ_7745c5c3_Var87 string | |
| 1821 | + | templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old) | |
| 1751 | 1822 | if templ_7745c5c3_Err != nil { | |
| 1752 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 325, Col: 28} | |
| 1823 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 340, Col: 28} | |
| 1753 | 1824 | } | |
| 1754 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) | |
| 1825 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) | |
| 1755 | 1826 | if templ_7745c5c3_Err != nil { | |
| 1756 | 1827 | return templ_7745c5c3_Err | |
| 1757 | 1828 | } | |
| 1758 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</td><td class=\"ln\">") | |
| 1829 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</td><td class=\"ln\">") | |
| 1759 | 1830 | if templ_7745c5c3_Err != nil { | |
| 1760 | 1831 | return templ_7745c5c3_Err | |
| 1761 | 1832 | } | |
| 1762 | - | var templ_7745c5c3_Var85 string | |
| 1763 | - | templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New) | |
| 1833 | + | var templ_7745c5c3_Var88 string | |
| 1834 | + | templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New) | |
| 1764 | 1835 | if templ_7745c5c3_Err != nil { | |
| 1765 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 326, Col: 28} | |
| 1836 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 341, Col: 28} | |
| 1766 | 1837 | } | |
| 1767 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) | |
| 1838 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) | |
| 1768 | 1839 | if templ_7745c5c3_Err != nil { | |
| 1769 | 1840 | return templ_7745c5c3_Err | |
| 1770 | 1841 | } | |
| 1771 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</td><td class=\"m\">") | |
| 1842 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</td><td class=\"m\">") | |
| 1772 | 1843 | if templ_7745c5c3_Err != nil { | |
| 1773 | 1844 | return templ_7745c5c3_Err | |
| 1774 | 1845 | } | |
| 1775 | - | var templ_7745c5c3_Var86 string | |
| 1776 | - | templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1846 | + | var templ_7745c5c3_Var89 string | |
| 1847 | + | templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1777 | 1848 | if templ_7745c5c3_Err != nil { | |
| 1778 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 327, Col: 28} | |
| 1849 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 28} | |
| 1779 | 1850 | } | |
| 1780 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) | |
| 1851 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) | |
| 1781 | 1852 | if templ_7745c5c3_Err != nil { | |
| 1782 | 1853 | return templ_7745c5c3_Err | |
| 1783 | 1854 | } | |
| 1784 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</td><td class=\"tx chroma\">") | |
| 1855 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</td><td class=\"tx chroma\">") | |
| 1785 | 1856 | if templ_7745c5c3_Err != nil { | |
| 1786 | 1857 | return templ_7745c5c3_Err | |
| 1787 | 1858 | } | |
| @@ -1789,7 +1860,7 @@ func DiffContext(c ContextRows) templ.Component { | |||
| 1789 | 1860 | if templ_7745c5c3_Err != nil { | |
| 1790 | 1861 | return templ_7745c5c3_Err | |
| 1791 | 1862 | } | |
| 1792 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</td></tr>") | |
| 1863 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</td></tr>") | |
| 1793 | 1864 | if templ_7745c5c3_Err != nil { | |
| 1794 | 1865 | return templ_7745c5c3_Err | |
| 1795 | 1866 | } | |
| @@ -1800,12 +1871,12 @@ func DiffContext(c ContextRows) templ.Component { | |||
| 1800 | 1871 | return templ_7745c5c3_Err | |
| 1801 | 1872 | } | |
| 1802 | 1873 | } | |
| 1803 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</tbody> <tbody data-for=\"spl\">") | |
| 1874 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</tbody> <tbody data-for=\"spl\">") | |
| 1804 | 1875 | if templ_7745c5c3_Err != nil { | |
| 1805 | 1876 | return templ_7745c5c3_Err | |
| 1806 | 1877 | } | |
| 1807 | 1878 | for i := range c.Lines { | |
| 1808 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<tr>") | |
| 1879 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr>") | |
| 1809 | 1880 | if templ_7745c5c3_Err != nil { | |
| 1810 | 1881 | return templ_7745c5c3_Err | |
| 1811 | 1882 | } | |
| @@ -1817,7 +1888,7 @@ func DiffContext(c ContextRows) templ.Component { | |||
| 1817 | 1888 | if templ_7745c5c3_Err != nil { | |
| 1818 | 1889 | return templ_7745c5c3_Err | |
| 1819 | 1890 | } | |
| 1820 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</tr>") | |
| 1891 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</tr>") | |
| 1821 | 1892 | if templ_7745c5c3_Err != nil { | |
| 1822 | 1893 | return templ_7745c5c3_Err | |
| 1823 | 1894 | } | |
| @@ -1828,7 +1899,7 @@ func DiffContext(c ContextRows) templ.Component { | |||
| 1828 | 1899 | return templ_7745c5c3_Err | |
| 1829 | 1900 | } | |
| 1830 | 1901 | } | |
| 1831 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</tbody></table>") | |
| 1902 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</tbody></table>") | |
| 1832 | 1903 | if templ_7745c5c3_Err != nil { | |
| 1833 | 1904 | return templ_7745c5c3_Err | |
| 1834 | 1905 | } | |
| @@ -1854,36 +1925,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1854 | 1925 | }() | |
| 1855 | 1926 | } | |
| 1856 | 1927 | ctx = templ.InitializeContext(ctx) | |
| 1857 | - | templ_7745c5c3_Var87 := templ.GetChildren(ctx) | |
| 1858 | - | if templ_7745c5c3_Var87 == nil { | |
| 1859 | - | templ_7745c5c3_Var87 = templ.NopComponent | |
| 1928 | + | templ_7745c5c3_Var90 := templ.GetChildren(ctx) | |
| 1929 | + | if templ_7745c5c3_Var90 == nil { | |
| 1930 | + | templ_7745c5c3_Var90 = templ.NopComponent | |
| 1860 | 1931 | } | |
| 1861 | 1932 | ctx = templ.ClearChildren(ctx) | |
| 1862 | 1933 | if ln == nil { | |
| 1863 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>") | |
| 1934 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>") | |
| 1864 | 1935 | if templ_7745c5c3_Err != nil { | |
| 1865 | 1936 | return templ_7745c5c3_Err | |
| 1866 | 1937 | } | |
| 1867 | 1938 | } else { | |
| 1868 | - | var templ_7745c5c3_Var88 = []any{"ln", ln.Kind} | |
| 1869 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var88...) | |
| 1939 | + | var templ_7745c5c3_Var91 = []any{"ln", ln.Kind} | |
| 1940 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var91...) | |
| 1870 | 1941 | if templ_7745c5c3_Err != nil { | |
| 1871 | 1942 | return templ_7745c5c3_Err | |
| 1872 | 1943 | } | |
| 1873 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "<td class=\"") | |
| 1944 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<td class=\"") | |
| 1874 | 1945 | if templ_7745c5c3_Err != nil { | |
| 1875 | 1946 | return templ_7745c5c3_Err | |
| 1876 | 1947 | } | |
| 1877 | - | var templ_7745c5c3_Var89 string | |
| 1878 | - | templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var88).String()) | |
| 1948 | + | var templ_7745c5c3_Var92 string | |
| 1949 | + | templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var91).String()) | |
| 1879 | 1950 | if templ_7745c5c3_Err != nil { | |
| 1880 | 1951 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1881 | 1952 | } | |
| 1882 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89) | |
| 1953 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92) | |
| 1883 | 1954 | if templ_7745c5c3_Err != nil { | |
| 1884 | 1955 | return templ_7745c5c3_Err | |
| 1885 | 1956 | } | |
| 1886 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\">") | |
| 1957 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "\">") | |
| 1887 | 1958 | if templ_7745c5c3_Err != nil { | |
| 1888 | 1959 | return templ_7745c5c3_Err | |
| 1889 | 1960 | } | |
| @@ -1898,64 +1969,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1898 | 1969 | return templ_7745c5c3_Err | |
| 1899 | 1970 | } | |
| 1900 | 1971 | } | |
| 1901 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</td>") | |
| 1972 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</td>") | |
| 1902 | 1973 | if templ_7745c5c3_Err != nil { | |
| 1903 | 1974 | return templ_7745c5c3_Err | |
| 1904 | 1975 | } | |
| 1905 | - | var templ_7745c5c3_Var90 = []any{"m", ln.Kind} | |
| 1906 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var90...) | |
| 1976 | + | var templ_7745c5c3_Var93 = []any{"m", ln.Kind} | |
| 1977 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) | |
| 1907 | 1978 | if templ_7745c5c3_Err != nil { | |
| 1908 | 1979 | return templ_7745c5c3_Err | |
| 1909 | 1980 | } | |
| 1910 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<td class=\"") | |
| 1981 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<td class=\"") | |
| 1911 | 1982 | if templ_7745c5c3_Err != nil { | |
| 1912 | 1983 | return templ_7745c5c3_Err | |
| 1913 | 1984 | } | |
| 1914 | - | var templ_7745c5c3_Var91 string | |
| 1915 | - | templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var90).String()) | |
| 1985 | + | var templ_7745c5c3_Var94 string | |
| 1986 | + | templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var93).String()) | |
| 1916 | 1987 | if templ_7745c5c3_Err != nil { | |
| 1917 | 1988 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1918 | 1989 | } | |
| 1919 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91) | |
| 1990 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var94) | |
| 1920 | 1991 | if templ_7745c5c3_Err != nil { | |
| 1921 | 1992 | return templ_7745c5c3_Err | |
| 1922 | 1993 | } | |
| 1923 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "\">") | |
| 1994 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "\">") | |
| 1924 | 1995 | if templ_7745c5c3_Err != nil { | |
| 1925 | 1996 | return templ_7745c5c3_Err | |
| 1926 | 1997 | } | |
| 1927 | - | var templ_7745c5c3_Var92 string | |
| 1928 | - | templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1998 | + | var templ_7745c5c3_Var95 string | |
| 1999 | + | templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1929 | 2000 | if templ_7745c5c3_Err != nil { | |
| 1930 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 364, Col: 38} | |
| 2001 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 379, Col: 38} | |
| 1931 | 2002 | } | |
| 1932 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) | |
| 2003 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) | |
| 1933 | 2004 | if templ_7745c5c3_Err != nil { | |
| 1934 | 2005 | return templ_7745c5c3_Err | |
| 1935 | 2006 | } | |
| 1936 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td>") | |
| 2007 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</td>") | |
| 1937 | 2008 | if templ_7745c5c3_Err != nil { | |
| 1938 | 2009 | return templ_7745c5c3_Err | |
| 1939 | 2010 | } | |
| 1940 | - | var templ_7745c5c3_Var93 = []any{"tx", "chroma", ln.Kind} | |
| 1941 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) | |
| 2011 | + | var templ_7745c5c3_Var96 = []any{"tx", "chroma", ln.Kind} | |
| 2012 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var96...) | |
| 1942 | 2013 | if templ_7745c5c3_Err != nil { | |
| 1943 | 2014 | return templ_7745c5c3_Err | |
| 1944 | 2015 | } | |
| 1945 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<td class=\"") | |
| 2016 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"") | |
| 1946 | 2017 | if templ_7745c5c3_Err != nil { | |
| 1947 | 2018 | return templ_7745c5c3_Err | |
| 1948 | 2019 | } | |
| 1949 | - | var templ_7745c5c3_Var94 string | |
| 1950 | - | templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var93).String()) | |
| 2020 | + | var templ_7745c5c3_Var97 string | |
| 2021 | + | templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var96).String()) | |
| 1951 | 2022 | if templ_7745c5c3_Err != nil { | |
| 1952 | 2023 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1953 | 2024 | } | |
| 1954 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var94) | |
| 2025 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var97) | |
| 1955 | 2026 | if templ_7745c5c3_Err != nil { | |
| 1956 | 2027 | return templ_7745c5c3_Err | |
| 1957 | 2028 | } | |
| 1958 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\">") | |
| 2029 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">") | |
| 1959 | 2030 | if templ_7745c5c3_Err != nil { | |
| 1960 | 2031 | return templ_7745c5c3_Err | |
| 1961 | 2032 | } | |
| @@ -1963,7 +2034,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1963 | 2034 | if templ_7745c5c3_Err != nil { | |
| 1964 | 2035 | return templ_7745c5c3_Err | |
| 1965 | 2036 | } | |
| 1966 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td>") | |
| 2037 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>") | |
| 1967 | 2038 | if templ_7745c5c3_Err != nil { | |
| 1968 | 2039 | return templ_7745c5c3_Err | |
| 1969 | 2040 | } | |
| @@ -1990,49 +2061,49 @@ func gutter(n, href string) templ.Component { | |||
| 1990 | 2061 | }() | |
| 1991 | 2062 | } | |
| 1992 | 2063 | ctx = templ.InitializeContext(ctx) | |
| 1993 | - | templ_7745c5c3_Var95 := templ.GetChildren(ctx) | |
| 1994 | - | if templ_7745c5c3_Var95 == nil { | |
| 1995 | - | templ_7745c5c3_Var95 = templ.NopComponent | |
| 2064 | + | templ_7745c5c3_Var98 := templ.GetChildren(ctx) | |
| 2065 | + | if templ_7745c5c3_Var98 == nil { | |
| 2066 | + | templ_7745c5c3_Var98 = templ.NopComponent | |
| 1996 | 2067 | } | |
| 1997 | 2068 | ctx = templ.ClearChildren(ctx) | |
| 1998 | 2069 | if href != "" { | |
| 1999 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<a href=\"") | |
| 2070 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<a href=\"") | |
| 2000 | 2071 | if templ_7745c5c3_Err != nil { | |
| 2001 | 2072 | return templ_7745c5c3_Err | |
| 2002 | 2073 | } | |
| 2003 | - | var templ_7745c5c3_Var96 templ.SafeURL | |
| 2004 | - | templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) | |
| 2074 | + | var templ_7745c5c3_Var99 templ.SafeURL | |
| 2075 | + | templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) | |
| 2005 | 2076 | if templ_7745c5c3_Err != nil { | |
| 2006 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 373, Col: 31} | |
| 2077 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 388, Col: 31} | |
| 2007 | 2078 | } | |
| 2008 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) | |
| 2079 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) | |
| 2009 | 2080 | if templ_7745c5c3_Err != nil { | |
| 2010 | 2081 | return templ_7745c5c3_Err | |
| 2011 | 2082 | } | |
| 2012 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\" title=\"comment on this line\">") | |
| 2083 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\" title=\"comment on this line\">") | |
| 2013 | 2084 | if templ_7745c5c3_Err != nil { | |
| 2014 | 2085 | return templ_7745c5c3_Err | |
| 2015 | 2086 | } | |
| 2016 | - | var templ_7745c5c3_Var97 string | |
| 2017 | - | templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 2087 | + | var templ_7745c5c3_Var100 string | |
| 2088 | + | templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 2018 | 2089 | if templ_7745c5c3_Err != nil { | |
| 2019 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 373, Col: 66} | |
| 2090 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 388, Col: 66} | |
| 2020 | 2091 | } | |
| 2021 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) | |
| 2092 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) | |
| 2022 | 2093 | if templ_7745c5c3_Err != nil { | |
| 2023 | 2094 | return templ_7745c5c3_Err | |
| 2024 | 2095 | } | |
| 2025 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</a>") | |
| 2096 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</a>") | |
| 2026 | 2097 | if templ_7745c5c3_Err != nil { | |
| 2027 | 2098 | return templ_7745c5c3_Err | |
| 2028 | 2099 | } | |
| 2029 | 2100 | } else { | |
| 2030 | - | var templ_7745c5c3_Var98 string | |
| 2031 | - | templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 2101 | + | var templ_7745c5c3_Var101 string | |
| 2102 | + | templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 2032 | 2103 | if templ_7745c5c3_Err != nil { | |
| 2033 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 375, Col: 5} | |
| 2104 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 390, Col: 5} | |
| 2034 | 2105 | } | |
| 2035 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) | |
| 2106 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) | |
| 2036 | 2107 | if templ_7745c5c3_Err != nil { | |
| 2037 | 2108 | return templ_7745c5c3_Err | |
| 2038 | 2109 | } | |
M internal/adapters/web/repo.go +1 -0
| @@ -272,6 +272,7 @@ func commitRow(repo string, c domain.Commit, now time.Time) CommitRow { | |||
| 272 | 272 | Short: short(c.ID.String()), Subject: visibleText(c.Summary), | |
| 273 | 273 | CHref: repoHref(repo, "commit", c.ID.String()), | |
| 274 | 274 | Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1, | |
| 275 | + | Sig: signatureBadge(c.Verification, false), | |
| 275 | 276 | } | |
| 276 | 277 | row.When, row.WhenAt = whenOf(now, c.Committer.Time) | |
| 277 | 278 | return row | |
A internal/adapters/web/signature.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bnema.dev/ermite/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // SignatureBadge is the pill of a signed commit. Label is the verdict, Proto | |
| 10 | + | // the protocol ("SSH", "GPG", "X.509", empty when unknown), Class the style | |
| 11 | + | // (sig-ok, sig-warn, sig-bad, sig-none) and Title the tooltip that says why. | |
| 12 | + | type SignatureBadge struct { | |
| 13 | + | Label, Proto, Class, Title string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // signatureProto is the display name of a signature format. | |
| 17 | + | func signatureProto(f domain.SignatureFormat) string { | |
| 18 | + | switch f { | |
| 19 | + | case domain.FormatSSH: | |
| 20 | + | return "SSH" | |
| 21 | + | case domain.FormatGPG: | |
| 22 | + | return "GPG" | |
| 23 | + | case domain.FormatX509: | |
| 24 | + | return "X.509" | |
| 25 | + | } | |
| 26 | + | return "" | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // signatureBadge is the badge of a verification, nil when there is nothing to | |
| 30 | + | // show: the commit was not checked, or (unless long, the commit page) it is | |
| 31 | + | // not signed. | |
| 32 | + | func signatureBadge(v domain.CommitVerification, long bool) *SignatureBadge { | |
| 33 | + | switch v.Status { | |
| 34 | + | case domain.StatusUnchecked: | |
| 35 | + | return nil | |
| 36 | + | case domain.StatusUnsigned: | |
| 37 | + | if !long { | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | return &SignatureBadge{Label: "unsigned", Class: "sig-none", Title: "This commit is not signed"} | |
| 41 | + | } | |
| 42 | + | b := &SignatureBadge{Proto: signatureProto(v.Format)} | |
| 43 | + | key := keyPhrase(v) | |
| 44 | + | switch v.Status { | |
| 45 | + | case domain.StatusVerified: | |
| 46 | + | b.Label, b.Class = "Verified", "sig-ok" | |
| 47 | + | b.Title = "Signed with " + key + " — verified for the committer email" | |
| 48 | + | case domain.StatusUnknownKey: | |
| 49 | + | b.Label, b.Class = "Unverified", "sig-warn" | |
| 50 | + | b.Title = unknownKeyTitle(v) | |
| 51 | + | case domain.StatusEmailMismatch: | |
| 52 | + | b.Label, b.Class = "Unverified", "sig-warn" | |
| 53 | + | b.Title = "Signed with " + key + " — this key is not trusted for the committer email" | |
| 54 | + | case domain.StatusExpired: | |
| 55 | + | b.Label, b.Class = "Unverified", "sig-warn" | |
| 56 | + | b.Title = "Signed with " + key + " — the key or the signature expired" | |
| 57 | + | case domain.StatusRevoked: | |
| 58 | + | b.Label, b.Class = "Unverified", "sig-warn" | |
| 59 | + | b.Title = "Signed with " + key + " — the key is revoked" | |
| 60 | + | case domain.StatusBadSignature: | |
| 61 | + | b.Label, b.Class = "Bad signature", "sig-bad" | |
| 62 | + | b.Title = "The signature does not match the commit" | |
| 63 | + | case domain.StatusUnsupported: | |
| 64 | + | b.Label, b.Class = "Unverified", "sig-warn" | |
| 65 | + | if proto := signatureProto(v.Format); proto != "" { | |
| 66 | + | b.Title = proto + " signatures are not checked" | |
| 67 | + | } else { | |
| 68 | + | b.Title = "This kind of signature is not checked" | |
| 69 | + | } | |
| 70 | + | default: | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | return b | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // keyPhrase names the signing key for a tooltip: "SSH key SHA256:… (title)". | |
| 77 | + | func keyPhrase(v domain.CommitVerification) string { | |
| 78 | + | s := "a key" | |
| 79 | + | if p := signatureProto(v.Format); p != "" { | |
| 80 | + | s = p + " key" | |
| 81 | + | } | |
| 82 | + | if v.Fingerprint != "" { | |
| 83 | + | s += " " + v.Fingerprint | |
| 84 | + | } | |
| 85 | + | if v.KeyTitle != "" { | |
| 86 | + | s += " (" + visibleText(v.KeyTitle) + ")" | |
| 87 | + | } | |
| 88 | + | return s | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // unknownKeyTitle is the tooltip of a signature by a key that is not trusted. | |
| 92 | + | func unknownKeyTitle(v domain.CommitVerification) string { | |
| 93 | + | s := "Signed with an unknown " | |
| 94 | + | if p := signatureProto(v.Format); p != "" { | |
| 95 | + | s += p + " " | |
| 96 | + | } | |
| 97 | + | s += "key" | |
| 98 | + | if v.Fingerprint != "" { | |
| 99 | + | s += " " + v.Fingerprint | |
| 100 | + | } | |
| 101 | + | return s | |
| 102 | + | } | |
A internal/adapters/web/signature_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | sshFP = "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" | |
| 17 | + | gpgFP = "0123456789ABCDEF0123456789ABCDEF01234567" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func verification(f domain.SignatureFormat, s domain.SignatureStatus, fp, title string) domain.CommitVerification { | |
| 21 | + | return domain.CommitVerification{Format: f, Status: s, Fingerprint: fp, KeyTitle: title} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestSignatureBadge(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | for _, tt := range []struct { | |
| 27 | + | name string | |
| 28 | + | v domain.CommitVerification | |
| 29 | + | long bool | |
| 30 | + | nilBadge bool | |
| 31 | + | proto, label string | |
| 32 | + | class, title string | |
| 33 | + | }{ | |
| 34 | + | {name: "unchecked", v: domain.CommitVerification{}, nilBadge: true}, | |
| 35 | + | {name: "unchecked long", v: domain.CommitVerification{}, long: true, nilBadge: true}, | |
| 36 | + | {name: "unsigned in a list", v: verification("", domain.StatusUnsigned, "", ""), nilBadge: true}, | |
| 37 | + | {name: "unsigned on the page", v: verification("", domain.StatusUnsigned, "", ""), long: true, label: "unsigned", class: "sig-none"}, | |
| 38 | + | {name: "verified ssh", v: verification(domain.FormatSSH, domain.StatusVerified, sshFP, "laptop"), | |
| 39 | + | proto: "SSH", label: "Verified", class: "sig-ok", title: "Signed with SSH key " + sshFP + " (laptop) — verified for the committer email"}, | |
| 40 | + | {name: "verified gpg", v: verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), | |
| 41 | + | proto: "GPG", label: "Verified", class: "sig-ok", title: "Signed with GPG key " + gpgFP + " — verified for the committer email"}, | |
| 42 | + | {name: "unknown key", v: verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""), | |
| 43 | + | proto: "GPG", label: "Unverified", class: "sig-warn", title: "Signed with an unknown GPG key " + gpgFP}, | |
| 44 | + | {name: "email mismatch", v: verification(domain.FormatSSH, domain.StatusEmailMismatch, sshFP, "laptop"), | |
| 45 | + | proto: "SSH", label: "Unverified", class: "sig-warn", title: "this key is not trusted for the committer email"}, | |
| 46 | + | {name: "expired", v: verification(domain.FormatGPG, domain.StatusExpired, gpgFP, ""), | |
| 47 | + | proto: "GPG", label: "Unverified", class: "sig-warn", title: "expired"}, | |
| 48 | + | {name: "revoked", v: verification(domain.FormatGPG, domain.StatusRevoked, gpgFP, ""), | |
| 49 | + | proto: "GPG", label: "Unverified", class: "sig-warn", title: "revoked"}, | |
| 50 | + | {name: "bad signature", v: verification(domain.FormatSSH, domain.StatusBadSignature, "", ""), | |
| 51 | + | proto: "SSH", label: "Bad signature", class: "sig-bad", title: "The signature does not match the commit"}, | |
| 52 | + | {name: "x509", v: verification(domain.FormatX509, domain.StatusUnsupported, "", ""), | |
| 53 | + | proto: "X.509", label: "Unverified", class: "sig-warn", title: "X.509 signatures are not checked"}, | |
| 54 | + | } { | |
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | b := signatureBadge(tt.v, tt.long) | |
| 58 | + | if tt.nilBadge { | |
| 59 | + | assert.Nil(t, b) | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | require.NotNil(t, b) | |
| 63 | + | assert.Equal(t, tt.proto, b.Proto) | |
| 64 | + | assert.Equal(t, tt.label, b.Label) | |
| 65 | + | assert.Equal(t, tt.class, b.Class) | |
| 66 | + | assert.Contains(t, b.Title, tt.title) | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func rowOf(v domain.CommitVerification) CommitRow { | |
| 72 | + | id := objectID(&testing.T{}, "0123456789abcdef0123456789abcdef01234567") | |
| 73 | + | return commitRow("demo", domain.Commit{ | |
| 74 | + | ID: id, Summary: "s", Verification: v, | |
| 75 | + | Author: domain.Signature{Name: "A"}, Committer: domain.Signature{Time: time.Unix(1, 0)}, | |
| 76 | + | }, time.Unix(100, 0)) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestCommitRowsShowTheSignatureBadge(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | for _, tt := range []struct { | |
| 82 | + | name string | |
| 83 | + | v domain.CommitVerification | |
| 84 | + | want []string | |
| 85 | + | none bool | |
| 86 | + | }{ | |
| 87 | + | {name: "ssh verified", v: verification(domain.FormatSSH, domain.StatusVerified, sshFP, "laptop"), | |
| 88 | + | want: []string{`class="pill sig sig-ok"`, `<span class="sig-proto">SSH</span> Verified`, `title="Signed with SSH key ` + sshFP}}, | |
| 89 | + | {name: "gpg verified", v: verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), | |
| 90 | + | want: []string{`class="pill sig sig-ok"`, `<span class="sig-proto">GPG</span> Verified`}}, | |
| 91 | + | {name: "unknown key", v: verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""), | |
| 92 | + | want: []string{`class="pill sig sig-warn"`, `GPG</span> Unverified`, `unknown GPG key`}}, | |
| 93 | + | {name: "bad signature", v: verification(domain.FormatSSH, domain.StatusBadSignature, "", ""), | |
| 94 | + | want: []string{`class="pill sig sig-bad"`, `SSH</span> Bad signature`}}, | |
| 95 | + | {name: "unsigned", v: verification("", domain.StatusUnsigned, "", ""), none: true}, | |
| 96 | + | {name: "unchecked", v: domain.CommitVerification{}, none: true}, | |
| 97 | + | } { | |
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 100 | + | got := renderToString(t, CommitRows([]CommitRow{rowOf(tt.v)})) | |
| 101 | + | if tt.none { | |
| 102 | + | assert.NotContains(t, got, "sig") | |
| 103 | + | assert.NotContains(t, got, "unsigned") | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | for _, w := range tt.want { | |
| 107 | + | assert.Contains(t, got, w) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestCommitPageShowsTheSignature(t *testing.T) { | |
| 114 | + | t.Parallel() | |
| 115 | + | page := func(v domain.CommitVerification) string { | |
| 116 | + | return renderToString(t, Commit(Layout{Repo: "demo"}, CommitPage{ | |
| 117 | + | SHA: "0123456789abcdef0123456789abcdef01234567", Short: "0123456", Subject: "s", | |
| 118 | + | Sig: signatureBadge(v, true), Verification: v, | |
| 119 | + | })) | |
| 120 | + | } | |
| 121 | + | got := page(verification(domain.FormatSSH, domain.StatusVerified, sshFP, "my <laptop>")) | |
| 122 | + | assert.Contains(t, got, "<dt>signature</dt>") | |
| 123 | + | assert.Contains(t, got, `<span class="sig-proto">SSH</span> Verified`) | |
| 124 | + | assert.Contains(t, got, "<code>"+sshFP+"</code>") | |
| 125 | + | assert.Contains(t, got, "my <laptop>") | |
| 126 | + | ||
| 127 | + | got = page(verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, "")) | |
| 128 | + | assert.Contains(t, got, `sig-warn`) | |
| 129 | + | assert.Contains(t, got, "<code>"+gpgFP+"</code>") | |
| 130 | + | assert.Contains(t, got, `<span class="sig-why dim">Signed with an unknown GPG key `+gpgFP+`</span>`, "the reason is text, not only a tooltip") | |
| 131 | + | ||
| 132 | + | got = page(verification("", domain.StatusUnsigned, "", "")) | |
| 133 | + | assert.Contains(t, got, "<dt>signature</dt>") | |
| 134 | + | assert.Contains(t, got, "sig-none") | |
| 135 | + | assert.Contains(t, got, "unsigned") | |
| 136 | + | assert.NotContains(t, got, "<code>") | |
| 137 | + | assert.NotContains(t, got, "sig-why") | |
| 138 | + | ||
| 139 | + | got = page(domain.CommitVerification{}) | |
| 140 | + | assert.NotContains(t, got, "signature") | |
| 141 | + | } | |
M internal/adapters/web/static/css/20-shell.css +14 -1
| @@ -36,7 +36,20 @@ | |||
| 36 | 36 | background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap; | |
| 37 | 37 | &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); } | |
| 38 | 38 | &.new { color: var(--teal); border-color: currentColor; } | |
| 39 | - | } | |
| 39 | + | /* the signature of a commit: the protocol, then the verdict, always as text | |
| 40 | + | (colour never carries it alone). Verified takes the accent, a signature | |
| 41 | + | that cannot be vouched for keeps the neutral pill with the ink of a | |
| 42 | + | warning, a bad one is red, "unsigned" (commit page) is dim. */ | |
| 43 | + | &.sig { gap: 5px; } | |
| 44 | + | &.sig .sig-proto { font-weight: 700; letter-spacing: .02em; } | |
| 45 | + | &.sig-ok { color: var(--teal); border-color: currentColor; } | |
| 46 | + | &.sig-warn { color: var(--ink-hi); border-style: dashed; } | |
| 47 | + | &.sig-bad { color: var(--red); border-color: currentColor; } | |
| 48 | + | &.sig-none { color: var(--ink-mute); border-style: dotted; font-weight: 400; } | |
| 49 | + | } | |
| 50 | + | /* Why a signature is or is not verified, as text: a title alone is not | |
| 51 | + | reachable by keyboard, touch or every screen reader. */ | |
| 52 | + | .sig-why { display: block; margin-top: 2px; } | |
| 40 | 53 | .count { | |
| 41 | 54 | display: inline-block; min-width: 22px; padding: 0 7px; margin-left: var(--s1); | |
| 42 | 55 | font: 600 var(--fs-xs) / 20px var(--font); text-align: center; color: var(--ink-dim); | |
M internal/adapters/web/static/css/50-code.css +2 -0
| @@ -162,6 +162,8 @@ | |||
| 162 | 162 | & td:last-child { padding-right: var(--s4); } | |
| 163 | 163 | & .c-who { display: none; } | |
| 164 | 164 | & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); } | |
| 165 | + | /* the pills are hidden here, the signature one too: the rail is narrow and | |
| 166 | + | the subject is what the row is for; the commit page shows the badge. */ | |
| 165 | 167 | & .c-subject .pill { display: none; } | |
| 166 | 168 | & .c-ck:empty { padding: 0; } | |
| 167 | 169 | & .c-when { text-align: right; } | |
M internal/adapters/web/view.go +2 -0
| @@ -180,6 +180,8 @@ type CommitRow struct { | |||
| 180 | 180 | // Checks is the combined state of the CI checks of the commit; empty when | |
| 181 | 181 | // it has none. | |
| 182 | 182 | Checks ChecksSummary | |
| 183 | + | // Sig is the signature badge, nil for an unchecked or unsigned commit. | |
| 184 | + | Sig *SignatureBadge | |
| 183 | 185 | } | |
| 184 | 186 | ||
| 185 | 187 | // SummaryPage is the repository summary. | |
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
M internal/app/cli_e2e_test.go +1 -1
| @@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 338 | 338 | ver := decode[versionOut](t, res.stdout) | |
| 339 | 339 | assert.Equal(t, c.url, ver.Server.URL) | |
| 340 | 340 | assert.NotEmpty(t, ver.Server.Version) | |
| 341 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts"}, ver.Server.Features) | |
| 341 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | |
| 342 | 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 343 | 343 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 344 | 344 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_fake_test.go +16 -0
| @@ -33,6 +33,7 @@ type fakeAPI struct { | |||
| 33 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 34 | 34 | ermiteconnect.UnimplementedSessionServiceHandler | |
| 35 | 35 | ermiteconnect.UnimplementedRunServiceHandler | |
| 36 | + | ermiteconnect.UnimplementedTrustedKeyServiceHandler | |
| 36 | 37 | ||
| 37 | 38 | runners []*ermite.Runner // ListRunners answer; nil means one default runner | |
| 38 | 39 | ||
| @@ -64,6 +65,10 @@ type fakeAPI struct { | |||
| 64 | 65 | ||
| 65 | 66 | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |
| 66 | 67 | removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) | |
| 68 | + | ||
| 69 | + | listTrustedKeys func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) | |
| 70 | + | addTrustedKey func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) | |
| 71 | + | removeTrustedKey func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) | |
| 67 | 72 | } | |
| 68 | 73 | ||
| 69 | 74 | func (f *fakeAPI) record(name string, h http.Header, req any) { | |
| @@ -152,6 +157,15 @@ func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.List | |||
| 152 | 157 | func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) { | |
| 153 | 158 | return respond(f, "RemovePasskey", r, f.removePasskey) | |
| 154 | 159 | } | |
| 160 | + | func (f *fakeAPI) ListTrustedKeys(_ context.Context, r *connect.Request[ermite.ListTrustedKeysRequest]) (*connect.Response[ermite.ListTrustedKeysResponse], error) { | |
| 161 | + | return respond(f, "ListTrustedKeys", r, f.listTrustedKeys) | |
| 162 | + | } | |
| 163 | + | func (f *fakeAPI) AddTrustedKey(_ context.Context, r *connect.Request[ermite.AddTrustedKeyRequest]) (*connect.Response[ermite.AddTrustedKeyResponse], error) { | |
| 164 | + | return respond(f, "AddTrustedKey", r, f.addTrustedKey) | |
| 165 | + | } | |
| 166 | + | func (f *fakeAPI) RemoveTrustedKey(_ context.Context, r *connect.Request[ermite.RemoveTrustedKeyRequest]) (*connect.Response[ermite.RemoveTrustedKeyResponse], error) { | |
| 167 | + | return respond(f, "RemoveTrustedKey", r, f.removeTrustedKey) | |
| 168 | + | } | |
| 155 | 169 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | |
| 156 | 170 | f.record("Watch", r.Header(), r.Msg) | |
| 157 | 171 | if f.watch == nil { | |
| @@ -179,6 +193,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 179 | 193 | mount(p, h) | |
| 180 | 194 | p, h = ermiteconnect.NewRunServiceHandler(f) | |
| 181 | 195 | mount(p, h) | |
| 196 | + | p, h = ermiteconnect.NewTrustedKeyServiceHandler(f) | |
| 197 | + | mount(p, h) | |
| 182 | 198 | srv := httptest.NewServer(mux) | |
| 183 | 199 | t.Cleanup(srv.Close) | |
| 184 | 200 | return srv.URL | |
M internal/app/cli_signing.go +247 -3
| @@ -3,12 +3,20 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 6 | 8 | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "slices" | |
| 12 | + | "strings" | |
| 7 | 13 | ||
| 8 | 14 | "connectrpc.com/connect" | |
| 9 | 15 | "github.com/spf13/cobra" | |
| 10 | 16 | ||
| 11 | 17 | "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | 20 | ) | |
| 13 | 21 | ||
| 14 | 22 | // jsonSigningKey is the stable JSON shape of `signing-key show --json`. | |
| @@ -23,14 +31,250 @@ type jsonSigningKey struct { | |||
| 23 | 31 | func newSigningKeyCmd(env *cliEnv) *cobra.Command { | |
| 24 | 32 | key := &cobra.Command{ | |
| 25 | 33 | Use: "signing-key", | |
| 26 | - | Short: "Show the key that signs the commits the server creates", | |
| 34 | + | Short: "Show the server's signing key and manage the keys that verify pushed commits", | |
| 27 | 35 | Long: "The server signs the commits it creates (squash merges) with its own SSH key, so that they show as\n" + | |
| 28 | - | "verified. Needs a server with the signing_key feature (see `ermite version`).", | |
| 36 | + | "verified: `show` prints that key. Needs a server with the signing_key feature (see `ermite version`).\n\n" + | |
| 37 | + | "The server also checks the signatures of the commits you push, SSH and GPG, against the public keys\n" + | |
| 38 | + | "you trust: `trust add|list|remove` manage them. A commit shows as verified when its signature is valid,\n" + | |
| 39 | + | "made by a trusted key, and the key is trusted for the committer's email. The server's own key is\n" + | |
| 40 | + | "always trusted (builtin) and cannot be removed. Needs the commit_signatures feature (see\n" + | |
| 41 | + | "`ermite version`); adding and removing keys is for the owner.", | |
| 29 | 42 | } | |
| 30 | - | key.AddCommand(newSigningKeyShowCmd(env)) | |
| 43 | + | key.AddCommand(newSigningKeyShowCmd(env), newSigningKeyTrustCmd(env)) | |
| 31 | 44 | return key | |
| 32 | 45 | } | |
| 33 | 46 | ||
| 47 | + | // jsonTrustedKey is the stable JSON shape of a trusted key. | |
| 48 | + | type jsonTrustedKey struct { | |
| 49 | + | ID string `json:"id"` | |
| 50 | + | Format string `json:"format"` | |
| 51 | + | Title string `json:"title"` | |
| 52 | + | PublicKey string `json:"public_key"` | |
| 53 | + | Emails []string `json:"emails"` | |
| 54 | + | Builtin bool `json:"builtin"` | |
| 55 | + | CreatedAt *string `json:"created_at"` | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | const jsonTrustedKeyHelp = "\n\nJSON fields of a key (--json): id (SHA256:... for ssh, the fingerprint in hex for gpg), format\n" + | |
| 59 | + | "(ssh, gpg), title, public_key, emails (array), builtin (true for the server's own key), created_at\n" + | |
| 60 | + | "(RFC 3339 UTC, null for the builtin key)." | |
| 61 | + | ||
| 62 | + | func formatName(f ermite.SignatureFormat) string { | |
| 63 | + | switch f { | |
| 64 | + | case ermite.SignatureFormat_SIGNATURE_FORMAT_SSH: | |
| 65 | + | return "ssh" | |
| 66 | + | case ermite.SignatureFormat_SIGNATURE_FORMAT_GPG: | |
| 67 | + | return "gpg" | |
| 68 | + | case ermite.SignatureFormat_SIGNATURE_FORMAT_X509: | |
| 69 | + | return "x509" | |
| 70 | + | case ermite.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED: | |
| 71 | + | } | |
| 72 | + | return "unknown" | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func toJSONTrustedKey(k *ermite.TrustedKey) jsonTrustedKey { | |
| 76 | + | return jsonTrustedKey{ | |
| 77 | + | ID: k.GetId(), Format: formatName(k.GetFormat()), Title: k.GetTitle(), PublicKey: k.GetPublicKey(), | |
| 78 | + | Emails: append([]string{}, k.GetEmails()...), Builtin: k.GetBuiltin(), CreatedAt: jsonTime(k.GetCreatedAt()), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // requireCommitSignatures refuses to go on with a server that does not list | |
| 83 | + | // commit_signatures: it has no TrustedKeyService. A server whose features | |
| 84 | + | // cannot be read is refused too. | |
| 85 | + | func requireCommitSignatures(ctx context.Context, c *client.Client) error { | |
| 86 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 87 | + | if err != nil { | |
| 88 | + | return c.Describe(err) | |
| 89 | + | } | |
| 90 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureCommitSignatures) { | |
| 91 | + | return fmt.Errorf("the server does not support %s, so it cannot manage trusted keys (upgrade the server, see `ermite version`)", client.FeatureCommitSignatures) | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func newSigningKeyTrustCmd(env *cliEnv) *cobra.Command { | |
| 97 | + | trust := &cobra.Command{ | |
| 98 | + | Use: "trust", | |
| 99 | + | Short: "Manage the keys commit signatures are verified against", | |
| 100 | + | Long: "List, add and remove the public keys the server trusts to verify the signatures of commits (SSH and\n" + | |
| 101 | + | "GPG). Each key is trusted for one to ten committer emails: a commit signed by the key but made\n" + | |
| 102 | + | "under another email shows as unverified. Needs a server with the commit_signatures feature.", | |
| 103 | + | } | |
| 104 | + | trust.AddCommand(newTrustListCmd(env), newTrustAddCmd(env), newTrustRemoveCmd(env)) | |
| 105 | + | return trust | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func newTrustListCmd(env *cliEnv) *cobra.Command { | |
| 109 | + | cmd := &cobra.Command{ | |
| 110 | + | Use: "list", | |
| 111 | + | Short: "List the trusted keys", | |
| 112 | + | Long: "List the trusted keys, the server's own (builtin) key first. Any authenticated token may." + jsonTrustedKeyHelp + | |
| 113 | + | "\n--json prints an array of keys.", | |
| 114 | + | Args: cobra.NoArgs, | |
| 115 | + | } | |
| 116 | + | asJSON := addJSONFlag(cmd) | |
| 117 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 118 | + | c, err := env.client(cmd) | |
| 119 | + | if err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | ctx := env.ctx(cmd) | |
| 123 | + | if err := requireCommitSignatures(ctx, c); err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | res, err := c.TrustedKeys().ListTrustedKeys(ctx, connect.NewRequest(&ermite.ListTrustedKeysRequest{})) | |
| 127 | + | if err != nil { | |
| 128 | + | return c.Describe(err) | |
| 129 | + | } | |
| 130 | + | keys := res.Msg.GetKeys() | |
| 131 | + | if *asJSON { | |
| 132 | + | out := make([]jsonTrustedKey, 0, len(keys)) | |
| 133 | + | for _, k := range keys { | |
| 134 | + | out = append(out, toJSONTrustedKey(k)) | |
| 135 | + | } | |
| 136 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 137 | + | } | |
| 138 | + | if len(keys) == 0 { | |
| 139 | + | noticef(cmd, "no trusted keys") | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | rows := make([][]string, 0, len(keys)) | |
| 143 | + | for _, k := range keys { | |
| 144 | + | builtin := "-" | |
| 145 | + | if k.GetBuiltin() { | |
| 146 | + | builtin = "yes" | |
| 147 | + | } | |
| 148 | + | rows = append(rows, []string{k.GetId(), formatName(k.GetFormat()), k.GetTitle(), strings.Join(k.GetEmails(), ","), builtin}) | |
| 149 | + | } | |
| 150 | + | return table(cmd.OutOrStdout(), []string{"ID", "FORMAT", "TITLE", "EMAILS", "BUILTIN"}, rows) | |
| 151 | + | } | |
| 152 | + | return cmd | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func newTrustAddCmd(env *cliEnv) *cobra.Command { | |
| 156 | + | var ( | |
| 157 | + | format string | |
| 158 | + | title string | |
| 159 | + | emails []string | |
| 160 | + | ) | |
| 161 | + | cmd := &cobra.Command{ | |
| 162 | + | Use: "add --format ssh|gpg --title <title> --email <email> <file|->", | |
| 163 | + | Short: "Trust a public key", | |
| 164 | + | Long: "Trust a public key for the given committer emails. The key is read from the file, or from standard\n" + | |
| 165 | + | "input with -, up to 64 KiB: an authorized_keys line for --format ssh (for example ~/.ssh/id_ed25519.pub),\n" + | |
| 166 | + | "an armored public key for --format gpg (`gpg --armor --export <fingerprint>`, exactly one key, never\n" + | |
| 167 | + | "a private key). --email is required, repeatable, 1 to 10 addresses; the title is 1 to 100 bytes.\n" + | |
| 168 | + | "Only the owner may add a key. Prints the key id on standard output, details on standard error;\n" + | |
| 169 | + | "with --json, standard output is the key object." + jsonTrustedKeyHelp, | |
| 170 | + | Example: " ermite signing-key trust add --format ssh --title laptop --email me@example.com ~/.ssh/id_ed25519.pub\n" + | |
| 171 | + | " gpg --armor --export 0xABCD | ermite signing-key trust add --format gpg --title work --email me@example.com -", | |
| 172 | + | Args: cobra.ExactArgs(1), | |
| 173 | + | } | |
| 174 | + | cmd.Flags().StringVar(&format, "format", "", "key format: ssh or gpg") | |
| 175 | + | cmd.Flags().StringVar(&title, "title", "", "label of the key") | |
| 176 | + | cmd.Flags().StringSliceVar(&emails, "email", nil, "committer email the key may sign for, repeatable") | |
| 177 | + | _ = cmd.MarkFlagRequired("format") | |
| 178 | + | _ = cmd.MarkFlagRequired("title") | |
| 179 | + | _ = cmd.MarkFlagRequired("email") | |
| 180 | + | _ = cmd.RegisterFlagCompletionFunc("format", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 181 | + | return []string{"ssh", "gpg"}, cobra.ShellCompDirectiveNoFileComp | |
| 182 | + | }) | |
| 183 | + | asJSON := addJSONFlag(cmd) | |
| 184 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 185 | + | var pbFormat ermite.SignatureFormat | |
| 186 | + | switch f, err := domain.ParseSignatureFormat(format); { | |
| 187 | + | case err != nil: | |
| 188 | + | return fmt.Errorf("invalid --format %q: use ssh or gpg", format) | |
| 189 | + | case f == domain.FormatGPG: | |
| 190 | + | pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_GPG | |
| 191 | + | default: | |
| 192 | + | pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_SSH | |
| 193 | + | } | |
| 194 | + | key, err := readTrustedKey(cmd, args[0]) | |
| 195 | + | if err != nil { | |
| 196 | + | return err | |
| 197 | + | } | |
| 198 | + | c, err := env.client(cmd) | |
| 199 | + | if err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | ctx := env.ctx(cmd) | |
| 203 | + | if err := requireCommitSignatures(ctx, c); err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | res, err := c.TrustedKeys().AddTrustedKey(ctx, connect.NewRequest(&ermite.AddTrustedKeyRequest{ | |
| 207 | + | Format: pbFormat, Title: title, PublicKey: key, Emails: emails, | |
| 208 | + | })) | |
| 209 | + | if err != nil { | |
| 210 | + | return c.Describe(err) | |
| 211 | + | } | |
| 212 | + | k := res.Msg.GetKey() | |
| 213 | + | if *asJSON { | |
| 214 | + | return writeJSON(cmd.OutOrStdout(), toJSONTrustedKey(k)) | |
| 215 | + | } | |
| 216 | + | noticef(cmd, "trusted %s key %q for %s", formatName(k.GetFormat()), clean(k.GetTitle()), clean(strings.Join(k.GetEmails(), ", "))) | |
| 217 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetId())) | |
| 218 | + | return err | |
| 219 | + | } | |
| 220 | + | return cmd | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // readTrustedKey reads a public key from a file, or from standard input for | |
| 224 | + | // "-", refusing more than domain.MaxTrustedKeyBytes. | |
| 225 | + | func readTrustedKey(cmd *cobra.Command, path string) (string, error) { | |
| 226 | + | var r io.Reader | |
| 227 | + | name := "standard input" | |
| 228 | + | if path == "-" { | |
| 229 | + | r = cmd.InOrStdin() | |
| 230 | + | } else { | |
| 231 | + | f, err := os.Open(path) //nolint:gosec // the user names the file | |
| 232 | + | if err != nil { | |
| 233 | + | return "", fmt.Errorf("read %s: %w", path, err) | |
| 234 | + | } | |
| 235 | + | defer func() { _ = f.Close() }() | |
| 236 | + | r, name = f, path | |
| 237 | + | } | |
| 238 | + | b, err := io.ReadAll(io.LimitReader(r, domain.MaxTrustedKeyBytes+1)) | |
| 239 | + | if err != nil { | |
| 240 | + | return "", fmt.Errorf("read %s: %w", name, err) | |
| 241 | + | } | |
| 242 | + | if len(b) > domain.MaxTrustedKeyBytes { | |
| 243 | + | return "", fmt.Errorf("%s: the key is larger than %d KiB", name, domain.MaxTrustedKeyBytes>>10) | |
| 244 | + | } | |
| 245 | + | key := strings.TrimSpace(string(b)) | |
| 246 | + | if key == "" { | |
| 247 | + | return "", errors.New(name + ": no key to read") | |
| 248 | + | } | |
| 249 | + | return key, nil | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func newTrustRemoveCmd(env *cliEnv) *cobra.Command { | |
| 253 | + | cmd := &cobra.Command{ | |
| 254 | + | Use: "remove <id>", | |
| 255 | + | Short: "Stop trusting a key", | |
| 256 | + | Long: "Remove a trusted key by its id (see `signing-key trust list`). Commits it signed then show as\n" + | |
| 257 | + | "unverified. The server's own (builtin) key cannot be removed. Only the owner may remove a key.", | |
| 258 | + | Args: cobra.ExactArgs(1), | |
| 259 | + | } | |
| 260 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 261 | + | c, err := env.client(cmd) | |
| 262 | + | if err != nil { | |
| 263 | + | return err | |
| 264 | + | } | |
| 265 | + | ctx := env.ctx(cmd) | |
| 266 | + | if err := requireCommitSignatures(ctx, c); err != nil { | |
| 267 | + | return err | |
| 268 | + | } | |
| 269 | + | if _, err := c.TrustedKeys().RemoveTrustedKey(ctx, connect.NewRequest(&ermite.RemoveTrustedKeyRequest{Id: args[0]})); err != nil { | |
| 270 | + | return c.Describe(err) | |
| 271 | + | } | |
| 272 | + | noticef(cmd, "removed trusted key %s", clean(args[0])) | |
| 273 | + | return nil | |
| 274 | + | } | |
| 275 | + | return cmd | |
| 276 | + | } | |
| 277 | + | ||
| 34 | 278 | func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |
| 35 | 279 | var allowedSigners, publicKey bool | |
| 36 | 280 | cmd := &cobra.Command{ | |
A internal/app/cli_signing_test.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // withCommitSignatures is a GetVersion that lists commit_signatures. | |
| 20 | + | func withCommitSignatures(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 21 | + | return &ermite.GetVersionResponse{Version: "v0.7.0", Features: []string{"commit_signatures"}}, nil | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | const testSSHKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE1234" | |
| 25 | + | ||
| 26 | + | func lastRequest[T any](t *testing.T, f *fakeAPI, call string) *T { | |
| 27 | + | t.Helper() | |
| 28 | + | calls, reqs := f.snapshot() | |
| 29 | + | for i := len(calls) - 1; i >= 0; i-- { | |
| 30 | + | if calls[i] == call { | |
| 31 | + | return reqs[i].(*T) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | t.Fatalf("no %s call", call) | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestSigningKeyTrustList(t *testing.T) { | |
| 39 | + | keys := []*ermite.TrustedKey{ | |
| 40 | + | {Id: "SHA256:builtin", Format: ermite.SignatureFormat_SIGNATURE_FORMAT_SSH, Title: "ermite", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"ermite@example.com"}, Builtin: true}, | |
| 41 | + | {Id: "ABCDEF0123", Format: ermite.SignatureFormat_SIGNATURE_FORMAT_GPG, Title: "work", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: ts("2026-10-10T12:00:00Z")}, | |
| 42 | + | } | |
| 43 | + | f := &fakeAPI{getVersion: withCommitSignatures, listTrustedKeys: func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) { | |
| 44 | + | return &ermite.ListTrustedKeysResponse{Keys: keys}, nil | |
| 45 | + | }} | |
| 46 | + | url := f.serve(t) | |
| 47 | + | ||
| 48 | + | res := runCLI(t, envFor(url), "", "signing-key", "trust", "list").ok(t) | |
| 49 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 50 | + | require.Len(t, lines, 3, res.stdout) | |
| 51 | + | assert.Regexp(t, `^ID\s+FORMAT\s+TITLE\s+EMAILS\s+BUILTIN$`, lines[0]) | |
| 52 | + | assert.Regexp(t, `^SHA256:builtin\s+ssh\s+ermite\s+ermite@example.com\s+yes$`, lines[1]) | |
| 53 | + | assert.Regexp(t, `^ABCDEF0123\s+gpg\s+work\s+a@b.c,d@e.f\s+-$`, lines[2]) | |
| 54 | + | ||
| 55 | + | res = runCLI(t, envFor(url), "", "signing-key", "trust", "list", "--json").ok(t) | |
| 56 | + | assert.JSONEq(t, `[ | |
| 57 | + | {"id":"SHA256:builtin","format":"ssh","title":"ermite","public_key":"ssh-ed25519 AAAA","emails":["ermite@example.com"],"builtin":true,"created_at":null}, | |
| 58 | + | {"id":"ABCDEF0123","format":"gpg","title":"work","public_key":"-----BEGIN PGP PUBLIC KEY BLOCK-----","emails":["a@b.c","d@e.f"],"builtin":false,"created_at":"2026-10-10T12:00:00Z"} | |
| 59 | + | ]`, res.stdout) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestSigningKeyTrustListEmpty(t *testing.T) { | |
| 63 | + | f := &fakeAPI{getVersion: withCommitSignatures, listTrustedKeys: func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) { | |
| 64 | + | return &ermite.ListTrustedKeysResponse{}, nil | |
| 65 | + | }} | |
| 66 | + | url := f.serve(t) | |
| 67 | + | res := runCLI(t, envFor(url), "", "signing-key", "trust", "list").ok(t) | |
| 68 | + | assert.Empty(t, res.stdout) | |
| 69 | + | assert.Contains(t, res.stderr, "no trusted keys") | |
| 70 | + | res = runCLI(t, envFor(url), "", "signing-key", "trust", "list", "--json").ok(t) | |
| 71 | + | assert.JSONEq(t, `[]`, res.stdout) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func addEcho(r *ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) { | |
| 75 | + | return &ermite.AddTrustedKeyResponse{Key: &ermite.TrustedKey{ | |
| 76 | + | Id: "SHA256:new", Format: r.GetFormat(), Title: r.GetTitle(), PublicKey: r.GetPublicKey(), Emails: r.GetEmails(), CreatedAt: ts("2026-10-10T12:00:00Z"), | |
| 77 | + | }}, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestSigningKeyTrustAdd(t *testing.T) { | |
| 81 | + | t.Run("from a file", func(t *testing.T) { | |
| 82 | + | f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho} | |
| 83 | + | file := filepath.Join(t.TempDir(), "id_ed25519.pub") | |
| 84 | + | require.NoError(t, os.WriteFile(file, []byte(testSSHKey+" me@laptop\n"), 0o600)) | |
| 85 | + | res := runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "add", "--format", "ssh", "--title", "laptop", | |
| 86 | + | "--email", "me@example.com", "--email", "me@work.example", file).ok(t) | |
| 87 | + | got := lastRequest[ermite.AddTrustedKeyRequest](t, f, "AddTrustedKey") | |
| 88 | + | assert.Equal(t, ermite.SignatureFormat_SIGNATURE_FORMAT_SSH, got.GetFormat()) | |
| 89 | + | assert.Equal(t, "laptop", got.GetTitle()) | |
| 90 | + | assert.Equal(t, testSSHKey+" me@laptop", got.GetPublicKey()) | |
| 91 | + | assert.Equal(t, []string{"me@example.com", "me@work.example"}, got.GetEmails()) | |
| 92 | + | assert.Equal(t, "SHA256:new\n", res.stdout) | |
| 93 | + | assert.Contains(t, res.stderr, `trusted ssh key "laptop"`) | |
| 94 | + | }) | |
| 95 | + | ||
| 96 | + | t.Run("gpg from stdin, json", func(t *testing.T) { | |
| 97 | + | f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho} | |
| 98 | + | armor := "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxxxx\n-----END PGP PUBLIC KEY BLOCK-----\n" | |
| 99 | + | res := runCLI(t, envFor(f.serve(t)), armor, "signing-key", "trust", "add", "--format", "GPG", "--title", "work", | |
| 100 | + | "--email", "me@example.com", "--json", "-").ok(t) | |
| 101 | + | got := lastRequest[ermite.AddTrustedKeyRequest](t, f, "AddTrustedKey") | |
| 102 | + | assert.Equal(t, ermite.SignatureFormat_SIGNATURE_FORMAT_GPG, got.GetFormat()) | |
| 103 | + | assert.Equal(t, strings.TrimSpace(armor), got.GetPublicKey()) | |
| 104 | + | assert.JSONEq(t, `{"id":"SHA256:new","format":"gpg","title":"work","public_key":"`+strings.ReplaceAll(strings.TrimSpace(armor), "\n", `\n`)+`","emails":["me@example.com"],"builtin":false,"created_at":"2026-10-10T12:00:00Z"}`, res.stdout) | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | t.Run("flag and input errors send nothing", func(t *testing.T) { | |
| 108 | + | big := strings.Repeat("a", 64<<10+1) | |
| 109 | + | for name, tt := range map[string]struct { | |
| 110 | + | stdin string | |
| 111 | + | args []string | |
| 112 | + | want string | |
| 113 | + | }{ | |
| 114 | + | "missing email": {testSSHKey, []string{"--format", "ssh", "--title", "t", "-"}, `"email"`}, | |
| 115 | + | "missing title": {testSSHKey, []string{"--format", "ssh", "--email", "a@b.c", "-"}, `"title"`}, | |
| 116 | + | "missing format": {testSSHKey, []string{"--title", "t", "--email", "a@b.c", "-"}, `"format"`}, | |
| 117 | + | "x509": {testSSHKey, []string{"--format", "x509", "--title", "t", "--email", "a@b.c", "-"}, "use ssh or gpg"}, | |
| 118 | + | "empty input": {" \n", []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, "no key to read"}, | |
| 119 | + | "too large": {big, []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, "larger than 64 KiB"}, | |
| 120 | + | "no such file": {"", []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "/nonexistent/key.pub"}, "read /nonexistent/key.pub"}, | |
| 121 | + | } { | |
| 122 | + | f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho} | |
| 123 | + | res := runCLI(t, envFor(f.serve(t)), tt.stdin, append([]string{"signing-key", "trust", "add"}, tt.args...)...) | |
| 124 | + | require.Error(t, res.err, name) | |
| 125 | + | assert.Contains(t, res.err.Error(), tt.want, name) | |
| 126 | + | calls, _ := f.snapshot() | |
| 127 | + | assert.NotContains(t, calls, "AddTrustedKey", name) | |
| 128 | + | } | |
| 129 | + | }) | |
| 130 | + | ||
| 131 | + | t.Run("server errors are described", func(t *testing.T) { | |
| 132 | + | for name, tt := range map[string]struct { | |
| 133 | + | err error | |
| 134 | + | want string | |
| 135 | + | }{ | |
| 136 | + | "not the owner": {connect.NewError(connect.CodePermissionDenied, errors.New("owner only")), "permission denied"}, | |
| 137 | + | "duplicate": {connect.NewError(connect.CodeAlreadyExists, errors.New("key is already trusted")), "already exists"}, | |
| 138 | + | "invalid": {connect.NewError(connect.CodeInvalidArgument, errors.New("not a public key")), "not a public key"}, | |
| 139 | + | } { | |
| 140 | + | f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) { return nil, tt.err }} | |
| 141 | + | res := runCLI(t, envFor(f.serve(t)), testSSHKey, "signing-key", "trust", "add", "--format", "ssh", "--title", "t", "--email", "a@b.c", "-") | |
| 142 | + | require.Error(t, res.err, name) | |
| 143 | + | assert.Contains(t, res.err.Error(), tt.want, name) | |
| 144 | + | assert.Empty(t, res.stdout, name) | |
| 145 | + | } | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestSigningKeyTrustRemove(t *testing.T) { | |
| 150 | + | f := &fakeAPI{getVersion: withCommitSignatures, removeTrustedKey: func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) { | |
| 151 | + | return &ermite.RemoveTrustedKeyResponse{}, nil | |
| 152 | + | }} | |
| 153 | + | res := runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove", "SHA256:abc").ok(t) | |
| 154 | + | assert.Equal(t, "SHA256:abc", lastRequest[ermite.RemoveTrustedKeyRequest](t, f, "RemoveTrustedKey").GetId()) | |
| 155 | + | assert.Empty(t, res.stdout) | |
| 156 | + | assert.Contains(t, res.stderr, "removed trusted key SHA256:abc") | |
| 157 | + | ||
| 158 | + | f = &fakeAPI{getVersion: withCommitSignatures, removeTrustedKey: func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) { | |
| 159 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the builtin key cannot be removed")) | |
| 160 | + | }} | |
| 161 | + | res = runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove", "SHA256:builtin") | |
| 162 | + | require.Error(t, res.err) | |
| 163 | + | assert.Contains(t, res.err.Error(), "builtin key cannot be removed") | |
| 164 | + | ||
| 165 | + | require.Error(t, runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove").err, "the id is required") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestSigningKeyTrustNeedsServerFeature(t *testing.T) { | |
| 169 | + | for _, tt := range []struct { | |
| 170 | + | name string | |
| 171 | + | version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | |
| 172 | + | want string | |
| 173 | + | }{ | |
| 174 | + | {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 175 | + | return &ermite.GetVersionResponse{Version: "v0.6.0", Features: []string{"signing_key"}}, nil | |
| 176 | + | }, "does not support commit_signatures"}, | |
| 177 | + | {"version unreadable", nil, "does not implement this call"}, | |
| 178 | + | {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 179 | + | return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused")) | |
| 180 | + | }, "cannot reach the server"}, | |
| 181 | + | } { | |
| 182 | + | for _, args := range [][]string{ | |
| 183 | + | {"list"}, | |
| 184 | + | {"add", "--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, | |
| 185 | + | {"remove", "SHA256:abc"}, | |
| 186 | + | } { | |
| 187 | + | f := &fakeAPI{getVersion: tt.version} | |
| 188 | + | res := runCLI(t, envFor(f.serve(t)), testSSHKey, append([]string{"signing-key", "trust"}, args...)...) | |
| 189 | + | require.Error(t, res.err, tt.name) | |
| 190 | + | assert.Contains(t, strings.ToLower(res.err.Error()), tt.want, tt.name) | |
| 191 | + | calls, _ := f.snapshot() | |
| 192 | + | assert.Equal(t, []string{"GetVersion"}, calls, "%s %v: no key call", tt.name, args) | |
| 193 | + | assert.Empty(t, res.stdout) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestSigningKeyHelpMentionsTrust(t *testing.T) { | |
| 199 | + | res := runCLI(t, nil, "", "signing-key", "--help").ok(t) | |
| 200 | + | assert.Contains(t, res.stdout, "trust") | |
| 201 | + | assert.Contains(t, res.stdout, "commit_signatures") | |
| 202 | + | res = runCLI(t, nil, "", "signing-key", "trust", "list", "--help").ok(t) | |
| 203 | + | assert.Contains(t, res.stdout, "JSON fields") | |
| 204 | + | } | |
M internal/app/cli_version.go +1 -0
| @@ -122,4 +122,5 @@ var featureUse = map[string]string{ | |||
| 122 | 122 | client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server", | |
| 123 | 123 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | |
| 124 | 124 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 125 | + | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | |
| 125 | 126 | } | |
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // allFeatures are the features this client uses. | |
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "passkeys"} | |
| 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, passkeys\n", res.stdout) | |
| 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "passkeys") | |
| 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
M internal/app/forge.go +34 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "net" | |
| 13 | 13 | "os" | |
| 14 | 14 | "path/filepath" | |
| 15 | + | "strings" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/zerowrap" | |
| @@ -31,6 +32,7 @@ import ( | |||
| 31 | 32 | "bnema.dev/ermite/internal/adapters/oidchttp" | |
| 32 | 33 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 33 | 34 | "bnema.dev/ermite/internal/adapters/secretbox" | |
| 35 | + | "bnema.dev/ermite/internal/adapters/sigverify" | |
| 34 | 36 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 35 | 37 | "bnema.dev/ermite/internal/adapters/web" | |
| 36 | 38 | "bnema.dev/ermite/internal/adapters/webauthn" | |
| @@ -114,7 +116,10 @@ type forge struct { | |||
| 114 | 116 | // ServerService.GetSigningKey. | |
| 115 | 117 | MergeRequests *usecase.MergeRequests | |
| 116 | 118 | SigningKey *usecase.SigningKey | |
| 117 | - | Sessions *usecase.Sessions | |
| 119 | + | // Signatures are the trusted keys and the commit signature checks | |
| 120 | + | // (ADR 0030). | |
| 121 | + | Signatures *usecase.Signatures | |
| 122 | + | Sessions *usecase.Sessions | |
| 118 | 123 | // Passkeys is nil when passkeys are not available (passkeysAvailable). | |
| 119 | 124 | Passkeys *usecase.Passkeys | |
| 120 | 125 | EventLog *usecase.Events | |
| @@ -301,6 +306,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 301 | 306 | return nil, err | |
| 302 | 307 | } | |
| 303 | 308 | f.SigningKey = usecase.NewSigningKey(signer, committer) | |
| 309 | + | if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil { | |
| 310 | + | return nil, err | |
| 311 | + | } | |
| 304 | 312 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 305 | 313 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 306 | 314 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| @@ -353,6 +361,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 353 | 361 | Events: f.EventLog, | |
| 354 | 362 | MergeRequests: f.MergeRequests, | |
| 355 | 363 | SigningKey: f.SigningKey, | |
| 364 | + | TrustedKeys: f.Signatures, | |
| 356 | 365 | Sessions: f.Sessions, | |
| 357 | 366 | Passkeys: apiPasskeys, | |
| 358 | 367 | Runner: f.CI, | |
| @@ -501,3 +510,27 @@ func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *g | |||
| 501 | 510 | cfg.Fresh = f.reader // a nil *ermitegit.Adapter must not become a non-nil interface | |
| 502 | 511 | } | |
| 503 | 512 | } | |
| 513 | + | ||
| 514 | + | // signatures wires the commit signature checks (ADR 0030): the stored trusted | |
| 515 | + | // keys, the instance key as a builtin one for the committer email, the SSH and | |
| 516 | + | // OpenPGP verifiers, and the raw commit objects of the selected reader. | |
| 517 | + | func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key domain.SigningPublicKey, committer domain.Identity, clock ports.Clock) error { | |
| 518 | + | objects, ok := reader.(ports.CommitObjectReader) | |
| 519 | + | if !ok { | |
| 520 | + | return errors.New("the git reader cannot read raw commit objects") | |
| 521 | + | } | |
| 522 | + | builtin := domain.TrustedKey{ | |
| 523 | + | ID: key.Fingerprint, Format: domain.FormatSSH, Title: "ermite instance key", | |
| 524 | + | PublicKey: key.AuthorizedKey, Emails: []string{strings.ToLower(committer.Email)}, Builtin: true, | |
| 525 | + | } | |
| 526 | + | verifiers := map[domain.SignatureFormat]ports.SignatureVerifier{ | |
| 527 | + | domain.FormatSSH: sigverify.SSH{}, domain.FormatGPG: sigverify.GPG{}, | |
| 528 | + | } | |
| 529 | + | sigs, err := usecase.NewSignatures(store.TrustedKeys(), sigverify.Inspector{}, verifiers, objects, builtin, clock) | |
| 530 | + | if err != nil { | |
| 531 | + | return err | |
| 532 | + | } | |
| 533 | + | f.Signatures = sigs | |
| 534 | + | f.Browse.WithVerifier(sigs) | |
| 535 | + | return nil | |
| 536 | + | } | |
A internal/app/signatures_e2e_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func needTool(t *testing.T, name string) { | |
| 23 | + | t.Helper() | |
| 24 | + | if _, err := exec.LookPath(name); err != nil { | |
| 25 | + | if os.Getenv("CI") != "" { | |
| 26 | + | t.Fatalf("%s is needed", name) | |
| 27 | + | } | |
| 28 | + | t.Skipf("%s not installed", name) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // ADR 0030 end to end: commits pushed signed with SSH and OpenPGP keys show | |
| 33 | + | // their protocol and state over the API and on the web, and trusting a key | |
| 34 | + | // turns them verified. | |
| 35 | + | func TestCommitSignaturesEndToEnd(t *testing.T) { | |
| 36 | + | needTool(t, "ssh-keygen") | |
| 37 | + | needTool(t, "gpg") | |
| 38 | + | h := newHarness(t, harnessOpts{}) | |
| 39 | + | ctx := h.ctx | |
| 40 | + | h.seed("sig", domain.VisibilityPublic) | |
| 41 | + | wt := h.workTree("main") | |
| 42 | + | keys := t.TempDir() | |
| 43 | + | ||
| 44 | + | // SSH key and commit. | |
| 45 | + | sshKey := filepath.Join(keys, "id") | |
| 46 | + | out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "c", "-f", sshKey).CombinedOutput() | |
| 47 | + | require.NoError(t, err, string(out)) | |
| 48 | + | h.writeFile(wt, "a.txt", []byte("a\n")) | |
| 49 | + | h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit") | |
| 50 | + | sshCommit := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 51 | + | ||
| 52 | + | // OpenPGP key and commit. | |
| 53 | + | // A short home: the agent socket path must fit sun_path. | |
| 54 | + | gnupg, err := os.MkdirTemp("", "gpg") | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | t.Cleanup(func() { | |
| 57 | + | _ = exec.Command("gpgconf", "--homedir", gnupg, "--kill", "all").Run() | |
| 58 | + | _ = os.RemoveAll(gnupg) | |
| 59 | + | }) | |
| 60 | + | gpg := func(args ...string) string { | |
| 61 | + | cmd := exec.Command("gpg", append([]string{"--batch", "--homedir", gnupg, "--pinentry-mode", "loopback", "--passphrase", ""}, args...)...) | |
| 62 | + | b, err := cmd.CombinedOutput() | |
| 63 | + | require.NoError(t, err, string(b)) | |
| 64 | + | return string(b) | |
| 65 | + | } | |
| 66 | + | gpg("--quick-gen-key", "T <t@example.test>", "ed25519", "sign", "never") | |
| 67 | + | wrapper := filepath.Join(keys, "gpg.sh") | |
| 68 | + | require.NoError(t, os.WriteFile(wrapper, []byte("#!/bin/sh\nexec gpg --homedir '"+gnupg+"' --batch --pinentry-mode loopback --passphrase '' \"$@\"\n"), 0o700)) | |
| 69 | + | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 70 | + | h.mustGit(wt, "-c", "gpg.program="+wrapper, "-c", "user.signingkey=t@example.test", "commit", "-q", "--amend", "-S", "--no-edit") | |
| 71 | + | gpgCommit := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 72 | + | h.writeFile(wt, "c.txt", []byte("c\n")) | |
| 73 | + | unsigned := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 74 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "main") | |
| 75 | + | ||
| 76 | + | root := h.api(adminToken) | |
| 77 | + | trust := ermiteconnect.NewTrustedKeyServiceClient(&http.Client{Transport: bearerTransport{secret: adminToken, next: http.DefaultTransport}}, h.url("/-/api")) | |
| 78 | + | states := func() map[string]*pb.CommitVerification { | |
| 79 | + | res, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "sig"})) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | m := map[string]*pb.CommitVerification{} | |
| 82 | + | for _, c := range res.Msg.GetCommits() { | |
| 83 | + | m[c.GetId()] = c.GetVerification() | |
| 84 | + | } | |
| 85 | + | return m | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | v, err := root.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | assert.Contains(t, v.Msg.GetFeatures(), "commit_signatures") | |
| 91 | + | ||
| 92 | + | s := states() | |
| 93 | + | assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, s[sshCommit].GetFormat()) | |
| 94 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, s[sshCommit].GetStatus()) | |
| 95 | + | assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_GPG, s[gpgCommit].GetFormat()) | |
| 96 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, s[gpgCommit].GetStatus()) | |
| 97 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED, s[unsigned].GetStatus()) | |
| 98 | + | ||
| 99 | + | // Trust both keys. | |
| 100 | + | pub, err := os.ReadFile(sshKey + ".pub") | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | _, err = trust.AddTrustedKey(ctx, connect.NewRequest(&pb.AddTrustedKeyRequest{ | |
| 103 | + | Format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, Title: "laptop", PublicKey: string(pub), Emails: []string{"T@example.test"}, | |
| 104 | + | })) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | _, err = trust.AddTrustedKey(ctx, connect.NewRequest(&pb.AddTrustedKeyRequest{ | |
| 107 | + | Format: pb.SignatureFormat_SIGNATURE_FORMAT_GPG, Title: "gpg", PublicKey: gpg("--armor", "--export", "t@example.test"), Emails: []string{"t@example.test"}, | |
| 108 | + | })) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | ||
| 111 | + | s = states() | |
| 112 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, s[sshCommit].GetStatus()) | |
| 113 | + | assert.Equal(t, "laptop", s[sshCommit].GetKeyTitle()) | |
| 114 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, s[gpgCommit].GetStatus()) | |
| 115 | + | ||
| 116 | + | list, err := trust.ListTrustedKeys(ctx, connect.NewRequest(&pb.ListTrustedKeysRequest{})) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Len(t, list.Msg.GetKeys(), 3) | |
| 119 | + | assert.True(t, list.Msg.GetKeys()[0].GetBuiltin(), "the instance key comes first") | |
| 120 | + | ||
| 121 | + | // A token without owner rights cannot change the list. | |
| 122 | + | weak := ermiteconnect.NewTrustedKeyServiceClient(&http.Client{Transport: bearerTransport{ | |
| 123 | + | secret: h.token(scopes(domain.ScopeRepoRead)), next: http.DefaultTransport, | |
| 124 | + | }}, h.url("/-/api")) | |
| 125 | + | _, err = weak.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()})) | |
| 126 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 127 | + | ||
| 128 | + | // The web shows the protocol and the state. | |
| 129 | + | code, _, body := h.get("/sig/commit/" + sshCommit) | |
| 130 | + | require.Equal(t, http.StatusOK, code) | |
| 131 | + | assert.Contains(t, body, "sig-ok") | |
| 132 | + | assert.Contains(t, body, "SSH") | |
| 133 | + | code, _, body = h.get("/sig/commit/" + gpgCommit) | |
| 134 | + | require.Equal(t, http.StatusOK, code) | |
| 135 | + | assert.Contains(t, body, "GPG") | |
| 136 | + | assert.Contains(t, body, "sig-ok") | |
| 137 | + | ||
| 138 | + | // Removing the key turns the commit unknown again. | |
| 139 | + | _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()})) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, states()[sshCommit].GetStatus()) | |
| 142 | + | } | |
M internal/app/testdata/web/commit-big.html +4 -0
| @@ -109,6 +109,10 @@ | |||
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 111 | 111 | </dd> | |
| 112 | + | <dt>signature</dt> | |
| 113 | + | <dd> | |
| 114 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 115 | + | </dd> | |
| 112 | 116 | <dt>refs</dt> | |
| 113 | 117 | <dd> | |
| 114 | 118 | <span class="pill">main</span> | |
M internal/app/testdata/web/commit-binary.html +4 -0
| @@ -109,6 +109,10 @@ | |||
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 111 | 111 | </dd> | |
| 112 | + | <dt>signature</dt> | |
| 113 | + | <dd> | |
| 114 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 115 | + | </dd> | |
| 112 | 116 | </dl> | |
| 113 | 117 | </header> | |
| 114 | 118 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-merge.html +4 -0
| @@ -108,6 +108,10 @@ | |||
| 108 | 108 | <dt>merge of</dt> | |
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> + <a href="/diffs/commit/SHA40">SHA7</a> · diff against the first parent</dd> | |
| 111 | + | <dt>signature</dt> | |
| 112 | + | <dd> | |
| 113 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 114 | + | </dd> | |
| 111 | 115 | </dl> | |
| 112 | 116 | </header> | |
| 113 | 117 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-modify-split.html +4 -0
| @@ -111,6 +111,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 111 | 111 | <dd> | |
| 112 | 112 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 113 | 113 | </dd> | |
| 114 | + | <dt>signature</dt> | |
| 115 | + | <dd> | |
| 116 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 117 | + | </dd> | |
| 114 | 118 | <dt>refs</dt> | |
| 115 | 119 | <dd> | |
| 116 | 120 | <span class="pill">tag: v1.0.0</span> | |
M internal/app/testdata/web/commit-modify.html +4 -0
| @@ -111,6 +111,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 111 | 111 | <dd> | |
| 112 | 112 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 113 | 113 | </dd> | |
| 114 | + | <dt>signature</dt> | |
| 115 | + | <dd> | |
| 116 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 117 | + | </dd> | |
| 114 | 118 | <dt>refs</dt> | |
| 115 | 119 | <dd> | |
| 116 | 120 | <span class="pill">tag: v1.0.0</span> | |
M internal/app/testdata/web/commit-rename.html +4 -0
| @@ -109,6 +109,10 @@ | |||
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 111 | 111 | </dd> | |
| 112 | + | <dt>signature</dt> | |
| 113 | + | <dd> | |
| 114 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 115 | + | </dd> | |
| 112 | 116 | </dl> | |
| 113 | 117 | </header> | |
| 114 | 118 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-root.html +4 -0
| @@ -107,6 +107,10 @@ | |||
| 107 | 107 | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 108 | 108 | <dt>parent</dt> | |
| 109 | 109 | <dd>none (root commit) </dd> | |
| 110 | + | <dt>signature</dt> | |
| 111 | + | <dd> | |
| 112 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 113 | + | </dd> | |
| 110 | 114 | </dl> | |
| 111 | 115 | </header> | |
| 112 | 116 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-trojan.html +4 -0
| @@ -110,6 +110,10 @@ | |||
| 110 | 110 | <dd> | |
| 111 | 111 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 112 | 112 | </dd> | |
| 113 | + | <dt>signature</dt> | |
| 114 | + | <dd> | |
| 115 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 116 | + | </dd> | |
| 113 | 117 | </dl> | |
| 114 | 118 | </header> | |
| 115 | 119 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/domain/git.go +3 -0
| @@ -442,6 +442,9 @@ type Commit struct { | |||
| 442 | 442 | // Message is the whole message without trailing line breaks, converted to | |
| 443 | 443 | // valid UTF-8. | |
| 444 | 444 | Message string | |
| 445 | + | // Verification is the signature state (ADR 0030). The readers leave it | |
| 446 | + | // zero (unchecked); the browse use case fills it. | |
| 447 | + | Verification CommitVerification | |
| 445 | 448 | } | |
| 446 | 449 | ||
| 447 | 450 | // FileStatus tells what a commit did to a file. | |
A internal/domain/signature.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "fmt" | |
| 8 | + | "net/mail" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // SignatureFormat is the protocol of a commit signature (ADR 0030). | |
| 15 | + | type SignatureFormat string | |
| 16 | + | ||
| 17 | + | // Signature formats. FormatNone is an unsigned commit. | |
| 18 | + | const ( | |
| 19 | + | FormatNone SignatureFormat = "" | |
| 20 | + | FormatSSH SignatureFormat = "ssh" | |
| 21 | + | FormatGPG SignatureFormat = "gpg" | |
| 22 | + | // FormatX509 is a gpgsm (S/MIME) signature: recognized, never verified. | |
| 23 | + | FormatX509 SignatureFormat = "x509" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // ParseSignatureFormat parses the text form of a key format, "ssh" or "gpg". | |
| 27 | + | // x509 keys cannot be trusted. | |
| 28 | + | func ParseSignatureFormat(s string) (SignatureFormat, error) { | |
| 29 | + | switch f := SignatureFormat(strings.ToLower(strings.TrimSpace(s))); f { | |
| 30 | + | case FormatSSH, FormatGPG: | |
| 31 | + | return f, nil | |
| 32 | + | } | |
| 33 | + | return FormatNone, fmt.Errorf("%w: key format %q, want ssh or gpg", ErrInvalid, s) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SignatureStatus is the outcome of checking a commit signature. | |
| 37 | + | type SignatureStatus string | |
| 38 | + | ||
| 39 | + | // Signature statuses. StatusUnchecked is the zero value: the commit was not | |
| 40 | + | // checked (or could not be read); it is never cached. | |
| 41 | + | const ( | |
| 42 | + | StatusUnchecked SignatureStatus = "" | |
| 43 | + | // StatusUnsigned: the commit carries no signature. | |
| 44 | + | StatusUnsigned SignatureStatus = "unsigned" | |
| 45 | + | // StatusVerified: valid signature by a trusted key whose emails include | |
| 46 | + | // the committer email. | |
| 47 | + | StatusVerified SignatureStatus = "verified" | |
| 48 | + | // StatusUnknownKey: well-formed signature by a key that is not trusted. | |
| 49 | + | StatusUnknownKey SignatureStatus = "unknown_key" | |
| 50 | + | // StatusBadSignature: malformed, or does not match the commit. | |
| 51 | + | StatusBadSignature SignatureStatus = "bad_signature" | |
| 52 | + | // StatusEmailMismatch: valid signature by a trusted key that is not | |
| 53 | + | // trusted for the committer email. | |
| 54 | + | StatusEmailMismatch SignatureStatus = "email_mismatch" | |
| 55 | + | // StatusExpired: the GPG key or signature had expired. | |
| 56 | + | StatusExpired SignatureStatus = "expired" | |
| 57 | + | // StatusRevoked: the GPG key is revoked. | |
| 58 | + | StatusRevoked SignatureStatus = "revoked" | |
| 59 | + | // StatusUnsupported: a format ermite does not verify (x509). | |
| 60 | + | StatusUnsupported SignatureStatus = "unsupported" | |
| 61 | + | ) | |
| 62 | + | ||
| 63 | + | // CommitVerification is what ermite knows about the signature of a commit. | |
| 64 | + | type CommitVerification struct { | |
| 65 | + | Format SignatureFormat | |
| 66 | + | Status SignatureStatus | |
| 67 | + | // Fingerprint is the fingerprint of the signing key when it could be | |
| 68 | + | // read: "SHA256:<base64>" for SSH, 40 upper-case hex digits (or the | |
| 69 | + | // 16-digit issuer key id when the key is unknown) for GPG. | |
| 70 | + | Fingerprint string | |
| 71 | + | // KeyID is the ID of the matching trusted key; empty when none matched. | |
| 72 | + | KeyID string | |
| 73 | + | // KeyTitle is the title of the matching trusted key. | |
| 74 | + | KeyTitle string | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Signed reports whether the commit carries a signature. | |
| 78 | + | func (v CommitVerification) Signed() bool { return v.Format != FormatNone } | |
| 79 | + | ||
| 80 | + | // Limits of trusted keys. | |
| 81 | + | const ( | |
| 82 | + | MaxTrustedKeys = 100 | |
| 83 | + | MaxTrustedKeyEmails = 10 | |
| 84 | + | MaxTrustedKeyTitleLen = 100 | |
| 85 | + | MaxTrustedKeyBytes = 64 << 10 | |
| 86 | + | MaxCommitObjectBytes = 1 << 20 | |
| 87 | + | // MaxCommitObjectsBytes bounds the total size of the commit objects one | |
| 88 | + | // ReadCommitObjects call returns. | |
| 89 | + | MaxCommitObjectsBytes = 8 << 20 | |
| 90 | + | maxTrustedKeyEmailSize = 254 | |
| 91 | + | ) | |
| 92 | + | ||
| 93 | + | // TrustedKey is a public key whose signatures ermite accepts for the listed | |
| 94 | + | // committer emails (ADR 0030). | |
| 95 | + | type TrustedKey struct { | |
| 96 | + | // ID is the key fingerprint: "SHA256:<base64>" for SSH, the primary key | |
| 97 | + | // fingerprint in upper-case hex for GPG. | |
| 98 | + | ID string | |
| 99 | + | Format SignatureFormat | |
| 100 | + | Title string | |
| 101 | + | // PublicKey is the key as given: an authorized_keys line for SSH, an | |
| 102 | + | // armored public key block for GPG. | |
| 103 | + | PublicKey string | |
| 104 | + | // Emails are the committer emails the key may sign for, lower-case. | |
| 105 | + | Emails []string | |
| 106 | + | CreatedAt time.Time | |
| 107 | + | // Builtin marks the instance signing key (ADR 0022): listed, never stored | |
| 108 | + | // nor deletable. | |
| 109 | + | Builtin bool | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // TrustsEmail reports whether the key may sign for the email (case-insensitive). | |
| 113 | + | func (k TrustedKey) TrustsEmail(email string) bool { | |
| 114 | + | return slices.Contains(k.Emails, strings.ToLower(strings.TrimSpace(email))) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // ParseTrustedKeyTitle checks a key title: 1 to MaxTrustedKeyTitleLen bytes | |
| 118 | + | // once trimmed, no control characters. | |
| 119 | + | func ParseTrustedKeyTitle(s string) (string, error) { | |
| 120 | + | s = strings.TrimSpace(s) | |
| 121 | + | if s == "" || len(s) > MaxTrustedKeyTitleLen || strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) { | |
| 122 | + | return "", fmt.Errorf("%w: key title must be 1 to %d printable bytes", ErrInvalid, MaxTrustedKeyTitleLen) | |
| 123 | + | } | |
| 124 | + | return s, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // NormalizeKeyEmails lower-cases, trims, de-duplicates and checks emails: | |
| 128 | + | // 1 to MaxTrustedKeyEmails plain addresses ("a@b", no display name). | |
| 129 | + | func NormalizeKeyEmails(in []string) ([]string, error) { | |
| 130 | + | out := make([]string, 0, len(in)) | |
| 131 | + | for _, e := range in { | |
| 132 | + | e = strings.ToLower(strings.TrimSpace(e)) | |
| 133 | + | a, err := mail.ParseAddress(e) | |
| 134 | + | if err != nil || a.Address != e || len(e) > maxTrustedKeyEmailSize { | |
| 135 | + | return nil, fmt.Errorf("%w: email %q", ErrInvalid, e) | |
| 136 | + | } | |
| 137 | + | if !slices.Contains(out, e) { | |
| 138 | + | out = append(out, e) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | if len(out) == 0 || len(out) > MaxTrustedKeyEmails { | |
| 142 | + | return nil, fmt.Errorf("%w: a key needs 1 to %d emails", ErrInvalid, MaxTrustedKeyEmails) | |
| 143 | + | } | |
| 144 | + | return out, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // SplitCommitSignature splits the raw content of a commit object (as stored, | |
| 148 | + | // without the "commit <size>\0" prefix) into the payload that was signed and | |
| 149 | + | // the signature, exactly as `git verify-commit` does for a SHA-1 repository | |
| 150 | + | // (checked against git 2.56 with a fake gpg.program): | |
| 151 | + | // | |
| 152 | + | // - in the header section only (up to the first empty line, so a message | |
| 153 | + | // cannot inject a header), every header whose name starts with "gpgsig" | |
| 154 | + | // (gpgsig, gpgsig-sha256, ...) is removed from the payload with its | |
| 155 | + | // continuation lines (lines that start with a space); | |
| 156 | + | // - the signature is the value of the "gpgsig " headers (name, one space, | |
| 157 | + | // value), with the leading space of each continuation line removed. | |
| 158 | + | // Several gpgsig headers are concatenated; their order and their line | |
| 159 | + | // endings are kept, and no line break is added; | |
| 160 | + | // - gpgsig-sha256 is the signature of SHA-256 repositories: it is never | |
| 161 | + | // the signature here, so a commit that only has it is unsigned (ok false). | |
| 162 | + | // | |
| 163 | + | // ok is false for an unsigned commit. | |
| 164 | + | func SplitCommitSignature(raw []byte) (payload, signature []byte, ok bool) { | |
| 165 | + | const ( | |
| 166 | + | prefix = "gpgsig" | |
| 167 | + | header = "gpgsig " | |
| 168 | + | ) | |
| 169 | + | var pay, sig bytes.Buffer | |
| 170 | + | pay.Grow(len(raw)) | |
| 171 | + | // inSig: the last header is a "gpgsig " one; skipping: the last header is | |
| 172 | + | // any gpgsig* one, so its continuation lines are removed from the payload. | |
| 173 | + | inSig, skipping, found := false, false, false | |
| 174 | + | rest := raw | |
| 175 | + | for len(rest) > 0 { | |
| 176 | + | i := bytes.IndexByte(rest, '\n') | |
| 177 | + | var line []byte | |
| 178 | + | if i < 0 { | |
| 179 | + | line, rest = rest, nil | |
| 180 | + | } else { | |
| 181 | + | line, rest = rest[:i+1], rest[i+1:] | |
| 182 | + | } | |
| 183 | + | if len(line) == 1 && line[0] == '\n' { // end of headers | |
| 184 | + | pay.Write(line) | |
| 185 | + | pay.Write(rest) | |
| 186 | + | break | |
| 187 | + | } | |
| 188 | + | switch { | |
| 189 | + | case skipping && line[0] == ' ': | |
| 190 | + | if inSig { | |
| 191 | + | sig.Write(line[1:]) | |
| 192 | + | } | |
| 193 | + | case bytes.HasPrefix(line, []byte(prefix)): | |
| 194 | + | skipping = true | |
| 195 | + | inSig = bytes.HasPrefix(line, []byte(header)) | |
| 196 | + | if inSig { | |
| 197 | + | found = true | |
| 198 | + | sig.Write(line[len(header):]) | |
| 199 | + | } | |
| 200 | + | default: | |
| 201 | + | skipping, inSig = false, false | |
| 202 | + | pay.Write(line) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | if !found { | |
| 206 | + | return nil, nil, false | |
| 207 | + | } | |
| 208 | + | return pay.Bytes(), sig.Bytes(), true | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // SignatureFormatOf tells the format of an armored signature from its first | |
| 212 | + | // line; FormatNone when it is not recognized. | |
| 213 | + | func SignatureFormatOf(signature []byte) SignatureFormat { | |
| 214 | + | switch { | |
| 215 | + | case bytes.HasPrefix(signature, []byte("-----BEGIN SSH SIGNATURE-----")): | |
| 216 | + | return FormatSSH | |
| 217 | + | case bytes.HasPrefix(signature, []byte("-----BEGIN PGP SIGNATURE-----")): | |
| 218 | + | return FormatGPG | |
| 219 | + | case bytes.HasPrefix(signature, []byte("-----BEGIN SIGNED MESSAGE-----")): | |
| 220 | + | return FormatX509 | |
| 221 | + | } | |
| 222 | + | return FormatNone | |
| 223 | + | } | |
A internal/domain/signature_test.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // TestSplitCommitSignature pins SplitCommitSignature to what `git | |
| 14 | + | // verify-commit` hands to gpg.program in a SHA-1 repository: the cases were | |
| 15 | + | // captured with a fake gpg.program that dumps its stdin (the payload) and the | |
| 16 | + | // signature file (git 2.56, objects made with `git hash-object --literally`). | |
| 17 | + | func TestSplitCommitSignature(t *testing.T) { | |
| 18 | + | const ( | |
| 19 | + | hdr = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n" | |
| 20 | + | one = "-----BEGIN PGP SIGNATURE-----\n\nONE\n-----END PGP SIGNATURE-----\n" | |
| 21 | + | two = "-----BEGIN PGP SIGNATURE-----\n\nTWO\n-----END PGP SIGNATURE-----\n" | |
| 22 | + | ) | |
| 23 | + | // sigHdr renders a signature as a header: name, then each line, the | |
| 24 | + | // continuation lines starting with a space. | |
| 25 | + | sigHdr := func(name, body string) string { | |
| 26 | + | return name + " -----BEGIN PGP SIGNATURE-----\n \n " + body + "\n -----END PGP SIGNATURE-----\n" | |
| 27 | + | } | |
| 28 | + | tests := []struct { | |
| 29 | + | name string | |
| 30 | + | raw string | |
| 31 | + | wantPayload string | |
| 32 | + | wantSig string | |
| 33 | + | unsigned bool | |
| 34 | + | }{ | |
| 35 | + | {"single signature", hdr + sigHdr("gpgsig", "ONE") + "\nmsg\n", hdr + "\nmsg\n", one, false}, | |
| 36 | + | {"repeated gpgsig are concatenated, both removed", hdr + sigHdr("gpgsig", "ONE") + sigHdr("gpgsig", "TWO") + "\nmsg\n", hdr + "\nmsg\n", one + two, false}, | |
| 37 | + | {"gpgsig and gpgsig-sha256: the sha256 one is removed, not used", hdr + sigHdr("gpgsig", "ONE") + sigHdr("gpgsig-sha256", "TWO") + "\nmsg\n", hdr + "\nmsg\n", one, false}, | |
| 38 | + | {"gpgsig-sha256 first", hdr + sigHdr("gpgsig-sha256", "TWO") + sigHdr("gpgsig", "ONE") + "\nmsg\n", hdr + "\nmsg\n", one, false}, | |
| 39 | + | {"gpgsig-sha256 only is unsigned", hdr + sigHdr("gpgsig-sha256", "TWO") + "\nmsg\n", "", "", true}, | |
| 40 | + | {"gpgsig-looking line in the message is untouched", hdr + "\nmsg\n" + sigHdr("gpgsig", "MSG"), "", "", true}, | |
| 41 | + | {"gpgsig in the message of a signed commit is untouched", hdr + sigHdr("gpgsig", "ONE") + "\nmsg\n" + sigHdr("gpgsig", "MSG"), hdr + "\nmsg\n" + sigHdr("gpgsig", "MSG"), one, false}, | |
| 42 | + | {"CRLF in the signature is kept", hdr + "gpgsig -----BEGIN PGP SIGNATURE-----\r\n \r\n ONE\r\n -----END PGP SIGNATURE-----\r\n" + "\nmsg\n", hdr + "\nmsg\n", "-----BEGIN PGP SIGNATURE-----\r\n\r\nONE\r\n-----END PGP SIGNATURE-----\r\n", false}, | |
| 43 | + | {"CRLF message", hdr + sigHdr("gpgsig", "ONE") + "\r\nmsg\r\n", hdr + "\r\nmsg\r\n", one, false}, | |
| 44 | + | {"missing final newline of the message", hdr + sigHdr("gpgsig", "ONE") + "\nmsg", hdr + "\nmsg", one, false}, | |
| 45 | + | {"missing final newline of the signature: none is added", hdr + sigHdr("gpgsig", "ONE")[:len(sigHdr("gpgsig", "ONE"))-1], hdr, one[:len(one)-1], false}, | |
| 46 | + | {"other headers with continuation lines are kept", hdr + sigHdr("gpgsig", "ONE") + "other x\n cont\n\nmsg\n", hdr + "other x\n cont\n\nmsg\n", one, false}, | |
| 47 | + | {"gpgsig between other headers", "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" + sigHdr("gpgsig", "ONE") + "author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nmsg\n", hdr + "\nmsg\n", one, false}, | |
| 48 | + | {"headers that start with gpgsig are removed with their continuation lines", hdr + sigHdr("gpgsig", "ONE") + "gpgsigx foo\n c1\ngpgsig-foo bar\n\nmsg\n", hdr + "\nmsg\n", one, false}, | |
| 49 | + | {"a gpgsigx block does not extend the signature", hdr + "gpgsig A\n B\ngpgsigx foo\n C\ngpgsig D\n\nmsg\n", hdr + "\nmsg\n", "A\nB\nD\n", false}, | |
| 50 | + | {"a gpgsig-sha256 block between two gpgsig", hdr + sigHdr("gpgsig", "ONE") + "gpgsig-sha256 mid\n m1\n" + sigHdr("gpgsig", "TWO") + "\nmsg\n", hdr + "\nmsg\n", one + two, false}, | |
| 51 | + | {"a bare gpgsig line (no value) alone is unsigned", hdr + "gpgsig\n c1\n\nmsg\n", "", "", true}, | |
| 52 | + | {"a bare gpgsig line next to a signature is removed", hdr + sigHdr("gpgsig", "ONE") + "gpgsig\n c1\n\nmsg\n", hdr + "\nmsg\n", one, false}, | |
| 53 | + | {"header names are case-sensitive", hdr + sigHdr("gpgsig", "ONE") + "GPGSIG foo\n\nmsg\n", hdr + "GPGSIG foo\n\nmsg\n", one, false}, | |
| 54 | + | {"continuation line of a plain header is kept", hdr + sigHdr("gpgsig", "ONE") + "x y\n z\n\nmsg\n", hdr + "x y\n z\n\nmsg\n", one, false}, | |
| 55 | + | {"empty value", hdr + "gpgsig \n\nmsg\n", hdr + "\nmsg\n", "\n", false}, | |
| 56 | + | {"unsigned", hdr + "\nmsg\n", "", "", true}, | |
| 57 | + | {"empty", "", "", "", true}, | |
| 58 | + | } | |
| 59 | + | for _, tt := range tests { | |
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |
| 61 | + | payload, sig, ok := domain.SplitCommitSignature([]byte(tt.raw)) | |
| 62 | + | assert.Equal(t, !tt.unsigned, ok) | |
| 63 | + | if tt.unsigned { | |
| 64 | + | assert.Nil(t, payload) | |
| 65 | + | assert.Nil(t, sig) | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | assert.Equal(t, tt.wantPayload, string(payload)) | |
| 69 | + | assert.Equal(t, tt.wantSig, string(sig)) | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | } | |
A internal/ports/mocks/CommitObjectReader.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewCommitObjectReader creates a new instance of CommitObjectReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewCommitObjectReader(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *CommitObjectReader { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &CommitObjectReader{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // CommitObjectReader is an autogenerated mock type for the CommitObjectReader type | |
| 38 | + | type CommitObjectReader struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type CommitObjectReader_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *CommitObjectReader) EXPECT() *CommitObjectReader_Expecter { | |
| 47 | + | return &CommitObjectReader_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ReadCommitObjects provides a mock function for the type CommitObjectReader | |
| 51 | + | func (_mock *CommitObjectReader) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | |
| 52 | + | ret := _mock.Called(ctx, repo, ids) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for ReadCommitObjects") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 map[domain.ObjectID][]byte | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.ObjectID) (map[domain.ObjectID][]byte, error)); ok { | |
| 61 | + | return returnFunc(ctx, repo, ids) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.ObjectID) map[domain.ObjectID][]byte); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo, ids) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).(map[domain.ObjectID][]byte) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.ObjectID) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, repo, ids) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // CommitObjectReader_ReadCommitObjects_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadCommitObjects' | |
| 79 | + | type CommitObjectReader_ReadCommitObjects_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ReadCommitObjects is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - ids []domain.ObjectID | |
| 87 | + | func (_e *CommitObjectReader_Expecter) ReadCommitObjects(ctx any, repo any, ids any) *CommitObjectReader_ReadCommitObjects_Call { | |
| 88 | + | return &CommitObjectReader_ReadCommitObjects_Call{Call: _e.mock.On("ReadCommitObjects", ctx, repo, ids)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *CommitObjectReader_ReadCommitObjects_Call) Run(run func(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID)) *CommitObjectReader_ReadCommitObjects_Call { | |
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | + | var arg0 context.Context | |
| 94 | + | if args[0] != nil { | |
| 95 | + | arg0 = args[0].(context.Context) | |
| 96 | + | } | |
| 97 | + | var arg1 domain.RepoName | |
| 98 | + | if args[1] != nil { | |
| 99 | + | arg1 = args[1].(domain.RepoName) | |
| 100 | + | } | |
| 101 | + | var arg2 []domain.ObjectID | |
| 102 | + | if args[2] != nil { | |
| 103 | + | arg2 = args[2].([]domain.ObjectID) | |
| 104 | + | } | |
| 105 | + | run( | |
| 106 | + | arg0, | |
| 107 | + | arg1, | |
| 108 | + | arg2, | |
| 109 | + | ) | |
| 110 | + | }) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *CommitObjectReader_ReadCommitObjects_Call) Return(objectIDToBytes map[domain.ObjectID][]byte, err error) *CommitObjectReader_ReadCommitObjects_Call { | |
| 115 | + | _c.Call.Return(objectIDToBytes, err) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *CommitObjectReader_ReadCommitObjects_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error)) *CommitObjectReader_ReadCommitObjects_Call { | |
| 120 | + | _c.Call.Return(run) | |
| 121 | + | return _c | |
| 122 | + | } | |
A internal/ports/mocks/CommitVerifier.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewCommitVerifier creates a new instance of CommitVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewCommitVerifier(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *CommitVerifier { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &CommitVerifier{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // CommitVerifier is an autogenerated mock type for the CommitVerifier type | |
| 38 | + | type CommitVerifier struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type CommitVerifier_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *CommitVerifier) EXPECT() *CommitVerifier_Expecter { | |
| 47 | + | return &CommitVerifier_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // VerifyCommits provides a mock function for the type CommitVerifier | |
| 51 | + | func (_mock *CommitVerifier) VerifyCommits(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification { | |
| 52 | + | ret := _mock.Called(ctx, repo, commits) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for VerifyCommits") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 map[domain.ObjectID]domain.CommitVerification | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.Commit) map[domain.ObjectID]domain.CommitVerification); ok { | |
| 60 | + | r0 = returnFunc(ctx, repo, commits) | |
| 61 | + | } else { | |
| 62 | + | if ret.Get(0) != nil { | |
| 63 | + | r0 = ret.Get(0).(map[domain.ObjectID]domain.CommitVerification) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return r0 | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // CommitVerifier_VerifyCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyCommits' | |
| 70 | + | type CommitVerifier_VerifyCommits_Call struct { | |
| 71 | + | *mock.Call | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // VerifyCommits is a helper method to define mock.On call | |
| 75 | + | // - ctx context.Context | |
| 76 | + | // - repo domain.RepoName | |
| 77 | + | // - commits []domain.Commit | |
| 78 | + | func (_e *CommitVerifier_Expecter) VerifyCommits(ctx any, repo any, commits any) *CommitVerifier_VerifyCommits_Call { | |
| 79 | + | return &CommitVerifier_VerifyCommits_Call{Call: _e.mock.On("VerifyCommits", ctx, repo, commits)} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *CommitVerifier_VerifyCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, commits []domain.Commit)) *CommitVerifier_VerifyCommits_Call { | |
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | + | var arg0 context.Context | |
| 85 | + | if args[0] != nil { | |
| 86 | + | arg0 = args[0].(context.Context) | |
| 87 | + | } | |
| 88 | + | var arg1 domain.RepoName | |
| 89 | + | if args[1] != nil { | |
| 90 | + | arg1 = args[1].(domain.RepoName) | |
| 91 | + | } | |
| 92 | + | var arg2 []domain.Commit | |
| 93 | + | if args[2] != nil { | |
| 94 | + | arg2 = args[2].([]domain.Commit) | |
| 95 | + | } | |
| 96 | + | run( | |
| 97 | + | arg0, | |
| 98 | + | arg1, | |
| 99 | + | arg2, | |
| 100 | + | ) | |
| 101 | + | }) | |
| 102 | + | return _c | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (_c *CommitVerifier_VerifyCommits_Call) Return(objectIDToCommitVerification map[domain.ObjectID]domain.CommitVerification) *CommitVerifier_VerifyCommits_Call { | |
| 106 | + | _c.Call.Return(objectIDToCommitVerification) | |
| 107 | + | return _c | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (_c *CommitVerifier_VerifyCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification) *CommitVerifier_VerifyCommits_Call { | |
| 111 | + | _c.Call.Return(run) | |
| 112 | + | return _c | |
| 113 | + | } | |
A internal/ports/mocks/KeyInspector.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewKeyInspector creates a new instance of KeyInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewKeyInspector(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *KeyInspector { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &KeyInspector{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // KeyInspector is an autogenerated mock type for the KeyInspector type | |
| 36 | + | type KeyInspector struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type KeyInspector_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *KeyInspector) EXPECT() *KeyInspector_Expecter { | |
| 45 | + | return &KeyInspector_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Inspect provides a mock function for the type KeyInspector | |
| 49 | + | func (_mock *KeyInspector) Inspect(format domain.SignatureFormat, publicKey string) (string, string, error) { | |
| 50 | + | ret := _mock.Called(format, publicKey) | |
| 51 | + | ||
| 52 | + | if len(ret) == 0 { | |
| 53 | + | panic("no return value specified for Inspect") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var r0 string | |
| 57 | + | var r1 string | |
| 58 | + | var r2 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(domain.SignatureFormat, string) (string, string, error)); ok { | |
| 60 | + | return returnFunc(format, publicKey) | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(domain.SignatureFormat, string) string); ok { | |
| 63 | + | r0 = returnFunc(format, publicKey) | |
| 64 | + | } else { | |
| 65 | + | r0 = ret.Get(0).(string) | |
| 66 | + | } | |
| 67 | + | if returnFunc, ok := ret.Get(1).(func(domain.SignatureFormat, string) string); ok { | |
| 68 | + | r1 = returnFunc(format, publicKey) | |
| 69 | + | } else { | |
| 70 | + | r1 = ret.Get(1).(string) | |
| 71 | + | } | |
| 72 | + | if returnFunc, ok := ret.Get(2).(func(domain.SignatureFormat, string) error); ok { | |
| 73 | + | r2 = returnFunc(format, publicKey) | |
| 74 | + | } else { | |
| 75 | + | r2 = ret.Error(2) | |
| 76 | + | } | |
| 77 | + | return r0, r1, r2 | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // KeyInspector_Inspect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Inspect' | |
| 81 | + | type KeyInspector_Inspect_Call struct { | |
| 82 | + | *mock.Call | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Inspect is a helper method to define mock.On call | |
| 86 | + | // - format domain.SignatureFormat | |
| 87 | + | // - publicKey string | |
| 88 | + | func (_e *KeyInspector_Expecter) Inspect(format any, publicKey any) *KeyInspector_Inspect_Call { | |
| 89 | + | return &KeyInspector_Inspect_Call{Call: _e.mock.On("Inspect", format, publicKey)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *KeyInspector_Inspect_Call) Run(run func(format domain.SignatureFormat, publicKey string)) *KeyInspector_Inspect_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 domain.SignatureFormat | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(domain.SignatureFormat) | |
| 97 | + | } | |
| 98 | + | var arg1 string | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(string) | |
| 101 | + | } | |
| 102 | + | run( | |
| 103 | + | arg0, | |
| 104 | + | arg1, | |
| 105 | + | ) | |
| 106 | + | }) | |
| 107 | + | return _c | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (_c *KeyInspector_Inspect_Call) Return(id string, canonical string, err error) *KeyInspector_Inspect_Call { | |
| 111 | + | _c.Call.Return(id, canonical, err) | |
| 112 | + | return _c | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (_c *KeyInspector_Inspect_Call) RunAndReturn(run func(format domain.SignatureFormat, publicKey string) (string, string, error)) *KeyInspector_Inspect_Call { | |
| 116 | + | _c.Call.Return(run) | |
| 117 | + | return _c | |
| 118 | + | } | |
A internal/ports/mocks/SignatureVerifier.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewSignatureVerifier creates a new instance of SignatureVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewSignatureVerifier(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *SignatureVerifier { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &SignatureVerifier{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // SignatureVerifier is an autogenerated mock type for the SignatureVerifier type | |
| 36 | + | type SignatureVerifier struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type SignatureVerifier_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *SignatureVerifier) EXPECT() *SignatureVerifier_Expecter { | |
| 45 | + | return &SignatureVerifier_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Verify provides a mock function for the type SignatureVerifier | |
| 49 | + | func (_mock *SignatureVerifier) Verify(payload []byte, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification { | |
| 50 | + | ret := _mock.Called(payload, signature, committerEmail, signedAt, keys) | |
| 51 | + | ||
| 52 | + | if len(ret) == 0 { | |
| 53 | + | panic("no return value specified for Verify") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var r0 domain.CommitVerification | |
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte, string, int64, []domain.TrustedKey) domain.CommitVerification); ok { | |
| 58 | + | r0 = returnFunc(payload, signature, committerEmail, signedAt, keys) | |
| 59 | + | } else { | |
| 60 | + | r0 = ret.Get(0).(domain.CommitVerification) | |
| 61 | + | } | |
| 62 | + | return r0 | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // SignatureVerifier_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' | |
| 66 | + | type SignatureVerifier_Verify_Call struct { | |
| 67 | + | *mock.Call | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Verify is a helper method to define mock.On call | |
| 71 | + | // - payload []byte | |
| 72 | + | // - signature []byte | |
| 73 | + | // - committerEmail string | |
| 74 | + | // - signedAt int64 | |
| 75 | + | // - keys []domain.TrustedKey | |
| 76 | + | func (_e *SignatureVerifier_Expecter) Verify(payload any, signature any, committerEmail any, signedAt any, keys any) *SignatureVerifier_Verify_Call { | |
| 77 | + | return &SignatureVerifier_Verify_Call{Call: _e.mock.On("Verify", payload, signature, committerEmail, signedAt, keys)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (_c *SignatureVerifier_Verify_Call) Run(run func(payload []byte, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey)) *SignatureVerifier_Verify_Call { | |
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 82 | + | var arg0 []byte | |
| 83 | + | if args[0] != nil { | |
| 84 | + | arg0 = args[0].([]byte) | |
| 85 | + | } | |
| 86 | + | var arg1 []byte | |
| 87 | + | if args[1] != nil { | |
| 88 | + | arg1 = args[1].([]byte) | |
| 89 | + | } | |
| 90 | + | var arg2 string | |
| 91 | + | if args[2] != nil { | |
| 92 | + | arg2 = args[2].(string) | |
| 93 | + | } | |
| 94 | + | var arg3 int64 | |
| 95 | + | if args[3] != nil { | |
| 96 | + | arg3 = args[3].(int64) | |
| 97 | + | } | |
| 98 | + | var arg4 []domain.TrustedKey | |
| 99 | + | if args[4] != nil { | |
| 100 | + | arg4 = args[4].([]domain.TrustedKey) | |
| 101 | + | } | |
| 102 | + | run( | |
| 103 | + | arg0, | |
| 104 | + | arg1, | |
| 105 | + | arg2, | |
| 106 | + | arg3, | |
| 107 | + | arg4, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *SignatureVerifier_Verify_Call) Return(commitVerification domain.CommitVerification) *SignatureVerifier_Verify_Call { | |
| 114 | + | _c.Call.Return(commitVerification) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *SignatureVerifier_Verify_Call) RunAndReturn(run func(payload []byte, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification) *SignatureVerifier_Verify_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
A internal/ports/mocks/TrustedKeyService.go +269 -0
| @@ -0,0 +1,269 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewTrustedKeyService creates a new instance of TrustedKeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewTrustedKeyService(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *TrustedKeyService { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &TrustedKeyService{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // TrustedKeyService is an autogenerated mock type for the TrustedKeyService type | |
| 38 | + | type TrustedKeyService struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type TrustedKeyService_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *TrustedKeyService) EXPECT() *TrustedKeyService_Expecter { | |
| 47 | + | return &TrustedKeyService_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // AddTrustedKey provides a mock function for the type TrustedKeyService | |
| 51 | + | func (_mock *TrustedKeyService) AddTrustedKey(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title string, publicKey string, emails []string) (domain.TrustedKey, error) { | |
| 52 | + | ret := _mock.Called(ctx, p, format, title, publicKey, emails) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for AddTrustedKey") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.TrustedKey | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SignatureFormat, string, string, []string) (domain.TrustedKey, error)); ok { | |
| 61 | + | return returnFunc(ctx, p, format, title, publicKey, emails) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SignatureFormat, string, string, []string) domain.TrustedKey); ok { | |
| 64 | + | r0 = returnFunc(ctx, p, format, title, publicKey, emails) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.TrustedKey) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SignatureFormat, string, string, []string) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, p, format, title, publicKey, emails) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // TrustedKeyService_AddTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddTrustedKey' | |
| 77 | + | type TrustedKeyService_AddTrustedKey_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // AddTrustedKey is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - p domain.Principal | |
| 84 | + | // - format domain.SignatureFormat | |
| 85 | + | // - title string | |
| 86 | + | // - publicKey string | |
| 87 | + | // - emails []string | |
| 88 | + | func (_e *TrustedKeyService_Expecter) AddTrustedKey(ctx any, p any, format any, title any, publicKey any, emails any) *TrustedKeyService_AddTrustedKey_Call { | |
| 89 | + | return &TrustedKeyService_AddTrustedKey_Call{Call: _e.mock.On("AddTrustedKey", ctx, p, format, title, publicKey, emails)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *TrustedKeyService_AddTrustedKey_Call) Run(run func(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title string, publicKey string, emails []string)) *TrustedKeyService_AddTrustedKey_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.Principal | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.Principal) | |
| 101 | + | } | |
| 102 | + | var arg2 domain.SignatureFormat | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(domain.SignatureFormat) | |
| 105 | + | } | |
| 106 | + | var arg3 string | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(string) | |
| 109 | + | } | |
| 110 | + | var arg4 string | |
| 111 | + | if args[4] != nil { | |
| 112 | + | arg4 = args[4].(string) | |
| 113 | + | } | |
| 114 | + | var arg5 []string | |
| 115 | + | if args[5] != nil { | |
| 116 | + | arg5 = args[5].([]string) | |
| 117 | + | } | |
| 118 | + | run( | |
| 119 | + | arg0, | |
| 120 | + | arg1, | |
| 121 | + | arg2, | |
| 122 | + | arg3, | |
| 123 | + | arg4, | |
| 124 | + | arg5, | |
| 125 | + | ) | |
| 126 | + | }) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *TrustedKeyService_AddTrustedKey_Call) Return(trustedKey domain.TrustedKey, err error) *TrustedKeyService_AddTrustedKey_Call { | |
| 131 | + | _c.Call.Return(trustedKey, err) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (_c *TrustedKeyService_AddTrustedKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title string, publicKey string, emails []string) (domain.TrustedKey, error)) *TrustedKeyService_AddTrustedKey_Call { | |
| 136 | + | _c.Call.Return(run) | |
| 137 | + | return _c | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // ListTrustedKeys provides a mock function for the type TrustedKeyService | |
| 141 | + | func (_mock *TrustedKeyService) ListTrustedKeys(ctx context.Context, p domain.Principal) ([]domain.TrustedKey, error) { | |
| 142 | + | ret := _mock.Called(ctx, p) | |
| 143 | + | ||
| 144 | + | if len(ret) == 0 { | |
| 145 | + | panic("no return value specified for ListTrustedKeys") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | var r0 []domain.TrustedKey | |
| 149 | + | var r1 error | |
| 150 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.TrustedKey, error)); ok { | |
| 151 | + | return returnFunc(ctx, p) | |
| 152 | + | } | |
| 153 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.TrustedKey); ok { | |
| 154 | + | r0 = returnFunc(ctx, p) | |
| 155 | + | } else { | |
| 156 | + | if ret.Get(0) != nil { | |
| 157 | + | r0 = ret.Get(0).([]domain.TrustedKey) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok { | |
| 161 | + | r1 = returnFunc(ctx, p) | |
| 162 | + | } else { | |
| 163 | + | r1 = ret.Error(1) | |
| 164 | + | } | |
| 165 | + | return r0, r1 | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // TrustedKeyService_ListTrustedKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTrustedKeys' | |
| 169 | + | type TrustedKeyService_ListTrustedKeys_Call struct { | |
| 170 | + | *mock.Call | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // ListTrustedKeys is a helper method to define mock.On call | |
| 174 | + | // - ctx context.Context | |
| 175 | + | // - p domain.Principal | |
| 176 | + | func (_e *TrustedKeyService_Expecter) ListTrustedKeys(ctx any, p any) *TrustedKeyService_ListTrustedKeys_Call { | |
| 177 | + | return &TrustedKeyService_ListTrustedKeys_Call{Call: _e.mock.On("ListTrustedKeys", ctx, p)} | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (_c *TrustedKeyService_ListTrustedKeys_Call) Run(run func(ctx context.Context, p domain.Principal)) *TrustedKeyService_ListTrustedKeys_Call { | |
| 181 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 182 | + | var arg0 context.Context | |
| 183 | + | if args[0] != nil { | |
| 184 | + | arg0 = args[0].(context.Context) | |
| 185 | + | } | |
| 186 | + | var arg1 domain.Principal | |
| 187 | + | if args[1] != nil { | |
| 188 | + | arg1 = args[1].(domain.Principal) | |
| 189 | + | } | |
| 190 | + | run( | |
| 191 | + | arg0, | |
| 192 | + | arg1, | |
| 193 | + | ) | |
| 194 | + | }) | |
| 195 | + | return _c | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (_c *TrustedKeyService_ListTrustedKeys_Call) Return(trustedKeys []domain.TrustedKey, err error) *TrustedKeyService_ListTrustedKeys_Call { | |
| 199 | + | _c.Call.Return(trustedKeys, err) | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *TrustedKeyService_ListTrustedKeys_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.TrustedKey, error)) *TrustedKeyService_ListTrustedKeys_Call { | |
| 204 | + | _c.Call.Return(run) | |
| 205 | + | return _c | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // RemoveTrustedKey provides a mock function for the type TrustedKeyService | |
| 209 | + | func (_mock *TrustedKeyService) RemoveTrustedKey(ctx context.Context, p domain.Principal, id string) error { | |
| 210 | + | ret := _mock.Called(ctx, p, id) | |
| 211 | + | ||
| 212 | + | if len(ret) == 0 { | |
| 213 | + | panic("no return value specified for RemoveTrustedKey") | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | var r0 error | |
| 217 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) error); ok { | |
| 218 | + | r0 = returnFunc(ctx, p, id) | |
| 219 | + | } else { | |
| 220 | + | r0 = ret.Error(0) | |
| 221 | + | } | |
| 222 | + | return r0 | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // TrustedKeyService_RemoveTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTrustedKey' | |
| 226 | + | type TrustedKeyService_RemoveTrustedKey_Call struct { | |
| 227 | + | *mock.Call | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // RemoveTrustedKey is a helper method to define mock.On call | |
| 231 | + | // - ctx context.Context | |
| 232 | + | // - p domain.Principal | |
| 233 | + | // - id string | |
| 234 | + | func (_e *TrustedKeyService_Expecter) RemoveTrustedKey(ctx any, p any, id any) *TrustedKeyService_RemoveTrustedKey_Call { | |
| 235 | + | return &TrustedKeyService_RemoveTrustedKey_Call{Call: _e.mock.On("RemoveTrustedKey", ctx, p, id)} | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (_c *TrustedKeyService_RemoveTrustedKey_Call) Run(run func(ctx context.Context, p domain.Principal, id string)) *TrustedKeyService_RemoveTrustedKey_Call { | |
| 239 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 240 | + | var arg0 context.Context | |
| 241 | + | if args[0] != nil { | |
| 242 | + | arg0 = args[0].(context.Context) | |
| 243 | + | } | |
| 244 | + | var arg1 domain.Principal | |
| 245 | + | if args[1] != nil { | |
| 246 | + | arg1 = args[1].(domain.Principal) | |
| 247 | + | } | |
| 248 | + | var arg2 string | |
| 249 | + | if args[2] != nil { | |
| 250 | + | arg2 = args[2].(string) | |
| 251 | + | } | |
| 252 | + | run( | |
| 253 | + | arg0, | |
| 254 | + | arg1, | |
| 255 | + | arg2, | |
| 256 | + | ) | |
| 257 | + | }) | |
| 258 | + | return _c | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func (_c *TrustedKeyService_RemoveTrustedKey_Call) Return(err error) *TrustedKeyService_RemoveTrustedKey_Call { | |
| 262 | + | _c.Call.Return(err) | |
| 263 | + | return _c | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (_c *TrustedKeyService_RemoveTrustedKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id string) error) *TrustedKeyService_RemoveTrustedKey_Call { | |
| 267 | + | _c.Call.Return(run) | |
| 268 | + | return _c | |
| 269 | + | } | |
A internal/ports/mocks/TrustedKeyStore.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewTrustedKeyStore creates a new instance of TrustedKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewTrustedKeyStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *TrustedKeyStore { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &TrustedKeyStore{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // TrustedKeyStore is an autogenerated mock type for the TrustedKeyStore type | |
| 38 | + | type TrustedKeyStore struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type TrustedKeyStore_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *TrustedKeyStore) EXPECT() *TrustedKeyStore_Expecter { | |
| 47 | + | return &TrustedKeyStore_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // CreateTrustedKey provides a mock function for the type TrustedKeyStore | |
| 51 | + | func (_mock *TrustedKeyStore) CreateTrustedKey(ctx context.Context, k domain.TrustedKey) error { | |
| 52 | + | ret := _mock.Called(ctx, k) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for CreateTrustedKey") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TrustedKey) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, k) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // TrustedKeyStore_CreateTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTrustedKey' | |
| 68 | + | type TrustedKeyStore_CreateTrustedKey_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // CreateTrustedKey is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - k domain.TrustedKey | |
| 75 | + | func (_e *TrustedKeyStore_Expecter) CreateTrustedKey(ctx any, k any) *TrustedKeyStore_CreateTrustedKey_Call { | |
| 76 | + | return &TrustedKeyStore_CreateTrustedKey_Call{Call: _e.mock.On("CreateTrustedKey", ctx, k)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *TrustedKeyStore_CreateTrustedKey_Call) Run(run func(ctx context.Context, k domain.TrustedKey)) *TrustedKeyStore_CreateTrustedKey_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | var arg1 domain.TrustedKey | |
| 86 | + | if args[1] != nil { | |
| 87 | + | arg1 = args[1].(domain.TrustedKey) | |
| 88 | + | } | |
| 89 | + | run( | |
| 90 | + | arg0, | |
| 91 | + | arg1, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *TrustedKeyStore_CreateTrustedKey_Call) Return(err error) *TrustedKeyStore_CreateTrustedKey_Call { | |
| 98 | + | _c.Call.Return(err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *TrustedKeyStore_CreateTrustedKey_Call) RunAndReturn(run func(ctx context.Context, k domain.TrustedKey) error) *TrustedKeyStore_CreateTrustedKey_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // DeleteTrustedKey provides a mock function for the type TrustedKeyStore | |
| 108 | + | func (_mock *TrustedKeyStore) DeleteTrustedKey(ctx context.Context, id string) error { | |
| 109 | + | ret := _mock.Called(ctx, id) | |
| 110 | + | ||
| 111 | + | if len(ret) == 0 { | |
| 112 | + | panic("no return value specified for DeleteTrustedKey") | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | var r0 error | |
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 117 | + | r0 = returnFunc(ctx, id) | |
| 118 | + | } else { | |
| 119 | + | r0 = ret.Error(0) | |
| 120 | + | } | |
| 121 | + | return r0 | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // TrustedKeyStore_DeleteTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteTrustedKey' | |
| 125 | + | type TrustedKeyStore_DeleteTrustedKey_Call struct { | |
| 126 | + | *mock.Call | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // DeleteTrustedKey is a helper method to define mock.On call | |
| 130 | + | // - ctx context.Context | |
| 131 | + | // - id string | |
| 132 | + | func (_e *TrustedKeyStore_Expecter) DeleteTrustedKey(ctx any, id any) *TrustedKeyStore_DeleteTrustedKey_Call { | |
| 133 | + | return &TrustedKeyStore_DeleteTrustedKey_Call{Call: _e.mock.On("DeleteTrustedKey", ctx, id)} | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *TrustedKeyStore_DeleteTrustedKey_Call) Run(run func(ctx context.Context, id string)) *TrustedKeyStore_DeleteTrustedKey_Call { | |
| 137 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 138 | + | var arg0 context.Context | |
| 139 | + | if args[0] != nil { | |
| 140 | + | arg0 = args[0].(context.Context) | |
| 141 | + | } | |
| 142 | + | var arg1 string | |
| 143 | + | if args[1] != nil { | |
| 144 | + | arg1 = args[1].(string) | |
| 145 | + | } | |
| 146 | + | run( | |
| 147 | + | arg0, | |
| 148 | + | arg1, | |
| 149 | + | ) | |
| 150 | + | }) | |
| 151 | + | return _c | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (_c *TrustedKeyStore_DeleteTrustedKey_Call) Return(err error) *TrustedKeyStore_DeleteTrustedKey_Call { | |
| 155 | + | _c.Call.Return(err) | |
| 156 | + | return _c | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (_c *TrustedKeyStore_DeleteTrustedKey_Call) RunAndReturn(run func(ctx context.Context, id string) error) *TrustedKeyStore_DeleteTrustedKey_Call { | |
| 160 | + | _c.Call.Return(run) | |
| 161 | + | return _c | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // ListTrustedKeys provides a mock function for the type TrustedKeyStore | |
| 165 | + | func (_mock *TrustedKeyStore) ListTrustedKeys(ctx context.Context) ([]domain.TrustedKey, error) { | |
| 166 | + | ret := _mock.Called(ctx) | |
| 167 | + | ||
| 168 | + | if len(ret) == 0 { | |
| 169 | + | panic("no return value specified for ListTrustedKeys") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | var r0 []domain.TrustedKey | |
| 173 | + | var r1 error | |
| 174 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.TrustedKey, error)); ok { | |
| 175 | + | return returnFunc(ctx) | |
| 176 | + | } | |
| 177 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.TrustedKey); ok { | |
| 178 | + | r0 = returnFunc(ctx) | |
| 179 | + | } else { | |
| 180 | + | if ret.Get(0) != nil { | |
| 181 | + | r0 = ret.Get(0).([]domain.TrustedKey) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 185 | + | r1 = returnFunc(ctx) | |
| 186 | + | } else { | |
| 187 | + | r1 = ret.Error(1) | |
| 188 | + | } | |
| 189 | + | return r0, r1 | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // TrustedKeyStore_ListTrustedKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTrustedKeys' | |
| 193 | + | type TrustedKeyStore_ListTrustedKeys_Call struct { | |
| 194 | + | *mock.Call | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // ListTrustedKeys is a helper method to define mock.On call | |
| 198 | + | // - ctx context.Context | |
| 199 | + | func (_e *TrustedKeyStore_Expecter) ListTrustedKeys(ctx any) *TrustedKeyStore_ListTrustedKeys_Call { | |
| 200 | + | return &TrustedKeyStore_ListTrustedKeys_Call{Call: _e.mock.On("ListTrustedKeys", ctx)} | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *TrustedKeyStore_ListTrustedKeys_Call) Run(run func(ctx context.Context)) *TrustedKeyStore_ListTrustedKeys_Call { | |
| 204 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 205 | + | var arg0 context.Context | |
| 206 | + | if args[0] != nil { | |
| 207 | + | arg0 = args[0].(context.Context) | |
| 208 | + | } | |
| 209 | + | run( | |
| 210 | + | arg0, | |
| 211 | + | ) | |
| 212 | + | }) | |
| 213 | + | return _c | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (_c *TrustedKeyStore_ListTrustedKeys_Call) Return(trustedKeys []domain.TrustedKey, err error) *TrustedKeyStore_ListTrustedKeys_Call { | |
| 217 | + | _c.Call.Return(trustedKeys, err) | |
| 218 | + | return _c | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (_c *TrustedKeyStore_ListTrustedKeys_Call) RunAndReturn(run func(ctx context.Context) ([]domain.TrustedKey, error)) *TrustedKeyStore_ListTrustedKeys_Call { | |
| 222 | + | _c.Call.Return(run) | |
| 223 | + | return _c | |
| 224 | + | } | |
A internal/ports/signatures.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ports | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CommitObjectReader reads raw commit objects (ADR 0030). | |
| 12 | + | type CommitObjectReader interface { | |
| 13 | + | // ReadCommitObjects returns the raw content of each commit (without the | |
| 14 | + | // "commit <size>\0" prefix), keyed by id. Ids that are missing, are not | |
| 15 | + | // commits, or exceed domain.MaxCommitObjectBytes are left out of the map. | |
| 16 | + | // The total size of the returned objects stays within | |
| 17 | + | // domain.MaxCommitObjectsBytes: ids are read in order, and the first one | |
| 18 | + | // that would exceed the budget ends the read, so it and the ids after it | |
| 19 | + | // are left out (the caller leaves those commits unchecked). A cancelled | |
| 20 | + | // ctx is returned as its error. | |
| 21 | + | // domain.ErrNotFound when the repository has no directory. | |
| 22 | + | ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // KeyInspector parses public keys for the trusted key list. | |
| 26 | + | type KeyInspector interface { | |
| 27 | + | // Inspect parses a public key of the given format and returns its ID | |
| 28 | + | // (fingerprint) and its canonical text: the "<type> <base64>" part of an | |
| 29 | + | // authorized_keys line for SSH (comment dropped), the armored public key | |
| 30 | + | // for GPG (exactly one entity, private material refused). Any parse | |
| 31 | + | // failure wraps domain.ErrInvalid. | |
| 32 | + | Inspect(format domain.SignatureFormat, publicKey string) (id, canonical string, err error) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // SignatureVerifier checks one signature against a set of trusted keys. It | |
| 36 | + | // never errors: every failure is a status. | |
| 37 | + | type SignatureVerifier interface { | |
| 38 | + | // Verify checks signature over payload, signed by committerEmail. | |
| 39 | + | // signedAt is the committer date: the committer chooses it, and it is | |
| 40 | + | // authenticated as part of the payload but is not a trusted timestamp. | |
| 41 | + | // SSH ignores it, and so does GPG, which | |
| 42 | + | // judges key and signature expiry at the signature creation time and | |
| 43 | + | // revocation at the current time. keys are the trusted keys of the | |
| 44 | + | // verifier's format. It returns Format, Status, Fingerprint, and | |
| 45 | + | // KeyID/KeyTitle of the key that verified (even on email_mismatch). | |
| 46 | + | Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // TrustedKeyStore keeps the trusted keys (ADR 0030). It does not bound their | |
| 50 | + | // number: the use case checks domain.MaxTrustedKeys. | |
| 51 | + | type TrustedKeyStore interface { | |
| 52 | + | // ListTrustedKeys returns every stored key, oldest first. | |
| 53 | + | ListTrustedKeys(ctx context.Context) ([]domain.TrustedKey, error) | |
| 54 | + | // CreateTrustedKey stores a key; domain.ErrAlreadyExists if the ID is taken. | |
| 55 | + | CreateTrustedKey(ctx context.Context, k domain.TrustedKey) error | |
| 56 | + | // DeleteTrustedKey removes a key or returns domain.ErrNotFound. | |
| 57 | + | DeleteTrustedKey(ctx context.Context, id string) error | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // TrustedKeyService manages the trusted keys. *usecase.Signatures implements it. | |
| 61 | + | type TrustedKeyService interface { | |
| 62 | + | // ListTrustedKeys lists the keys, the builtin instance key first. Any | |
| 63 | + | // authenticated caller may. | |
| 64 | + | ListTrustedKeys(ctx context.Context, p domain.Principal) ([]domain.TrustedKey, error) | |
| 65 | + | // AddTrustedKey adds a key. Owner only. | |
| 66 | + | AddTrustedKey(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title, publicKey string, emails []string) (domain.TrustedKey, error) | |
| 67 | + | // RemoveTrustedKey removes a key by ID. Owner only; the builtin key is | |
| 68 | + | // refused with domain.ErrPreconditionFailed. | |
| 69 | + | RemoveTrustedKey(ctx context.Context, p domain.Principal, id string) error | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // CommitVerifier tells the signature state of commits the caller already read. | |
| 73 | + | // *usecase.Signatures implements it. | |
| 74 | + | type CommitVerifier interface { | |
| 75 | + | // VerifyCommits returns one verification per commit, keyed by id. A | |
| 76 | + | // commit that could not be read is left out (unchecked). | |
| 77 | + | VerifyCommits(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification | |
| 78 | + | } | |
A internal/readertest/commitobjects.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package readertest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // CommitObjectsMaker builds a CommitObjectReader serving the repositories | |
| 23 | + | // below data/repos, and returns it with the data directory. | |
| 24 | + | type CommitObjectsMaker func(t *testing.T) (ports.CommitObjectReader, string) | |
| 25 | + | ||
| 26 | + | // CommitObjects runs the CommitObjectReader contract against the readers mk | |
| 27 | + | // builds: the raw bytes of unsigned and SSH-signed commits equal `git cat-file | |
| 28 | + | // commit`; missing ids, trees and blobs are left out; a missing repository is | |
| 29 | + | // domain.ErrNotFound; too many ids are domain.ErrInvalid. | |
| 30 | + | func CommitObjects(t *testing.T, mk CommitObjectsMaker) { | |
| 31 | + | ctx := context.Background() | |
| 32 | + | r, data := mk(t) | |
| 33 | + | f := NewFixture(t, data, "signed") | |
| 34 | + | ||
| 35 | + | f.Write("a.txt", "one\n") | |
| 36 | + | unsigned := f.Commit("unsigned") | |
| 37 | + | signed := signedCommit(t, f, "b.txt", "signed with ssh") | |
| 38 | + | f.Write("c.txt", "three\n") | |
| 39 | + | again := f.Commit("unsigned again") | |
| 40 | + | tree := f.Git("rev-parse", signed+"^{tree}") | |
| 41 | + | blob := f.Git("rev-parse", signed+":b.txt") | |
| 42 | + | missing := ObjectID(t, strings.Repeat("1", 40)) | |
| 43 | + | ||
| 44 | + | ids := []domain.ObjectID{ObjectID(t, unsigned), ObjectID(t, signed), ObjectID(t, again)} | |
| 45 | + | got, err := r.ReadCommitObjects(ctx, f.Name, ids) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | require.Len(t, got, len(ids)) | |
| 48 | + | for _, id := range ids { | |
| 49 | + | assert.Equal(t, f.Git("cat-file", "commit", id.String()), strings.TrimSpace(string(got[id])), "commit %s", id) | |
| 50 | + | assert.True(t, bytes.Equal(got[id], rawCommit(t, f, id.String())), "raw bytes of %s", id) | |
| 51 | + | } | |
| 52 | + | assert.Contains(t, string(got[ids[1]]), "gpgsig -----BEGIN SSH SIGNATURE-----") | |
| 53 | + | assert.NotContains(t, string(got[ids[0]]), "gpgsig") | |
| 54 | + | ||
| 55 | + | // Missing ids, a tree and a blob are left out; duplicates are fine. | |
| 56 | + | mixed := []domain.ObjectID{ids[1], missing, ObjectID(t, tree), ObjectID(t, blob), ids[1], ids[0]} | |
| 57 | + | got, err = r.ReadCommitObjects(ctx, f.Name, mixed) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | assert.Len(t, got, 2) | |
| 60 | + | assert.Contains(t, got, ids[0]) | |
| 61 | + | assert.Contains(t, got, ids[1]) | |
| 62 | + | ||
| 63 | + | got, err = r.ReadCommitObjects(ctx, f.Name, nil) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | assert.Empty(t, got) | |
| 66 | + | ||
| 67 | + | // A repository reads the same after its objects were packed. | |
| 68 | + | f.Git("gc", "--quiet") | |
| 69 | + | got, err = r.ReadCommitObjects(ctx, f.Name, ids) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.Equal(t, rawCommit(t, f, signed), got[ids[1]], "packed") | |
| 72 | + | ||
| 73 | + | t.Run("budget", func(t *testing.T) { commitObjectsBudget(t, r, f) }) | |
| 74 | + | ||
| 75 | + | cancelled, cancel := context.WithCancel(ctx) | |
| 76 | + | cancel() | |
| 77 | + | _, err = r.ReadCommitObjects(cancelled, f.Name, ids) | |
| 78 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 79 | + | ||
| 80 | + | _, err = r.ReadCommitObjects(ctx, RepoName(t, "nope"), ids) | |
| 81 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 82 | + | ||
| 83 | + | many := make([]domain.ObjectID, ports.MaxCommitLimit+1) | |
| 84 | + | for i := range many { | |
| 85 | + | many[i] = missing | |
| 86 | + | } | |
| 87 | + | _, err = r.ReadCommitObjects(ctx, f.Name, many) | |
| 88 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 89 | + | _, err = r.ReadCommitObjects(ctx, f.Name, many[:ports.MaxCommitLimit]) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // commitObjectsBudget checks the aggregate budget: ten commits of about | |
| 94 | + | // 900 KiB each (under the per-object limit) do not fit in | |
| 95 | + | // domain.MaxCommitObjectsBytes, so the read stops before the one that would | |
| 96 | + | // not fit, and the others are left out. | |
| 97 | + | func commitObjectsBudget(t *testing.T, r ports.CommitObjectReader, f *Fixture) { | |
| 98 | + | const n, msgSize = 10, 900 << 10 | |
| 99 | + | tree := f.Git("rev-parse", "HEAD^{tree}") | |
| 100 | + | big := make([]domain.ObjectID, n) | |
| 101 | + | for i := range big { | |
| 102 | + | msg := fmt.Sprintf("big %d\n", i) + strings.Repeat("x", msgSize-8) + "\n" | |
| 103 | + | raw := fmt.Sprintf("tree %s\nauthor A <a@x.org> 1 +0000\ncommitter A <a@x.org> 1 +0000\n\n%s", tree, msg) | |
| 104 | + | big[i] = ObjectID(t, f.WriteObject("commit", raw)) | |
| 105 | + | } | |
| 106 | + | got, err := r.ReadCommitObjects(context.Background(), f.Name, big) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | var total int | |
| 109 | + | for id, raw := range got { | |
| 110 | + | assert.Contains(t, big, id) | |
| 111 | + | total += len(raw) | |
| 112 | + | } | |
| 113 | + | assert.LessOrEqual(t, total, domain.MaxCommitObjectsBytes) | |
| 114 | + | assert.Len(t, got, domain.MaxCommitObjectsBytes/(msgSize+200), "as many as fit in the budget") | |
| 115 | + | assert.Less(t, len(got), n) | |
| 116 | + | for _, id := range big[:len(got)] { | |
| 117 | + | assert.Contains(t, got, id, "the budget is spent in the order of the ids") | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // ObjectID parses a full object id. | |
| 122 | + | func ObjectID(t testing.TB, s string) domain.ObjectID { | |
| 123 | + | t.Helper() | |
| 124 | + | id, err := domain.ParseObjectID(s) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | return id | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // rawCommit returns `git cat-file commit <id>` byte for byte (Fixture.Git | |
| 130 | + | // trims its output). | |
| 131 | + | func rawCommit(t testing.TB, f *Fixture, id string) []byte { | |
| 132 | + | t.Helper() | |
| 133 | + | cmd := exec.Command("git", "--git-dir="+f.Bare, "cat-file", "commit", id) | |
| 134 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + f.Work, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} | |
| 135 | + | out, err := cmd.Output() | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | return out | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // signedCommit writes a file and commits it with an SSH signature (a key made | |
| 141 | + | // for the test, `git commit -S` with gpg.format=ssh). Without ssh-keygen it | |
| 142 | + | // skips locally and fails when CI is set. | |
| 143 | + | func signedCommit(t testing.TB, f *Fixture, file, msg string) string { | |
| 144 | + | t.Helper() | |
| 145 | + | if _, err := exec.LookPath("ssh-keygen"); err != nil { | |
| 146 | + | if os.Getenv("CI") != "" { | |
| 147 | + | t.Fatalf("ssh-keygen not found") | |
| 148 | + | } | |
| 149 | + | t.Skip("ssh-keygen not found") | |
| 150 | + | } | |
| 151 | + | key := filepath.Join(t.TempDir(), "id_ed25519") | |
| 152 | + | out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "test", "-f", key).CombinedOutput() | |
| 153 | + | require.NoError(t, err, string(out)) | |
| 154 | + | f.Write(file, msg+"\n") | |
| 155 | + | f.Git("add", "-A") | |
| 156 | + | f.Git("-c", "gpg.format=ssh", "-c", "user.signingkey="+key, "commit", "--quiet", "-S", "-m", msg) | |
| 157 | + | return f.Git("rev-parse", "HEAD") | |
| 158 | + | } | |
M internal/usecase/browse.go +37 -1
| @@ -31,6 +31,9 @@ type Browse struct { | |||
| 31 | 31 | history *historyCache | |
| 32 | 32 | // stats keeps the figures of the home page. | |
| 33 | 33 | stats statsCache | |
| 34 | + | // verifier fills the signature state of the commits; nil leaves it | |
| 35 | + | // unchecked. | |
| 36 | + | verifier ports.CommitVerifier | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | var _ ports.BrowseService = (*Browse)(nil) | |
| @@ -53,6 +56,28 @@ func (b *Browse) WithHistoryCache(maxEntries int, maxBytes int64) *Browse { | |||
| 53 | 56 | return b | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 59 | + | // WithVerifier sets the checker of commit signatures (ADR 0030): commits | |
| 60 | + | // returned by ListCommits and GetCommit then carry their Verification. Call | |
| 61 | + | // it before the first request. | |
| 62 | + | func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse { | |
| 63 | + | b.verifier = v | |
| 64 | + | return b | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // verify fills the Verification of the commits, in place, with one call. The | |
| 68 | + | // slice must not be shared with a cache. | |
| 69 | + | func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) { | |
| 70 | + | if b.verifier == nil || len(commits) == 0 { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | res := b.verifier.VerifyCommits(ctx, repo, commits) | |
| 74 | + | for i := range commits { | |
| 75 | + | if v, ok := res[commits[i].ID]; ok { | |
| 76 | + | commits[i].Verification = v | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 56 | 81 | // authorize returns the repository if p may read it. The caller uses it to | |
| 57 | 82 | // tell a repository from a later one created under the same name. | |
| 58 | 83 | func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| @@ -247,6 +272,8 @@ func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domai | |||
| 247 | 272 | if err != nil { | |
| 248 | 273 | return ports.HistoryPage{}, err | |
| 249 | 274 | } | |
| 275 | + | // res.Commits is a copy (see listCommits): the cached page is untouched. | |
| 276 | + | b.verify(ctx, repo, res.Commits) | |
| 250 | 277 | out := ports.HistoryPage{Commits: res.Commits} | |
| 251 | 278 | if res.HasMore { | |
| 252 | 279 | out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path) | |
| @@ -308,5 +335,14 @@ func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain. | |||
| 308 | 335 | if _, err := b.authorize(ctx, p, repo); err != nil { | |
| 309 | 336 | return domain.CommitDetail{}, err | |
| 310 | 337 | } | |
| 311 | - | return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits()) | |
| 338 | + | d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits()) | |
| 339 | + | if err != nil { | |
| 340 | + | return domain.CommitDetail{}, err | |
| 341 | + | } | |
| 342 | + | if b.verifier != nil { | |
| 343 | + | one := []domain.Commit{d.Commit} | |
| 344 | + | b.verify(ctx, repo, one) | |
| 345 | + | d.Commit = one[0] | |
| 346 | + | } | |
| 347 | + | return d, nil | |
| 312 | 348 | } | |
M internal/usecase/browse_test.go +95 -0
| @@ -567,3 +567,98 @@ func mustPath(t *testing.T, s string) domain.TreePath { | |||
| 567 | 567 | require.NoError(t, err) | |
| 568 | 568 | return p | |
| 569 | 569 | } | |
| 570 | + | ||
| 571 | + | func TestBrowseVerifiesCommits(t *testing.T) { | |
| 572 | + | t.Parallel() | |
| 573 | + | ctx := context.Background() | |
| 574 | + | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 575 | + | verified := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, KeyID: "SHA256:k"} | |
| 576 | + | unsigned := domain.CommitVerification{Status: domain.StatusUnsigned} | |
| 577 | + | ||
| 578 | + | newFixture := func(t *testing.T) (*browseFixture, *mocks.CommitVerifier) { | |
| 579 | + | f := newBrowseFixture(t) | |
| 580 | + | v := mocks.NewCommitVerifier(t) | |
| 581 | + | f.browse.WithVerifier(v) | |
| 582 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | |
| 583 | + | return f, v | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | t.Run("ListCommits fills the page with one call and leaves unchecked commits alone", func(t *testing.T) { | |
| 587 | + | t.Parallel() | |
| 588 | + | f, v := newFixture(t) | |
| 589 | + | cs := []domain.Commit{{ID: oid("a")}, {ID: oid("b")}, {ID: oid("c")}} | |
| 590 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).Return(ports.CommitPage{Head: oid("a"), Commits: cs}, nil) | |
| 591 | + | v.EXPECT().VerifyCommits(ctx, pub.Name, cs).Return(map[domain.ObjectID]domain.CommitVerification{ | |
| 592 | + | oid("a"): verified, oid("b"): unsigned, | |
| 593 | + | }).Once() | |
| 594 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 595 | + | require.NoError(t, err) | |
| 596 | + | require.Len(t, page.Commits, 3) | |
| 597 | + | assert.Equal(t, verified, page.Commits[0].Verification) | |
| 598 | + | assert.Equal(t, unsigned, page.Commits[1].Verification) | |
| 599 | + | assert.Equal(t, domain.CommitVerification{}, page.Commits[2].Verification) | |
| 600 | + | }) | |
| 601 | + | ||
| 602 | + | t.Run("a cached page is not mutated", func(t *testing.T) { | |
| 603 | + | t.Parallel() | |
| 604 | + | f, v := newFixture(t) | |
| 605 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | |
| 606 | + | full := oid("a") | |
| 607 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | |
| 608 | + | Return(ports.CommitPage{Head: full, Commits: []domain.Commit{{ID: full}}}, nil).Once() | |
| 609 | + | v.EXPECT().VerifyCommits(ctx, pub.Name, mock.Anything). | |
| 610 | + | Return(map[domain.ObjectID]domain.CommitVerification{full: verified}).Twice() | |
| 611 | + | params := ports.ListCommitsParams{Revision: domain.RevisionOf(full), PageSize: 1} | |
| 612 | + | for range 2 { // the second answer comes from the history cache | |
| 613 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params) | |
| 614 | + | require.NoError(t, err) | |
| 615 | + | assert.Equal(t, verified, page.Commits[0].Verification) | |
| 616 | + | } | |
| 617 | + | // With the verifier off, the cached entry is still unchecked. | |
| 618 | + | f.browse.WithVerifier(nil) | |
| 619 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params) | |
| 620 | + | require.NoError(t, err) | |
| 621 | + | assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification) | |
| 622 | + | }) | |
| 623 | + | ||
| 624 | + | t.Run("GetCommit fills the commit", func(t *testing.T) { | |
| 625 | + | t.Parallel() | |
| 626 | + | f, v := newFixture(t) | |
| 627 | + | c := domain.Commit{ID: oid("a")} | |
| 628 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, domain.DefaultDiffLimits()). | |
| 629 | + | Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil) | |
| 630 | + | v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}). | |
| 631 | + | Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified}) | |
| 632 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | |
| 633 | + | require.NoError(t, err) | |
| 634 | + | assert.Equal(t, verified, d.Commit.Verification) | |
| 635 | + | assert.Equal(t, 2, d.Stats.FilesChanged) | |
| 636 | + | }) | |
| 637 | + | ||
| 638 | + | t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) { | |
| 639 | + | t.Parallel() | |
| 640 | + | f, _ := newFixture(t) // the verifier mock fails the test if called | |
| 641 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).Return(ports.CommitPage{}, nil) | |
| 642 | + | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 643 | + | require.NoError(t, err) | |
| 644 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound) | |
| 645 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | |
| 646 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 647 | + | }) | |
| 648 | + | ||
| 649 | + | t.Run("without a verifier the commits are unchanged", func(t *testing.T) { | |
| 650 | + | t.Parallel() | |
| 651 | + | f := newBrowseFixture(t) | |
| 652 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | |
| 653 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | |
| 654 | + | Return(ports.CommitPage{Commits: []domain.Commit{{ID: oid("a")}}}, nil) | |
| 655 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything). | |
| 656 | + | Return(domain.CommitDetail{Commit: domain.Commit{ID: oid("a")}}, nil) | |
| 657 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 658 | + | require.NoError(t, err) | |
| 659 | + | assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification) | |
| 660 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | |
| 661 | + | require.NoError(t, err) | |
| 662 | + | assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification) | |
| 663 | + | }) | |
| 664 | + | } | |
M internal/usecase/sessions.go +6 -2
| @@ -75,12 +75,16 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc | |||
| 75 | 75 | ||
| 76 | 76 | // requireOwner is the access rule of the session API: root, or a stored token | |
| 77 | 77 | // with every scope, no repository list and no branch prefixes. | |
| 78 | - | func requireOwner(p domain.Principal) error { | |
| 78 | + | func requireOwner(p domain.Principal) error { return requireOwnerFor(p, "managing web sessions") } | |
| 79 | + | ||
| 80 | + | // requireOwnerFor is the owner rule; action names what was refused in the | |
| 81 | + | // error ("managing web sessions"). | |
| 82 | + | func requireOwnerFor(p domain.Principal, action string) error { | |
| 79 | 83 | switch { | |
| 80 | 84 | case p.IsAnonymous(): | |
| 81 | 85 | return domain.ErrUnauthenticated | |
| 82 | 86 | case !p.HasOwnerRights(): | |
| 83 | - | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden) | |
| 87 | + | return fmt.Errorf("%w: %s needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden, action) | |
| 84 | 88 | } | |
| 85 | 89 | return nil | |
| 86 | 90 | } | |
A internal/usecase/signatures.go +286 -0
| @@ -0,0 +1,286 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "slices" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // MaxSignatureCacheEntries bounds the verification results kept in memory. | |
| 18 | + | const MaxSignatureCacheEntries = 10000 | |
| 19 | + | ||
| 20 | + | // Signatures manages the trusted keys and tells whether commits are signed by | |
| 21 | + | // them (ADR 0030). The keys are loaded once and kept in memory: this process | |
| 22 | + | // is their only writer. | |
| 23 | + | type Signatures struct { | |
| 24 | + | store ports.TrustedKeyStore | |
| 25 | + | inspector ports.KeyInspector | |
| 26 | + | verifiers map[domain.SignatureFormat]ports.SignatureVerifier | |
| 27 | + | objects ports.CommitObjectReader | |
| 28 | + | builtin domain.TrustedKey | |
| 29 | + | clock ports.Clock | |
| 30 | + | ||
| 31 | + | // mu guards stored and generation. Mutations hold it for writing during | |
| 32 | + | // the store call, which serializes them and makes the limit check exact. | |
| 33 | + | mu sync.RWMutex | |
| 34 | + | stored []domain.TrustedKey // oldest first | |
| 35 | + | // generation changes whenever the set of trusted keys does, so cached | |
| 36 | + | // results of an older set are never returned. | |
| 37 | + | generation uint64 | |
| 38 | + | ||
| 39 | + | cacheMu sync.Mutex | |
| 40 | + | cache map[sigKey]domain.CommitVerification | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | var ( | |
| 44 | + | _ ports.TrustedKeyService = (*Signatures)(nil) | |
| 45 | + | _ ports.CommitVerifier = (*Signatures)(nil) | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | // sigKey identifies a cached verification. | |
| 49 | + | type sigKey struct { | |
| 50 | + | repo domain.RepoName | |
| 51 | + | id domain.ObjectID | |
| 52 | + | generation uint64 | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // NewSignatures loads the stored trusted keys. builtin is the instance | |
| 56 | + | // signing key (Builtin, SSH, ID = its fingerprint), built by the caller; a | |
| 57 | + | // zero ID means there is none. | |
| 58 | + | func NewSignatures(store ports.TrustedKeyStore, inspector ports.KeyInspector, verifiers map[domain.SignatureFormat]ports.SignatureVerifier, | |
| 59 | + | objects ports.CommitObjectReader, builtin domain.TrustedKey, clock ports.Clock) (*Signatures, error) { | |
| 60 | + | keys, err := store.ListTrustedKeys(context.Background()) | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, fmt.Errorf("load trusted keys: %w", err) | |
| 63 | + | } | |
| 64 | + | return &Signatures{ | |
| 65 | + | store: store, inspector: inspector, verifiers: verifiers, objects: objects, builtin: builtin, clock: clock, | |
| 66 | + | stored: keys, cache: map[sigKey]domain.CommitVerification{}, | |
| 67 | + | }, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func cloneKey(k domain.TrustedKey) domain.TrustedKey { | |
| 71 | + | k.Emails = slices.Clone(k.Emails) | |
| 72 | + | return k | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // requireKeyOwner is the access rule of the key changes: the owner. | |
| 76 | + | func requireKeyOwner(p domain.Principal) error { return requireOwnerFor(p, "managing trusted keys") } | |
| 77 | + | ||
| 78 | + | // ListTrustedKeys lists the instance key first, then the stored keys oldest | |
| 79 | + | // first. Any authenticated caller may. | |
| 80 | + | func (s *Signatures) ListTrustedKeys(_ context.Context, p domain.Principal) ([]domain.TrustedKey, error) { | |
| 81 | + | if p.IsAnonymous() { | |
| 82 | + | return nil, domain.ErrUnauthenticated | |
| 83 | + | } | |
| 84 | + | s.mu.RLock() | |
| 85 | + | defer s.mu.RUnlock() | |
| 86 | + | out := make([]domain.TrustedKey, 0, len(s.stored)+1) | |
| 87 | + | if s.builtin.ID != "" { | |
| 88 | + | out = append(out, cloneKey(s.builtin)) | |
| 89 | + | } | |
| 90 | + | for _, k := range s.stored { | |
| 91 | + | out = append(out, cloneKey(k)) | |
| 92 | + | } | |
| 93 | + | return out, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // AddTrustedKey stores a key. Only the owner may. The instance key cannot be | |
| 97 | + | // added again (domain.ErrAlreadyExists), and at most domain.MaxTrustedKeys | |
| 98 | + | // keys are stored (domain.ErrResourceExhausted). | |
| 99 | + | func (s *Signatures) AddTrustedKey(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title, publicKey string, emails []string) (domain.TrustedKey, error) { | |
| 100 | + | if err := requireKeyOwner(p); err != nil { | |
| 101 | + | return domain.TrustedKey{}, err | |
| 102 | + | } | |
| 103 | + | title, err := domain.ParseTrustedKeyTitle(title) | |
| 104 | + | if err != nil { | |
| 105 | + | return domain.TrustedKey{}, err | |
| 106 | + | } | |
| 107 | + | emails, err = domain.NormalizeKeyEmails(emails) | |
| 108 | + | if err != nil { | |
| 109 | + | return domain.TrustedKey{}, err | |
| 110 | + | } | |
| 111 | + | if format, err = domain.ParseSignatureFormat(string(format)); err != nil { | |
| 112 | + | return domain.TrustedKey{}, err | |
| 113 | + | } | |
| 114 | + | if len(publicKey) > domain.MaxTrustedKeyBytes { | |
| 115 | + | return domain.TrustedKey{}, fmt.Errorf("%w: public key over %d bytes", domain.ErrInvalid, domain.MaxTrustedKeyBytes) | |
| 116 | + | } | |
| 117 | + | id, canonical, err := s.inspector.Inspect(format, publicKey) | |
| 118 | + | if err != nil { | |
| 119 | + | return domain.TrustedKey{}, err | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | s.mu.Lock() | |
| 123 | + | defer s.mu.Unlock() | |
| 124 | + | if (s.builtin.ID != "" && id == s.builtin.ID) || slices.ContainsFunc(s.stored, func(k domain.TrustedKey) bool { return k.ID == id }) { | |
| 125 | + | return domain.TrustedKey{}, fmt.Errorf("%w: trusted key %s", domain.ErrAlreadyExists, id) | |
| 126 | + | } | |
| 127 | + | if len(s.stored) >= domain.MaxTrustedKeys { | |
| 128 | + | return domain.TrustedKey{}, fmt.Errorf("%w: at most %d trusted keys", domain.ErrResourceExhausted, domain.MaxTrustedKeys) | |
| 129 | + | } | |
| 130 | + | key := domain.TrustedKey{ | |
| 131 | + | ID: id, Format: format, Title: title, PublicKey: canonical, Emails: emails, CreatedAt: s.clock.Now().UTC(), | |
| 132 | + | } | |
| 133 | + | if err := s.store.CreateTrustedKey(ctx, key); err != nil { | |
| 134 | + | return domain.TrustedKey{}, err | |
| 135 | + | } | |
| 136 | + | s.stored = append(s.stored, key) | |
| 137 | + | s.generation++ | |
| 138 | + | log := zerowrap.FromCtx(ctx) | |
| 139 | + | log.Info().Str("key_id", id).Msg("trusted key added") | |
| 140 | + | return cloneKey(key), nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // RemoveTrustedKey deletes a key. Only the owner may. The instance key is | |
| 144 | + | // refused with domain.ErrPreconditionFailed. | |
| 145 | + | func (s *Signatures) RemoveTrustedKey(ctx context.Context, p domain.Principal, id string) error { | |
| 146 | + | if err := requireKeyOwner(p); err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | if id == "" { | |
| 150 | + | return fmt.Errorf("%w: key id", domain.ErrInvalid) | |
| 151 | + | } | |
| 152 | + | s.mu.Lock() | |
| 153 | + | defer s.mu.Unlock() | |
| 154 | + | if s.builtin.ID != "" && id == s.builtin.ID { | |
| 155 | + | return fmt.Errorf("%w: the instance signing key cannot be removed", domain.ErrPreconditionFailed) | |
| 156 | + | } | |
| 157 | + | if err := s.store.DeleteTrustedKey(ctx, id); err != nil { | |
| 158 | + | return err | |
| 159 | + | } | |
| 160 | + | s.stored = slices.DeleteFunc(s.stored, func(k domain.TrustedKey) bool { return k.ID == id }) | |
| 161 | + | s.generation++ | |
| 162 | + | log := zerowrap.FromCtx(ctx) | |
| 163 | + | log.Info().Str("key_id", id).Msg("trusted key removed") | |
| 164 | + | return nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // snapshot returns the current trust generation and the trusted keys of each | |
| 168 | + | // format, the instance key included. | |
| 169 | + | func (s *Signatures) snapshot() (uint64, map[domain.SignatureFormat][]domain.TrustedKey) { | |
| 170 | + | s.mu.RLock() | |
| 171 | + | defer s.mu.RUnlock() | |
| 172 | + | byFormat := map[domain.SignatureFormat][]domain.TrustedKey{} | |
| 173 | + | if s.builtin.ID != "" { | |
| 174 | + | byFormat[s.builtin.Format] = append(byFormat[s.builtin.Format], s.builtin) | |
| 175 | + | } | |
| 176 | + | for _, k := range s.stored { | |
| 177 | + | byFormat[k.Format] = append(byFormat[k.Format], k) | |
| 178 | + | } | |
| 179 | + | return s.generation, byFormat | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // VerifyCommits checks the signature of the commits against the trusted keys. | |
| 183 | + | // Results are cached by repository, commit and trust generation. When the raw | |
| 184 | + | // commits cannot be read, only the cached results are returned. | |
| 185 | + | func (s *Signatures) VerifyCommits(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification { | |
| 186 | + | out := make(map[domain.ObjectID]domain.CommitVerification, len(commits)) | |
| 187 | + | if len(commits) == 0 { | |
| 188 | + | return out | |
| 189 | + | } | |
| 190 | + | gen, keys := s.snapshot() | |
| 191 | + | ||
| 192 | + | var missing []domain.ObjectID | |
| 193 | + | byID := make(map[domain.ObjectID]domain.Commit, len(commits)) | |
| 194 | + | s.cacheMu.Lock() | |
| 195 | + | for _, c := range commits { | |
| 196 | + | if _, dup := byID[c.ID]; dup { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | byID[c.ID] = c | |
| 200 | + | if v, ok := s.cache[sigKey{repo, c.ID, gen}]; ok { | |
| 201 | + | out[c.ID] = v | |
| 202 | + | } else { | |
| 203 | + | missing = append(missing, c.ID) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | s.cacheMu.Unlock() | |
| 207 | + | if len(missing) == 0 { | |
| 208 | + | return out | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | raws, err := s.objects.ReadCommitObjects(ctx, repo, missing) | |
| 212 | + | if err != nil { | |
| 213 | + | log := zerowrap.FromCtx(ctx) | |
| 214 | + | log.Warn().Err(err).Msg("read commit objects for signature check") | |
| 215 | + | return out | |
| 216 | + | } | |
| 217 | + | fresh := make(map[sigKey]domain.CommitVerification, len(raws)) | |
| 218 | + | for _, id := range missing { | |
| 219 | + | raw, ok := raws[id] | |
| 220 | + | if !ok { | |
| 221 | + | continue | |
| 222 | + | } | |
| 223 | + | v := s.verifyOne(byID[id], raw, keys) | |
| 224 | + | out[id] = v | |
| 225 | + | fresh[sigKey{repo, id, gen}] = v | |
| 226 | + | } | |
| 227 | + | s.remember(fresh) | |
| 228 | + | return out | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // verifyOne checks one raw commit. | |
| 232 | + | func (s *Signatures) verifyOne(c domain.Commit, raw []byte, keys map[domain.SignatureFormat][]domain.TrustedKey) domain.CommitVerification { | |
| 233 | + | payload, sig, signed := domain.SplitCommitSignature(raw) | |
| 234 | + | if !signed { | |
| 235 | + | return domain.CommitVerification{Status: domain.StatusUnsigned} | |
| 236 | + | } | |
| 237 | + | format := domain.SignatureFormatOf(sig) | |
| 238 | + | switch format { | |
| 239 | + | case domain.FormatNone: | |
| 240 | + | return domain.CommitVerification{Format: domain.FormatNone, Status: domain.StatusBadSignature} | |
| 241 | + | case domain.FormatX509: | |
| 242 | + | return domain.CommitVerification{Format: domain.FormatX509, Status: domain.StatusUnsupported} | |
| 243 | + | } | |
| 244 | + | v, ok := s.verifiers[format] | |
| 245 | + | if !ok { | |
| 246 | + | return domain.CommitVerification{Format: format, Status: domain.StatusUnsupported} | |
| 247 | + | } | |
| 248 | + | return v.Verify(payload, sig, c.Committer.Email, c.Committer.Time.Unix(), keys[format]) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // remember adds results to the cache. The cache is a plain map: when adding | |
| 252 | + | // would exceed MaxSignatureCacheEntries, an arbitrary half of the entries | |
| 253 | + | // (the map iteration order) is dropped first. A result is cheap to compute | |
| 254 | + | // again, so no recency bookkeeping is kept. | |
| 255 | + | func (s *Signatures) remember(fresh map[sigKey]domain.CommitVerification) { | |
| 256 | + | if len(fresh) == 0 { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | s.cacheMu.Lock() | |
| 260 | + | defer s.cacheMu.Unlock() | |
| 261 | + | if len(fresh) >= MaxSignatureCacheEntries { | |
| 262 | + | clear(s.cache) | |
| 263 | + | } | |
| 264 | + | for len(s.cache)+len(fresh) > MaxSignatureCacheEntries && len(s.cache) > 0 { | |
| 265 | + | drop := len(s.cache) / 2 | |
| 266 | + | if drop == 0 { | |
| 267 | + | drop = 1 | |
| 268 | + | } | |
| 269 | + | for k := range s.cache { | |
| 270 | + | if drop == 0 { | |
| 271 | + | break | |
| 272 | + | } | |
| 273 | + | delete(s.cache, k) | |
| 274 | + | drop-- | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | // A page larger than the cache keeps only what fits. | |
| 278 | + | n := 0 | |
| 279 | + | for k, v := range fresh { | |
| 280 | + | if n >= MaxSignatureCacheEntries { | |
| 281 | + | break | |
| 282 | + | } | |
| 283 | + | s.cache[k] = v | |
| 284 | + | n++ | |
| 285 | + | } | |
| 286 | + | } | |
A internal/usecase/signatures_test.go +458 -0
| @@ -0,0 +1,458 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | sshArmor = "-----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----\n" | |
| 25 | + | gpgArmor = "-----BEGIN PGP SIGNATURE-----\n\n iQ==\n -----END PGP SIGNATURE-----\n" | |
| 26 | + | x509Armor = "-----BEGIN SIGNED MESSAGE-----\n MIIB\n -----END SIGNED MESSAGE-----\n" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | var sigNow = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) | |
| 30 | + | ||
| 31 | + | type sigFixture struct { | |
| 32 | + | store *mocks.TrustedKeyStore | |
| 33 | + | inspector *mocks.KeyInspector | |
| 34 | + | ssh, gpg *mocks.SignatureVerifier | |
| 35 | + | objects *mocks.CommitObjectReader | |
| 36 | + | clock *mocks.Clock | |
| 37 | + | sigs *usecase.Signatures | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | var builtinKey = domain.TrustedKey{ | |
| 41 | + | ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", Emails: []string{"bot@example.org"}, Builtin: true, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func newSigFixture(t *testing.T, stored ...domain.TrustedKey) *sigFixture { | |
| 45 | + | t.Helper() | |
| 46 | + | f := &sigFixture{ | |
| 47 | + | store: mocks.NewTrustedKeyStore(t), inspector: mocks.NewKeyInspector(t), | |
| 48 | + | ssh: mocks.NewSignatureVerifier(t), gpg: mocks.NewSignatureVerifier(t), | |
| 49 | + | objects: mocks.NewCommitObjectReader(t), clock: mocks.NewClock(t), | |
| 50 | + | } | |
| 51 | + | f.clock.EXPECT().Now().Return(sigNow).Maybe() | |
| 52 | + | f.store.EXPECT().ListTrustedKeys(mock.Anything).Return(stored, nil) | |
| 53 | + | var err error | |
| 54 | + | f.sigs, err = usecase.NewSignatures(f.store, f.inspector, map[domain.SignatureFormat]ports.SignatureVerifier{ | |
| 55 | + | domain.FormatSSH: f.ssh, domain.FormatGPG: f.gpg, | |
| 56 | + | }, f.objects, builtinKey, f.clock) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | return f | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func sigToken(scopes []domain.Scope, repos ...domain.RepoName) domain.Principal { | |
| 62 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos}) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestNewSignaturesLoadError(t *testing.T) { | |
| 66 | + | t.Parallel() | |
| 67 | + | store := mocks.NewTrustedKeyStore(t) | |
| 68 | + | store.EXPECT().ListTrustedKeys(mock.Anything).Return(nil, errors.New("boom")) | |
| 69 | + | _, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil) | |
| 70 | + | require.Error(t, err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestTrustedKeyAccess(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | ctx := context.Background() | |
| 76 | + | reader := sigToken([]domain.Scope{domain.ScopeRepoRead}) | |
| 77 | + | owner := sigToken(domain.AllScopes()) | |
| 78 | + | ||
| 79 | + | t.Run("list", func(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | f := newSigFixture(t) | |
| 82 | + | _, err := f.sigs.ListTrustedKeys(ctx, domain.Anonymous()) | |
| 83 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 84 | + | for _, p := range []domain.Principal{reader, owner, domain.Root(), domain.Owner()} { | |
| 85 | + | _, err := f.sigs.ListTrustedKeys(ctx, p) | |
| 86 | + | assert.NoError(t, err) | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | ||
| 90 | + | t.Run("add and remove", func(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | f := newSigFixture(t) | |
| 93 | + | _, err := f.sigs.AddTrustedKey(ctx, domain.Anonymous(), domain.FormatSSH, "t", "k", []string{"a@b.c"}) | |
| 94 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 95 | + | err = f.sigs.RemoveTrustedKey(ctx, domain.Anonymous(), "x") | |
| 96 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 97 | + | ||
| 98 | + | _, err = f.sigs.AddTrustedKey(ctx, reader, domain.FormatSSH, "t", "k", []string{"a@b.c"}) | |
| 99 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 100 | + | assert.Contains(t, err.Error(), "managing trusted keys needs") | |
| 101 | + | err = f.sigs.RemoveTrustedKey(ctx, reader, "x") | |
| 102 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 103 | + | ||
| 104 | + | limited := sigToken(domain.AllScopes(), name(t, "r")) | |
| 105 | + | _, err = f.sigs.AddTrustedKey(ctx, limited, domain.FormatSSH, "t", "k", []string{"a@b.c"}) | |
| 106 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 107 | + | ||
| 108 | + | for _, p := range []domain.Principal{owner, domain.Root(), domain.Owner()} { | |
| 109 | + | id := "SHA256:" + p.Label() | |
| 110 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return(id, "ssh-ed25519 K", nil).Once() | |
| 111 | + | f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil).Once() | |
| 112 | + | _, err := f.sigs.AddTrustedKey(ctx, p, domain.FormatSSH, "t", "k", []string{"a@b.c"}) | |
| 113 | + | assert.NoError(t, err) | |
| 114 | + | f.store.EXPECT().DeleteTrustedKey(ctx, id).Return(nil).Once() | |
| 115 | + | assert.NoError(t, f.sigs.RemoveTrustedKey(ctx, p, id)) | |
| 116 | + | } | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestAddTrustedKey(t *testing.T) { | |
| 121 | + | t.Parallel() | |
| 122 | + | ctx := context.Background() | |
| 123 | + | owner := domain.Root() | |
| 124 | + | ||
| 125 | + | t.Run("stores the canonical key and lists it after the builtin", func(t *testing.T) { | |
| 126 | + | t.Parallel() | |
| 127 | + | f := newSigFixture(t) | |
| 128 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "ssh-ed25519 K comment").Return("SHA256:k1", "ssh-ed25519 K", nil) | |
| 129 | + | want := domain.TrustedKey{ | |
| 130 | + | ID: "SHA256:k1", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 K", | |
| 131 | + | Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: sigNow, | |
| 132 | + | } | |
| 133 | + | f.store.EXPECT().CreateTrustedKey(ctx, want).Return(nil) | |
| 134 | + | got, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " laptop ", "ssh-ed25519 K comment", []string{"A@B.c", " d@e.f", "a@b.c"}) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | assert.Equal(t, want, got) | |
| 137 | + | ||
| 138 | + | keys, err := f.sigs.ListTrustedKeys(ctx, owner) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | require.Len(t, keys, 2) | |
| 141 | + | assert.Equal(t, builtinKey, keys[0]) | |
| 142 | + | assert.Equal(t, want, keys[1]) | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | t.Run("validation", func(t *testing.T) { | |
| 146 | + | t.Parallel() | |
| 147 | + | f := newSigFixture(t) | |
| 148 | + | for name, call := range map[string]func() error{ | |
| 149 | + | "empty title": func() error { | |
| 150 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " ", "k", []string{"a@b.c"}) | |
| 151 | + | return err | |
| 152 | + | }, | |
| 153 | + | "no email": func() error { | |
| 154 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", nil) | |
| 155 | + | return err | |
| 156 | + | }, | |
| 157 | + | "bad email": func() error { | |
| 158 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"not an email"}) | |
| 159 | + | return err | |
| 160 | + | }, | |
| 161 | + | "x509": func() error { | |
| 162 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatX509, "t", "k", []string{"a@b.c"}) | |
| 163 | + | return err | |
| 164 | + | }, | |
| 165 | + | "no format": func() error { | |
| 166 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatNone, "t", "k", []string{"a@b.c"}) | |
| 167 | + | return err | |
| 168 | + | }, | |
| 169 | + | "key too big": func() error { | |
| 170 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", strings.Repeat("x", domain.MaxTrustedKeyBytes+1), []string{"a@b.c"}) | |
| 171 | + | return err | |
| 172 | + | }, | |
| 173 | + | } { | |
| 174 | + | assert.ErrorIs(t, call(), domain.ErrInvalid, name) | |
| 175 | + | } | |
| 176 | + | }) | |
| 177 | + | ||
| 178 | + | t.Run("inspector error is returned", func(t *testing.T) { | |
| 179 | + | t.Parallel() | |
| 180 | + | f := newSigFixture(t) | |
| 181 | + | f.inspector.EXPECT().Inspect(domain.FormatGPG, "bad").Return("", "", fmt.Errorf("%w: nope", domain.ErrInvalid)) | |
| 182 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", "bad", []string{"a@b.c"}) | |
| 183 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | t.Run("builtin and duplicates are refused", func(t *testing.T) { | |
| 187 | + | t.Parallel() | |
| 188 | + | f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:old", Format: domain.FormatSSH}) | |
| 189 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "b").Return(builtinKey.ID, "ssh-ed25519 B", nil) | |
| 190 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "b", []string{"a@b.c"}) | |
| 191 | + | assert.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 192 | + | ||
| 193 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "o").Return("SHA256:old", "ssh-ed25519 O", nil) | |
| 194 | + | _, err = f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "o", []string{"a@b.c"}) | |
| 195 | + | assert.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 196 | + | }) | |
| 197 | + | ||
| 198 | + | t.Run("limit", func(t *testing.T) { | |
| 199 | + | t.Parallel() | |
| 200 | + | var stored []domain.TrustedKey | |
| 201 | + | for i := range domain.MaxTrustedKeys { | |
| 202 | + | stored = append(stored, domain.TrustedKey{ID: fmt.Sprintf("SHA256:%d", i), Format: domain.FormatSSH}) | |
| 203 | + | } | |
| 204 | + | f := newSigFixture(t, stored...) | |
| 205 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "n").Return("SHA256:new", "ssh-ed25519 N", nil) | |
| 206 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "n", []string{"a@b.c"}) | |
| 207 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 208 | + | }) | |
| 209 | + | ||
| 210 | + | t.Run("store error leaves the list unchanged", func(t *testing.T) { | |
| 211 | + | t.Parallel() | |
| 212 | + | f := newSigFixture(t) | |
| 213 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:k", "ssh-ed25519 K", nil) | |
| 214 | + | f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(errors.New("down")) | |
| 215 | + | _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"a@b.c"}) | |
| 216 | + | require.Error(t, err) | |
| 217 | + | keys, _ := f.sigs.ListTrustedKeys(ctx, owner) | |
| 218 | + | assert.Len(t, keys, 1) | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestRemoveTrustedKey(t *testing.T) { | |
| 223 | + | t.Parallel() | |
| 224 | + | ctx := context.Background() | |
| 225 | + | ||
| 226 | + | t.Run("builtin is refused without touching the store", func(t *testing.T) { | |
| 227 | + | t.Parallel() | |
| 228 | + | f := newSigFixture(t) | |
| 229 | + | err := f.sigs.RemoveTrustedKey(ctx, domain.Root(), builtinKey.ID) | |
| 230 | + | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 231 | + | }) | |
| 232 | + | ||
| 233 | + | t.Run("removes from the store and the list", func(t *testing.T) { | |
| 234 | + | t.Parallel() | |
| 235 | + | f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"}, domain.TrustedKey{ID: "SHA256:b"}) | |
| 236 | + | f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(nil) | |
| 237 | + | require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a")) | |
| 238 | + | keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root()) | |
| 239 | + | require.Len(t, keys, 2) | |
| 240 | + | assert.Equal(t, "SHA256:b", keys[1].ID) | |
| 241 | + | }) | |
| 242 | + | ||
| 243 | + | t.Run("store errors are returned and the key stays", func(t *testing.T) { | |
| 244 | + | t.Parallel() | |
| 245 | + | f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"}) | |
| 246 | + | f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(domain.ErrNotFound) | |
| 247 | + | assert.ErrorIs(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"), domain.ErrNotFound) | |
| 248 | + | keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root()) | |
| 249 | + | assert.Len(t, keys, 2) | |
| 250 | + | }) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestListWithoutBuiltin(t *testing.T) { | |
| 254 | + | t.Parallel() | |
| 255 | + | store := mocks.NewTrustedKeyStore(t) | |
| 256 | + | store.EXPECT().ListTrustedKeys(mock.Anything).Return([]domain.TrustedKey{{ID: "SHA256:a"}}, nil) | |
| 257 | + | s, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | keys, err := s.ListTrustedKeys(context.Background(), domain.Root()) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | require.Len(t, keys, 1) | |
| 262 | + | assert.Equal(t, "SHA256:a", keys[0].ID) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // rawCommit builds a commit object with the signature armor in a gpgsig header. | |
| 266 | + | func rawCommit(armor string) []byte { | |
| 267 | + | var b strings.Builder | |
| 268 | + | b.WriteString("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b.c> 1 +0000\ncommitter A <a@b.c> 1 +0000\n") | |
| 269 | + | if armor != "" { | |
| 270 | + | lines := strings.Split(strings.TrimSuffix(armor, "\n"), "\n") | |
| 271 | + | b.WriteString("gpgsig " + strings.TrimPrefix(lines[0], " ") + "\n") | |
| 272 | + | for _, l := range lines[1:] { | |
| 273 | + | b.WriteString(" " + strings.TrimPrefix(l, " ") + "\n") | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | b.WriteString("\nmessage\n") | |
| 277 | + | return []byte(b.String()) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func sigCommit(c string) domain.Commit { | |
| 281 | + | return domain.Commit{ID: oid(c), Committer: domain.Signature{Email: "a@b.c", Time: time.Unix(1700000000, 0)}} | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestVerifyCommits(t *testing.T) { | |
| 285 | + | t.Parallel() | |
| 286 | + | ctx := context.Background() | |
| 287 | + | repo := name(t, "r") | |
| 288 | + | stored := []domain.TrustedKey{ | |
| 289 | + | {ID: "SHA256:s1", Format: domain.FormatSSH, Emails: []string{"a@b.c"}}, | |
| 290 | + | {ID: "ABCD", Format: domain.FormatGPG, Emails: []string{"a@b.c"}}, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | t.Run("unsigned, x509 and unknown armor need no verifier", func(t *testing.T) { | |
| 294 | + | t.Parallel() | |
| 295 | + | f := newSigFixture(t, stored...) | |
| 296 | + | cs := []domain.Commit{sigCommit("1"), sigCommit("2"), sigCommit("3")} | |
| 297 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1"), oid("2"), oid("3")}). | |
| 298 | + | Return(map[domain.ObjectID][]byte{ | |
| 299 | + | oid("1"): rawCommit(""), | |
| 300 | + | oid("2"): rawCommit(x509Armor), | |
| 301 | + | oid("3"): rawCommit("-----BEGIN WHAT-----\n x\n -----END WHAT-----\n"), | |
| 302 | + | }, nil).Once() | |
| 303 | + | got := f.sigs.VerifyCommits(ctx, repo, cs) | |
| 304 | + | assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{ | |
| 305 | + | oid("1"): {Status: domain.StatusUnsigned}, | |
| 306 | + | oid("2"): {Format: domain.FormatX509, Status: domain.StatusUnsupported}, | |
| 307 | + | oid("3"): {Format: domain.FormatNone, Status: domain.StatusBadSignature}, | |
| 308 | + | }, got) | |
| 309 | + | }) | |
| 310 | + | ||
| 311 | + | t.Run("ssh gets the builtin and the ssh keys only", func(t *testing.T) { | |
| 312 | + | t.Parallel() | |
| 313 | + | f := newSigFixture(t, stored...) | |
| 314 | + | c := sigCommit("1") | |
| 315 | + | raw := rawCommit(sshArmor) | |
| 316 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil) | |
| 317 | + | want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:s1", KeyID: "SHA256:s1"} | |
| 318 | + | payload, sig, ok := domain.SplitCommitSignature(raw) | |
| 319 | + | require.True(t, ok) | |
| 320 | + | f.ssh.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{builtinKey, stored[0]}).Return(want) | |
| 321 | + | assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID]) | |
| 322 | + | }) | |
| 323 | + | ||
| 324 | + | t.Run("gpg gets the gpg keys only", func(t *testing.T) { | |
| 325 | + | t.Parallel() | |
| 326 | + | f := newSigFixture(t, stored...) | |
| 327 | + | c := sigCommit("1") | |
| 328 | + | raw := rawCommit(gpgArmor) | |
| 329 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil) | |
| 330 | + | want := domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusExpired} | |
| 331 | + | payload, sig, _ := domain.SplitCommitSignature(raw) | |
| 332 | + | f.gpg.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{stored[1]}).Return(want) | |
| 333 | + | assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID]) | |
| 334 | + | }) | |
| 335 | + | ||
| 336 | + | t.Run("results are cached per repository", func(t *testing.T) { | |
| 337 | + | t.Parallel() | |
| 338 | + | f := newSigFixture(t, stored...) | |
| 339 | + | c := sigCommit("1") | |
| 340 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}). | |
| 341 | + | Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once() | |
| 342 | + | first := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) | |
| 343 | + | second := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) | |
| 344 | + | assert.Equal(t, first, second) | |
| 345 | + | ||
| 346 | + | // Another repository is another entry. | |
| 347 | + | other := name(t, "other") | |
| 348 | + | f.objects.EXPECT().ReadCommitObjects(ctx, other, []domain.ObjectID{c.ID}). | |
| 349 | + | Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once() | |
| 350 | + | f.sigs.VerifyCommits(ctx, other, []domain.Commit{c}) | |
| 351 | + | }) | |
| 352 | + | ||
| 353 | + | t.Run("only the misses are read", func(t *testing.T) { | |
| 354 | + | t.Parallel() | |
| 355 | + | f := newSigFixture(t, stored...) | |
| 356 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}). | |
| 357 | + | Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once() | |
| 358 | + | f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}) | |
| 359 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}). | |
| 360 | + | Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once() | |
| 361 | + | got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")}) | |
| 362 | + | assert.Len(t, got, 2) | |
| 363 | + | }) | |
| 364 | + | ||
| 365 | + | t.Run("adding or removing a key invalidates the cache", func(t *testing.T) { | |
| 366 | + | t.Parallel() | |
| 367 | + | f := newSigFixture(t, stored...) | |
| 368 | + | c := sigCommit("1") | |
| 369 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}). | |
| 370 | + | Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Times(3) | |
| 371 | + | f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) | |
| 372 | + | ||
| 373 | + | f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:new", "ssh-ed25519 N", nil) | |
| 374 | + | f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil) | |
| 375 | + | _, err := f.sigs.AddTrustedKey(ctx, domain.Root(), domain.FormatSSH, "t", "k", []string{"a@b.c"}) | |
| 376 | + | require.NoError(t, err) | |
| 377 | + | f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 2 | |
| 378 | + | f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // cached | |
| 379 | + | ||
| 380 | + | f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:new").Return(nil) | |
| 381 | + | require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:new")) | |
| 382 | + | f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 3 | |
| 383 | + | }) | |
| 384 | + | ||
| 385 | + | t.Run("a read error returns the cached results only", func(t *testing.T) { | |
| 386 | + | t.Parallel() | |
| 387 | + | f := newSigFixture(t, stored...) | |
| 388 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}). | |
| 389 | + | Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once() | |
| 390 | + | f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}) | |
| 391 | + | ||
| 392 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).Return(nil, errors.New("disk")).Once() | |
| 393 | + | got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")}) | |
| 394 | + | assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{oid("1"): {Status: domain.StatusUnsigned}}, got) | |
| 395 | + | ||
| 396 | + | // A failure is not cached. | |
| 397 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}). | |
| 398 | + | Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once() | |
| 399 | + | assert.Len(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("2")}), 1) | |
| 400 | + | }) | |
| 401 | + | ||
| 402 | + | t.Run("a commit missing from the raw map is left out and not cached", func(t *testing.T) { | |
| 403 | + | t.Parallel() | |
| 404 | + | f := newSigFixture(t, stored...) | |
| 405 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).Return(map[domain.ObjectID][]byte{}, nil).Twice() | |
| 406 | + | assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})) | |
| 407 | + | assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})) | |
| 408 | + | }) | |
| 409 | + | ||
| 410 | + | t.Run("no commits, no read", func(t *testing.T) { | |
| 411 | + | t.Parallel() | |
| 412 | + | f := newSigFixture(t) | |
| 413 | + | assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, nil)) | |
| 414 | + | }) | |
| 415 | + | ||
| 416 | + | t.Run("the cache stays bounded", func(t *testing.T) { | |
| 417 | + | t.Parallel() | |
| 418 | + | f := newSigFixture(t) | |
| 419 | + | read := 0 // ids asked to the reader | |
| 420 | + | f.objects.EXPECT().ReadCommitObjects(ctx, repo, mock.Anything). | |
| 421 | + | RunAndReturn(func(_ context.Context, _ domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | |
| 422 | + | read += len(ids) | |
| 423 | + | m := make(map[domain.ObjectID][]byte, len(ids)) | |
| 424 | + | for _, id := range ids { | |
| 425 | + | m[id] = rawCommit("") | |
| 426 | + | } | |
| 427 | + | return m, nil | |
| 428 | + | }) | |
| 429 | + | mk := func(from, n int) []domain.Commit { | |
| 430 | + | cs := make([]domain.Commit, n) | |
| 431 | + | for i := range cs { | |
| 432 | + | cs[i] = sigCommit("a") | |
| 433 | + | cs[i].ID = numID(from + i) | |
| 434 | + | } | |
| 435 | + | return cs | |
| 436 | + | } | |
| 437 | + | const total = 2 * usecase.MaxSignatureCacheEntries | |
| 438 | + | for from := 0; from < total; from += 1000 { | |
| 439 | + | assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(from, 1000)), 1000) | |
| 440 | + | } | |
| 441 | + | assert.Equal(t, total, read) | |
| 442 | + | // The latest page is still cached. | |
| 443 | + | f.sigs.VerifyCommits(ctx, repo, mk(total-1000, 1000)) | |
| 444 | + | assert.Equal(t, total, read) | |
| 445 | + | // At most MaxSignatureCacheEntries of the total are kept. | |
| 446 | + | read = 0 | |
| 447 | + | assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(0, total)), total) | |
| 448 | + | assert.GreaterOrEqual(t, read, total-usecase.MaxSignatureCacheEntries) | |
| 449 | + | }) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func numID(n int) domain.ObjectID { | |
| 453 | + | id, err := domain.ParseObjectID(fmt.Sprintf("%040x", n+1)) | |
| 454 | + | if err != nil { | |
| 455 | + | panic(err) | |
| 456 | + | } | |
| 457 | + | return id | |
| 458 | + | } | |
M proto/ermite/storage/signing.proto +16 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | // Internal, unversioned storage schema of ermite: the commit signing key. | |
| 3 | + | // Internal, unversioned storage schema of ermite: the commit signing key and | |
| 4 | + | // the trusted signing keys. | |
| 4 | 5 | ||
| 5 | 6 | syntax = "proto3"; | |
| 6 | 7 | ||
| @@ -17,3 +18,17 @@ message SigningKey { | |||
| 17 | 18 | bytes seed = 1; | |
| 18 | 19 | google.protobuf.Timestamp created_at = 2; | |
| 19 | 20 | } | |
| 21 | + | ||
| 22 | + | // TrustedKey is stored under the key "signing/trusted/<id>": a public key | |
| 23 | + | // whose commit signatures ermite accepts for the listed committer emails. | |
| 24 | + | message TrustedKey { | |
| 25 | + | // The fingerprint: "SHA256:<base64>" for SSH, upper-case hex for GPG. | |
| 26 | + | string id = 1; | |
| 27 | + | // "ssh" or "gpg". | |
| 28 | + | string format = 2; | |
| 29 | + | string title = 3; | |
| 30 | + | // An authorized_keys line (SSH) or an armored public key block (GPG). | |
| 31 | + | string public_key = 4; | |
| 32 | + | repeated string emails = 5; | |
| 33 | + | google.protobuf.Timestamp created_at = 6; | |
| 34 | + | } | |
M skills/ermite/CLI.md +2 -0
| @@ -82,6 +82,8 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||
| 82 | 82 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 83 | 83 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |
| 84 | 84 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 85 | + | | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) | | |
| 86 | + | | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key | | |
| 85 | 87 | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | |
| 86 | 88 | | `web logout --all` | signs every browser out | | |
| 87 | 89 | | `web passkey list [--json]` | the owner's passkeys (`id`, `name`, `created_at`, `last_used_at`, `synced`); same rights as `web login`; fails with "passkeys are not available on this server" when the public URL is not https with a host name | | |