detached fda3c2b

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.
156func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
157	if !validTokenID(id) {
158		return domain.ErrNotFound
159	}
160	for range updateAttempts {
161		item, ok := s.db.Get(ctx, tokenKey(id))
162		if !ok {
163			return domain.ErrNotFound
164		}
165		t, err := decodeToken(item.Value)
166		if err != nil {
167			return err
168		}
169		if !at.After(t.LastUsedAt) {
170			return nil
171		}
172		t.LastUsedAt = at
173		val, err := encodeToken(t)
174		if err != nil {
175			return err
176		}
177		var b kv.Batch
178		b.RequireVersion(tokenKey(id), item.Version)
179		b.Put(tokenKey(id), val)
180		_, err = s.db.Apply(ctx, &b)
181		if errors.Is(err, kv.ErrConflict) {
182			continue
183		}
184		if err != nil {
185			return fmt.Errorf("touch token %q: %w", id, err)
186		}
187		return nil
188	}
189	return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
190}
191
192// encodeToken refuses what the use case already refused (scopes, branch
193// prefixes): a malformed prefix must never reach the store, where it would
194// limit a token wrongly or not at all.
195func encodeToken(t domain.Token) ([]byte, error) {
196	if !validTokenID(t.ID) {
197		return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
198	}
199	if !utf8.ValidString(t.Label) {
200		return nil, fmt.Errorf("%w: label is not valid UTF-8", domain.ErrInvalidToken)
201	}
202	if len(t.Scopes) == 0 {
203		return nil, fmt.Errorf("%w: no scopes", domain.ErrInvalidToken)
204	}
205	pb := &storagepb.Token{
206		Id:         t.ID,
207		Label:      t.Label,
208		Hash:       t.Hash[:],
209		ExpiresAt:  encodeTime(t.ExpiresAt),
210		CreatedAt:  encodeTime(t.CreatedAt),
211		LastUsedAt: encodeTime(t.LastUsedAt),
212	}
213	for _, sc := range t.Scopes {
214		if _, err := domain.ParseScope(sc.String()); err != nil {
215			return nil, err
216		}
217		pb.Scopes = append(pb.Scopes, sc.String())
218	}
219	for _, r := range t.Repos {
220		if r.IsZero() {
221			return nil, fmt.Errorf("%w: empty repository name", domain.ErrInvalidName)
222		}
223		pb.Repos = append(pb.Repos, r.String())
224	}
225	for _, bp := range t.BranchPrefixes {
226		if _, err := domain.ParseBranchPrefix(bp); err != nil {
227			return nil, err
228		}
229		pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
230	}
231	return mustMarshal(pb), nil
232}
233
234// decodeToken drops scopes it does not know (written by a newer ermite): a
235// token never gains rights by being read by an older build.
236func decodeToken(val []byte) (domain.Token, error) {
237	var pb storagepb.Token
238	if err := proto.Unmarshal(val, &pb); err != nil {
239		return domain.Token{}, fmt.Errorf("decode token: %w", err)
240	}
241	t := domain.Token{
242		ID:         pb.GetId(),
243		Label:      pb.GetLabel(),
244		ExpiresAt:  decodeTime(pb.GetExpiresAt()),
245		CreatedAt:  decodeTime(pb.GetCreatedAt()),
246		LastUsedAt: decodeTime(pb.GetLastUsedAt()),
247	}
248	if len(pb.GetHash()) != len(t.Hash) {
249		return domain.Token{}, fmt.Errorf("decode token %q: hash has %d bytes", t.ID, len(pb.GetHash()))
250	}
251	copy(t.Hash[:], pb.GetHash())
252	for _, s := range pb.GetScopes() {
253		if sc, err := domain.ParseScope(s); err == nil {
254			t.Scopes = append(t.Scopes, sc)
255		}
256	}
257	for _, r := range pb.GetRepos() {
258		name, err := domain.ParseRepoName(r)
259		if err != nil {
260			return domain.Token{}, fmt.Errorf("decode token %q: %w", t.ID, err)
261		}
262		t.Repos = append(t.Repos, name)
263	}
264	// Kept as stored: a token with prefixes stays limited, whatever they say.
265	t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
266	return t, nil
267}

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