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}