v0.7.0 4c16dd8

catalogue.go RawHistoryFolder

1// Package catalogue owns the independently versioned remote discovery schema.
2package catalogue
3
4import (
5	"encoding/json"
6	"errors"
7	"fmt"
8	"math"
9	"time"
10	"unicode"
11	"unicode/utf8"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/protocol"
15)
16
17const (
18	// RemoteCatalogSchemaVersion is independent from the binary session protocol.
19	RemoteCatalogSchemaVersion uint16 = 3
20
21	RemoteCatalogMaxHosts        = 64
22	RemoteCatalogMaxSessions     = 256
23	RemoteCatalogMaxTabsPerSess  = 128
24	RemoteCatalogMaxNameBytes    = 256
25	RemoteCatalogMaxDetailBytes  = 512
26	RemoteCatalogMaxIDBytes      = 128
27	RemoteCatalogMaxCatalogBytes = 1 << 20
28)
29
30// RemoteCatalogCacheEntry is one immutable exact-schema host snapshot.
31// Incarnation binds the entry to one host-registry registration: a zero
32// incarnation is unbound advisory data and must never seed a registration.
33type RemoteCatalogCacheEntry struct {
34	Host        string
35	FetchedAt   time.Time
36	Incarnation [16]byte
37	Sessions    []RemoteCatalogSession
38}
39
40// RemoteCatalogTab is one bounded, untrusted tab presentation hint.
41type RemoteCatalogTab struct {
42	ID        string `json:"id"`
43	Index     uint16 `json:"index"`
44	Name      string `json:"name"`
45	Detail    string `json:"detail,omitempty"`
46	Attention bool   `json:"attention,omitempty"`
47}
48
49// RemoteCatalogSessionState is the JSON catalogue lifecycle contract.
50type RemoteCatalogSessionState string
51
52const (
53	RemoteCatalogSessionUp     RemoteCatalogSessionState = "up"
54	RemoteCatalogSessionDown   RemoteCatalogSessionState = "down"
55	RemoteCatalogSessionBroken RemoteCatalogSessionState = "broken"
56)
57
58func (s RemoteCatalogSessionState) Valid() bool {
59	switch s {
60	case RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken:
61		return true
62	default:
63		return false
64	}
65}
66
67// RemoteCatalogSession is one exact-identity session in the remote catalogue.
68type RemoteCatalogSession struct {
69	LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
70	Name        string                    `json:"name"`
71	State       RemoteCatalogSessionState `json:"state"`
72	Ephemeral   bool                      `json:"ephemeral"`
73	Tabs        []RemoteCatalogTab        `json:"tabs"`
74	Attached    bool                      `json:"attached"`
75	LastUsedSeq uint64                    `json:"last_used_seq,omitempty"`
76	ActiveTabID string                    `json:"active_tab_id,omitempty"`
77	Reason      string                    `json:"reason,omitempty"`
78}
79
80func CatalogTabCount(session RemoteCatalogSession) int { return len(session.Tabs) }
81
82func SaturateUint16(n int) uint16 {
83	if n <= 0 {
84		return 0
85	}
86	if n >= 1<<16 {
87		return math.MaxUint16
88	}
89	return uint16(n)
90}
91
92// RemoteCatalog is the versioned JSON envelope returned by remote-catalog.
93type RemoteCatalog struct {
94	ProtocolVersion uint16                 `json:"protocol_version"`
95	SchemaVersion   uint16                 `json:"schema_version"`
96	Sessions        []RemoteCatalogSession `json:"sessions"`
97}
98
99// RemoteCatalogVersionMismatchError reports an incompatible protocol or schema version.
100type RemoteCatalogVersionMismatchError struct {
101	Got  uint16
102	Want uint16
103	Kind string
104}
105
106func (e *RemoteCatalogVersionMismatchError) Error() string {
107	kind := e.Kind
108	if kind == "" {
109		kind = "protocol"
110	}
111	return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
112}
113
114var (
115	ErrInvalidRemoteCatalog       = errors.New("catalogue: invalid remote catalog")
116	ErrRemoteCatalogTooLarge      = errors.New("catalogue: remote catalog exceeds size limit")
117	ErrRemoteCatalogUnknownState  = errors.New("catalogue: remote catalog has unknown state")
118	ErrRemoteCatalogInvalidReason = errors.New("catalogue: remote catalog has unknown reason")
119)
120
121// ValidateRemoteCatalog applies the exact current schema and all bounds before use.
122func ValidateRemoteCatalog(c RemoteCatalog) error {
123	if c.ProtocolVersion != protocol.Version {
124		return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: protocol.Version, Kind: "protocol"}
125	}
126	if c.SchemaVersion != RemoteCatalogSchemaVersion {
127		return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
128	}
129	if len(c.Sessions) > RemoteCatalogMaxSessions {
130		return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
131	}
132	seenLifecycles := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
133	seenNames := make(map[string]struct{}, len(c.Sessions))
134	bytes := 0
135	for _, session := range c.Sessions {
136		if !validText(session.Name) || !validText(string(session.State)) || !validText(session.Reason) {
137			return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
138		}
139		if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
140			return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
141		}
142		if err := domain.ValidateSessionName(session.Name); err != nil {
143			return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
144		}
145		if !session.State.Valid() {
146			return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
147		}
148		if session.ActiveTabID != "" {
149			if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
150				return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
151			}
152		}
153		if session.Reason != "" && !validReason(session.Reason) {
154			return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
155		}
156		if session.LifecycleID == (domain.SessionLifecycleID{}) {
157			return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
158		}
159		if _, exists := seenNames[session.Name]; exists {
160			return fmt.Errorf("%w: duplicate session name %q", ErrInvalidRemoteCatalog, session.Name)
161		}
162		seenNames[session.Name] = struct{}{}
163		if prior, exists := seenLifecycles[session.LifecycleID]; exists {
164			return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
165		}
166		seenLifecycles[session.LifecycleID] = session.Name
167		if session.Tabs == nil {
168			return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
169		}
170		tabs := session.Tabs
171		if len(tabs) == 0 {
172			if session.ActiveTabID != "" {
173				return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
174			}
175		} else {
176			if len(tabs) > RemoteCatalogMaxTabsPerSess {
177				return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
178			}
179			tabIDs := make(map[string]struct{}, len(tabs))
180			for i, tab := range tabs {
181				if !validText(tab.ID) || !validText(tab.Name) || !validText(tab.Detail) {
182					return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
183				}
184				if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
185					return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
186				}
187				if tab.Index != uint16(i) {
188					return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
189				}
190				if session.State == RemoteCatalogSessionUp && tab.ID == "" {
191					return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
192				}
193				if tab.ID != "" {
194					if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
195						return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
196					}
197					if _, duplicate := tabIDs[tab.ID]; duplicate {
198						return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
199					}
200					tabIDs[tab.ID] = struct{}{}
201				}
202				bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
203			}
204			if session.ActiveTabID != "" {
205				if session.State != RemoteCatalogSessionUp {
206					return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
207				}
208				if _, ok := tabIDs[session.ActiveTabID]; !ok {
209					return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
210				}
211			}
212		}
213		bytes += len(session.Name) + len(session.State) + len(session.Reason)
214	}
215	if bytes > RemoteCatalogMaxCatalogBytes {
216		return ErrRemoteCatalogTooLarge
217	}
218	encoded, err := json.Marshal(c)
219	if err != nil {
220		return fmt.Errorf("%w: encoding catalogue: %v", ErrInvalidRemoteCatalog, err)
221	}
222	// marshalListing appends one newline after the JSON payload.
223	if len(encoded)+1 > RemoteCatalogMaxCatalogBytes {
224		return ErrRemoteCatalogTooLarge
225	}
226	return nil
227}
228
229// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache DTO.
230func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
231	if len(entries) > RemoteCatalogMaxHosts {
232		return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
233	}
234	seenHosts := make(map[string]struct{}, len(entries))
235	for _, entry := range entries {
236		if !validText(entry.Host) || len(entry.Host) > RemoteCatalogMaxNameBytes || domain.ValidateRemoteHostTarget(entry.Host) != nil {
237			return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
238		}
239		if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
240			return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
241		}
242		if _, exists := seenHosts[entry.Host]; exists {
243			return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
244		}
245		seenHosts[entry.Host] = struct{}{}
246		catalog := RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions}
247		if err := ValidateRemoteCatalog(catalog); err != nil {
248			return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
249		}
250	}
251	return nil
252}
253
254func validText(value string) bool {
255	if !utf8.ValidString(value) {
256		return false
257	}
258	for _, r := range value {
259		if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
260			return false
261		}
262	}
263	return true
264}
265
266func validReason(reason string) bool {
267	switch reason {
268	case "", domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale, domain.RemoteReasonHostUnreachable, domain.RemoteReasonVersionMismatch, domain.RemoteReasonSessionDown, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonNotFound, domain.RemoteReasonTimeout, domain.RemoteReasonMalformed, domain.RemoteReasonNoDaemon:
269		return true
270	default:
271		return false
272	}
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 12

Tags 11

↑↓ moveenter openesc close