v0.18.0 0cea275

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
194// encodeToken refuses what the use case already refused (scopes, branch
195// prefixes): a malformed prefix must never reach the store, where it would
196// limit a token wrongly or not at all.
197//
198//hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy
199func encodeToken(t domain.Token) ([]byte, error) {
200	if !validTokenID(t.ID) {
201		return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
202	}
203	if !utf8.ValidString(t.Label) {
204		return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
205	}
206	if len(t.Scopes) == 0 {
207		return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
208	}
209	pb := &storagepb.Token{
210		Id:         t.ID,
211		Label:      t.Label,
212		Hash:       t.Hash[:],
213		ExpiresAt:  encodeTime(t.ExpiresAt),
214		CreatedAt:  encodeTime(t.CreatedAt),
215		LastUsedAt: encodeTime(t.LastUsedAt),
216	}
217	for _, sc := range t.Scopes {
218		if _, err := domain.ParseScope(sc.String()); err != nil {
219			return nil, err
220		}
221		pb.Scopes = append(pb.Scopes, sc.String())
222	}
223	for _, r := range t.Repos {
224		if r.IsZero() {
225			return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
226		}
227		pb.Repos = append(pb.Repos, r.String())
228	}
229	for _, bp := range t.BranchPrefixes {
230		if _, err := domain.ParseBranchPrefix(bp); err != nil {
231			return nil, err
232		}
233		pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
234	}
235	return mustMarshal(pb), nil
236}
237
238// decodeToken drops scopes it does not know (written by a newer ermite): a
239// token never gains rights by being read by an older build.
240//
241//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
242func decodeToken(val []byte) (domain.Token, error) {
243	var pb storagepb.Token
244	if err := proto.Unmarshal(val, &pb); err != nil {
245		return domain.Token{}, fmt.Errorf("decode token: %w", err)
246	}
247	t := domain.Token{
248		ID:         pb.GetId(),
249		Label:      pb.GetLabel(),
250		ExpiresAt:  decodeTime(pb.GetExpiresAt()),
251		CreatedAt:  decodeTime(pb.GetCreatedAt()),
252		LastUsedAt: decodeTime(pb.GetLastUsedAt()),
253	}
254	if len(pb.GetHash()) != len(t.Hash) {
255		return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
256	}
257	copy(t.Hash[:], pb.GetHash())
258	for _, s := range pb.GetScopes() {
259		if sc, err := domain.ParseScope(s); err == nil {
260			t.Scopes = append(t.Scopes, sc)
261		}
262	}
263	for _, r := range pb.GetRepos() {
264		name, err := domain.ParseRepoName(r)
265		if err != nil {
266			return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
267		}
268		t.Repos = append(t.Repos, name)
269	}
270	// Kept as stored: a token with prefixes stays limited, whatever they say.
271	t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
272	return t, nil
273}

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