v0.5.0 bfab294

tokens.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strings"
11	"time"
12	"unicode/utf8"
13
14	kv "bnema.dev/ermite-kv"
15	"google.golang.org/protobuf/proto"
16
17	storagepb "bnema.dev/ermite/gen/ermite/storage"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22const (
23	tokenPrefix     = "token/"
24	tokenHashPrefix = "tokenhash/"
25
26	// updateAttempts bounds the retries of a read-modify-write that loses a
27	// race with another write.
28	updateAttempts = 5
29)
30
31// Tokens is the ermite-kv backed ports.TokenStore. It shares the database of
32// the Store it comes from.
33type Tokens struct {
34	db *kv.DB
35}
36
37var _ ports.TokenStore = (*Tokens)(nil)
38
39// Tokens returns the token store over the same database. It is valid until
40// the Store is closed.
41func (s *Store) Tokens() *Tokens { return &Tokens{db: s.db} }
42
43func tokenKey(id string) []byte              { return []byte(tokenPrefix + id) }
44func tokenHashKey(h domain.TokenHash) []byte { return []byte(tokenHashPrefix + h.String()) }
45
46// validTokenID reports whether id is usable inside a key.
47func validTokenID(id string) bool {
48	return id != "" && len(id) <= 64 && !strings.ContainsAny(id, "/\x00") && utf8.ValidString(id)
49}
50
51// Create stores token/<id> and tokenhash/<hex> in one batch, each with an
52// "absent" precondition: a taken id or hash yields domain.ErrAlreadyExists.
53func (s *Tokens) Create(ctx context.Context, t domain.Token) error {
54	val, err := encodeToken(t)
55	if err != nil {
56		return err
57	}
58	var b kv.Batch
59	b.RequireAbsent(tokenKey(t.ID))
60	b.RequireAbsent(tokenHashKey(t.Hash))
61	b.Put(tokenKey(t.ID), val)
62	b.Put(tokenHashKey(t.Hash), []byte(t.ID))
63	if _, err := s.db.Apply(ctx, &b); err != nil {
64		if errors.Is(err, kv.ErrConflict) {
65			return domain.ErrAlreadyExists
66		}
67		return fmt.Errorf("create token %q: %w", t.ID, err)
68	}
69	return nil
70}
71
72// Get reads token/<id>.
73func (s *Tokens) Get(ctx context.Context, id string) (domain.Token, error) {
74	if !validTokenID(id) {
75		return domain.Token{}, domain.ErrNotFound
76	}
77	item, ok := s.db.Get(ctx, tokenKey(id))
78	if !ok {
79		return domain.Token{}, domain.ErrNotFound
80	}
81	return decodeToken(item.Value)
82}
83
84// FindByHash resolves the hash index, then reads the token.
85func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) {
86	snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree
87	idx, ok := snap.Get(ctx, tokenHashKey(hash))
88	if !ok {
89		return domain.Token{}, domain.ErrNotFound
90	}
91	item, ok := snap.Get(ctx, tokenKey(string(idx.Value)))
92	if !ok {
93		return domain.Token{}, domain.ErrNotFound
94	}
95	t, err := decodeToken(item.Value)
96	if err != nil {
97		return domain.Token{}, err
98	}
99	if t.Hash != hash {
100		return domain.Token{}, fmt.Errorf("token %q: hash index does not match the stored hash", t.ID)
101	}
102	return t, nil
103}
104
105// List returns every token ordered by creation time, then id.
106func (s *Tokens) List(ctx context.Context) ([]domain.Token, error) {
107	var out []domain.Token
108	for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
109		t, err := decodeToken(item.Value)
110		if err != nil {
111			return nil, fmt.Errorf("decode %s: %w", key, err)
112		}
113		out = append(out, t)
114	}
115	slices.SortStableFunc(out, func(a, b domain.Token) int {
116		if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
117			return c
118		}
119		return strings.Compare(a.ID, b.ID)
120	})
121	return out, nil
122}
123
124// Revoke deletes token/<id> and its hash index in one batch.
125func (s *Tokens) Revoke(ctx context.Context, id string) error {
126	if !validTokenID(id) {
127		return domain.ErrNotFound
128	}
129	for range updateAttempts {
130		item, ok := s.db.Get(ctx, tokenKey(id))
131		if !ok {
132			return domain.ErrNotFound
133		}
134		t, err := decodeToken(item.Value)
135		if err != nil {
136			return err
137		}
138		var b kv.Batch
139		b.RequireVersion(tokenKey(id), item.Version)
140		b.Delete(tokenKey(id))
141		b.Delete(tokenHashKey(t.Hash))
142		_, err = s.db.Apply(ctx, &b)
143		if errors.Is(err, kv.ErrConflict) {
144			continue // touched or revoked meanwhile: read again
145		}
146		if err != nil {
147			return fmt.Errorf("revoke token %q: %w", id, err)
148		}
149		return nil
150	}
151	return fmt.Errorf("revoke token %q: %w", id, domain.ErrConflict)
152}
153
154// Touch sets last_used_at when the given time is later than the stored one,
155// so that racing writers never move it back.
156//
157//hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy
158func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
159	if !validTokenID(id) {
160		return domain.ErrNotFound
161	}
162	for range updateAttempts {
163		item, ok := s.db.Get(ctx, tokenKey(id))
164		if !ok {
165			return domain.ErrNotFound
166		}
167		t, err := decodeToken(item.Value)
168		if err != nil {
169			return err
170		}
171		if !at.After(t.LastUsedAt) {
172			return nil
173		}
174		t.LastUsedAt = at
175		val, err := encodeToken(t)
176		if err != nil {
177			return err
178		}
179		var b kv.Batch
180		b.RequireVersion(tokenKey(id), item.Version)
181		b.Put(tokenKey(id), val)
182		_, err = s.db.Apply(ctx, &b)
183		if errors.Is(err, kv.ErrConflict) {
184			continue
185		}
186		if err != nil {
187			return fmt.Errorf("touch token %q: %w", id, err)
188		}
189		return nil
190	}
191	return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
192}
193
194func encodeToken(t domain.Token) ([]byte, error) {
195	if !validTokenID(t.ID) {
196		return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
197	}
198	if !utf8.ValidString(t.Label) {
199		return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
200	}
201	if len(t.Scopes) == 0 {
202		return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
203	}
204	pb := &storagepb.Token{
205		Id:         t.ID,
206		Label:      t.Label,
207		Hash:       t.Hash[:],
208		ExpiresAt:  encodeTime(t.ExpiresAt),
209		CreatedAt:  encodeTime(t.CreatedAt),
210		LastUsedAt: encodeTime(t.LastUsedAt),
211	}
212	for _, sc := range t.Scopes {
213		if _, err := domain.ParseScope(sc.String()); err != nil {
214			return nil, err
215		}
216		pb.Scopes = append(pb.Scopes, sc.String())
217	}
218	for _, r := range t.Repos {
219		if r.IsZero() {
220			return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
221		}
222		pb.Repos = append(pb.Repos, r.String())
223	}
224	return mustMarshal(pb), nil
225}
226
227// decodeToken drops scopes it does not know (written by a newer ermite): a
228// token never gains rights by being read by an older build.
229//
230//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
231func decodeToken(val []byte) (domain.Token, error) {
232	var pb storagepb.Token
233	if err := proto.Unmarshal(val, &pb); err != nil {
234		return domain.Token{}, fmt.Errorf("decode token: %w", err)
235	}
236	t := domain.Token{
237		ID:         pb.GetId(),
238		Label:      pb.GetLabel(),
239		ExpiresAt:  decodeTime(pb.GetExpiresAt()),
240		CreatedAt:  decodeTime(pb.GetCreatedAt()),
241		LastUsedAt: decodeTime(pb.GetLastUsedAt()),
242	}
243	if len(pb.GetHash()) != len(t.Hash) {
244		return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
245	}
246	copy(t.Hash[:], pb.GetHash())
247	for _, s := range pb.GetScopes() {
248		if sc, err := domain.ParseScope(s); err == nil {
249			t.Scopes = append(t.Scopes, sc)
250		}
251	}
252	for _, r := range pb.GetRepos() {
253		name, err := domain.ParseRepoName(r)
254		if err != nil {
255			return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
256		}
257		t.Repos = append(t.Repos, name)
258	}
259	return t, nil
260}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close