detached 2f0d2b4

refactor(brokerstore): drop legacy host and cache import

commit
2f0d2b4ab1080e6888e61f917e5baad3d174f271
author
brice · · 2026-09-23 12:14 UTC
parent
2d72cbf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +148 -826
A internal/adapters/brokerstore/json.go +91 -0
@@ -0,0 +1,91 @@
1+package brokerstore
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "io"
8+ "os"
9+ "syscall"
10+ "unicode/utf8"
11+)
12+
13+func readBounded(path string) ([]byte, error) {
14+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
15+ if err != nil {
16+ return nil, err
17+ }
18+ f := os.NewFile(uintptr(fd), path)
19+ defer f.Close()
20+ st, err := f.Stat()
21+ if err != nil {
22+ return nil, err
23+ }
24+ if !st.Mode().IsRegular() || st.Size() > MaxFileBytes {
25+ return nil, errors.New("brokerstore: invalid file type or size")
26+ }
27+ b, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1))
28+ if len(b) > MaxFileBytes {
29+ return nil, errors.New("brokerstore: oversized input")
30+ }
31+ return b, err
32+}
33+
34+// strict rejects duplicate keys as well as unknown fields, trailing JSON,
35+// invalid UTF-8 and excessive nesting. Byte bounds apply before decoding.
36+func strict(raw []byte, dst any) error {
37+ if len(raw) > MaxFileBytes || !utf8.Valid(raw) {
38+ return errors.New("brokerstore: invalid JSON size or encoding")
39+ }
40+ scan := json.NewDecoder(bytes.NewReader(raw))
41+ if err := scanValue(scan, 0); err != nil {
42+ return err
43+ }
44+ if _, err := scan.Token(); err != io.EOF {
45+ return errors.New("brokerstore: trailing JSON")
46+ }
47+ dec := json.NewDecoder(bytes.NewReader(raw))
48+ dec.DisallowUnknownFields()
49+ return dec.Decode(dst)
50+}
51+func scanValue(d *json.Decoder, depth int) error {
52+ if depth > 64 {
53+ return errors.New("brokerstore: JSON nesting limit")
54+ }
55+ t, err := d.Token()
56+ if err != nil {
57+ return err
58+ }
59+ delim, ok := t.(json.Delim)
60+ if !ok {
61+ return nil
62+ }
63+ switch delim {
64+ case '{':
65+ seen := map[string]bool{}
66+ for d.More() {
67+ key, err := d.Token()
68+ if err != nil {
69+ return err
70+ }
71+ k, ok := key.(string)
72+ if !ok || seen[k] {
73+ return errors.New("brokerstore: duplicate JSON key")
74+ }
75+ seen[k] = true
76+ if err := scanValue(d, depth+1); err != nil {
77+ return err
78+ }
79+ }
80+ case '[':
81+ for d.More() {
82+ if err := scanValue(d, depth+1); err != nil {
83+ return err
84+ }
85+ }
86+ default:
87+ return errors.New("brokerstore: unexpected delimiter")
88+ }
89+ _, err = d.Token()
90+ return err
91+}
@@ -0,0 +1,91 @@
1+package brokerstore
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "io"
8+ "os"
9+ "syscall"
10+ "unicode/utf8"
11+)
12+
13+func readBounded(path string) ([]byte, error) {
14+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
15+ if err != nil {
16+ return nil, err
17+ }
18+ f := os.NewFile(uintptr(fd), path)
19+ defer f.Close()
20+ st, err := f.Stat()
21+ if err != nil {
22+ return nil, err
23+ }
24+ if !st.Mode().IsRegular() || st.Size() > MaxFileBytes {
25+ return nil, errors.New("brokerstore: invalid file type or size")
26+ }
27+ b, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1))
28+ if len(b) > MaxFileBytes {
29+ return nil, errors.New("brokerstore: oversized input")
30+ }
31+ return b, err
32+}
33+
34+// strict rejects duplicate keys as well as unknown fields, trailing JSON,
35+// invalid UTF-8 and excessive nesting. Byte bounds apply before decoding.
36+func strict(raw []byte, dst any) error {
37+ if len(raw) > MaxFileBytes || !utf8.Valid(raw) {
38+ return errors.New("brokerstore: invalid JSON size or encoding")
39+ }
40+ scan := json.NewDecoder(bytes.NewReader(raw))
41+ if err := scanValue(scan, 0); err != nil {
42+ return err
43+ }
44+ if _, err := scan.Token(); err != io.EOF {
45+ return errors.New("brokerstore: trailing JSON")
46+ }
47+ dec := json.NewDecoder(bytes.NewReader(raw))
48+ dec.DisallowUnknownFields()
49+ return dec.Decode(dst)
50+}
51+func scanValue(d *json.Decoder, depth int) error {
52+ if depth > 64 {
53+ return errors.New("brokerstore: JSON nesting limit")
54+ }
55+ t, err := d.Token()
56+ if err != nil {
57+ return err
58+ }
59+ delim, ok := t.(json.Delim)
60+ if !ok {
61+ return nil
62+ }
63+ switch delim {
64+ case '{':
65+ seen := map[string]bool{}
66+ for d.More() {
67+ key, err := d.Token()
68+ if err != nil {
69+ return err
70+ }
71+ k, ok := key.(string)
72+ if !ok || seen[k] {
73+ return errors.New("brokerstore: duplicate JSON key")
74+ }
75+ seen[k] = true
76+ if err := scanValue(d, depth+1); err != nil {
77+ return err
78+ }
79+ }
80+ case '[':
81+ for d.More() {
82+ if err := scanValue(d, depth+1); err != nil {
83+ return err
84+ }
85+ }
86+ default:
87+ return errors.New("brokerstore: unexpected delimiter")
88+ }
89+ _, err = d.Token()
90+ return err
91+}
D internal/adapters/brokerstore/legacy.go +0 -289
@@ -1,289 +0,0 @@
1-package brokerstore
2-
3-import (
4- "bytes"
5- "crypto/rand"
6- "encoding/hex"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "io"
11- "os"
12- "syscall"
13- "unicode/utf8"
14-
15- "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
17-)
18-
19-func readBounded(path string) ([]byte, error) {
20- fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
21- if err != nil {
22- return nil, err
23- }
24- f := os.NewFile(uintptr(fd), path)
25- defer f.Close()
26- st, err := f.Stat()
27- if err != nil {
28- return nil, err
29- }
30- if !st.Mode().IsRegular() || st.Size() > MaxFileBytes {
31- return nil, errors.New("brokerstore: invalid file type or size")
32- }
33- b, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1))
34- if len(b) > MaxFileBytes {
35- return nil, errors.New("brokerstore: oversized input")
36- }
37- return b, err
38-}
39-
40-// strict rejects duplicate keys as well as unknown fields, trailing JSON,
41-// invalid UTF-8 and excessive nesting. Byte bounds apply before decoding.
42-func strict(raw []byte, dst any) error {
43- if len(raw) > MaxFileBytes || !utf8.Valid(raw) {
44- return errors.New("brokerstore: invalid JSON size or encoding")
45- }
46- scan := json.NewDecoder(bytes.NewReader(raw))
47- if err := scanValue(scan, 0); err != nil {
48- return err
49- }
50- if _, err := scan.Token(); err != io.EOF {
51- return errors.New("brokerstore: trailing JSON")
52- }
53- dec := json.NewDecoder(bytes.NewReader(raw))
54- dec.DisallowUnknownFields()
55- return dec.Decode(dst)
56-}
57-func scanValue(d *json.Decoder, depth int) error {
58- if depth > 64 {
59- return errors.New("brokerstore: JSON nesting limit")
60- }
61- t, err := d.Token()
62- if err != nil {
63- return err
64- }
65- delim, ok := t.(json.Delim)
66- if !ok {
67- return nil
68- }
69- switch delim {
70- case '{':
71- seen := map[string]bool{}
72- for d.More() {
73- key, err := d.Token()
74- if err != nil {
75- return err
76- }
77- k, ok := key.(string)
78- if !ok || seen[k] {
79- return errors.New("brokerstore: duplicate JSON key")
80- }
81- seen[k] = true
82- if err := scanValue(d, depth+1); err != nil {
83- return err
84- }
85- }
86- case '[':
87- for d.More() {
88- if err := scanValue(d, depth+1); err != nil {
89- return err
90- }
91- }
92- default:
93- return errors.New("brokerstore: unexpected delimiter")
94- }
95- _, err = d.Token()
96- return err
97-}
98-func source(path string) ([]byte, error) {
99- if path == "" {
100- return nil, nil
101- }
102- return readBounded(path)
103-}
104-func migrate(o Options) (recovery, error) {
105- var r recovery
106- var err error
107- if r.Hosts, err = source(o.LegacyHosts); err != nil {
108- return r, err
109- }
110- if r.Cache, err = source(o.LegacyCache); err != nil {
111- return r, err
112- }
113- r.State.Manifest = manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{
114- {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)},
115- {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)},
116- }}
117- r.State.Hosts = ports.BrokerHosts{Revision: 1}
118- if o.InitialImportProvided {
119- r.State.Hosts.Hosts = ports.CloneBrokerHostRecords(o.InitialHosts)
120- if err := ports.ValidateBrokerHostRecords(r.State.Hosts.Hosts); err != nil && len(r.State.Hosts.Hosts) != 0 {
121- return r, fmt.Errorf("brokerstore: initial import: %w", err)
122- }
123- } else if o.LegacyHosts != "" {
124- r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies)
125- if err != nil {
126- return r, err
127- }
128- }
129- if o.LegacyCache != "" {
130- entries, err := decodeCache(r.Cache)
131- if err != nil {
132- return r, err
133- }
134- for _, e := range entries {
135- for _, h := range r.State.Hosts.Hosts {
136- // Unbound v2/v3 cache data is validated and backed up, never promoted
137- // to authority. v4 can seed only its exact surviving incarnation.
138- if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation {
139- // A legacy cache entry carries only observed sessions: daemon
140- // identity, incarnation, protocol version, and capabilities stay
141- // zero (never invented), and policy stays absent because the
142- // loader re-stamps it from membership.
143- r.State.Snapshot.Daemons = append(r.State.Snapshot.Daemons, ports.BrokerDaemonObservation{
144- Endpoint: e.Host,
145- DisplayOrigin: domain.RemoteDisplayOrigin(e.Host),
146- Registration: h.Registration,
147- Availability: domain.RemoteAvailabilityUnknown,
148- InventoryKnown: true,
149- LastSuccess: e.FetchedAt,
150- Sessions: e.Sessions,
151- })
152- }
153- }
154- }
155- }
156- if len(r.State.Snapshot.Daemons) > 0 {
157- var epoch [8]byte
158- if _, err = rand.Read(epoch[:]); err != nil {
159- return r, err
160- }
161- var n uint64
162- for _, b := range epoch {
163- n = n<<8 | uint64(b)
164- }
165- if n == 0 {
166- n = 1
167- }
168- r.State.Snapshot.Epoch = ports.BrokerEpoch(n)
169- r.State.Snapshot.Revision = 1
170- }
171- return r, validate(r.State)
172-}
173-
174-type legacyRecord struct {
175- Endpoint string `json:"endpoint"`
176- Incarnation string `json:"incarnation"`
177- Generation uint64 `json:"generation"`
178-}
179-
180-func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.BrokerHostRecord, error) {
181- var header struct {
182- Version int `json:"version"`
183- }
184- if err := json.Unmarshal(raw, &header); err != nil {
185- return nil, err
186- }
187- var pinned, learned []legacyRecord
188- switch header.Version {
189- case 1:
190- var f struct {
191- Version int `json:"version"`
192- Hosts []string `json:"hosts"`
193- }
194- if err := strict(raw, &f); err != nil {
195- return nil, err
196- }
197- if f.Hosts == nil {
198- return nil, errors.New("brokerstore: missing hosts")
199- }
200- for _, e := range f.Hosts {
201- learned = append(learned, legacyRecord{Endpoint: e})
202- }
203- case 2:
204- var f struct {
205- Version int `json:"version"`
206- Pinned []string `json:"pinned"`
207- Learned []string `json:"learned"`
208- }
209- if err := strict(raw, &f); err != nil {
210- return nil, err
211- }
212- if f.Pinned == nil || f.Learned == nil {
213- return nil, errors.New("brokerstore: missing memberships")
214- }
215- for _, e := range f.Pinned {
216- pinned = append(pinned, legacyRecord{Endpoint: e})
217- }
218- for _, e := range f.Learned {
219- learned = append(learned, legacyRecord{Endpoint: e})
220- }
221- case 3:
222- var f struct {
223- Version int `json:"version"`
224- Pinned []legacyRecord `json:"pinned"`
225- Learned []legacyRecord `json:"learned"`
226- }
227- if err := strict(raw, &f); err != nil {
228- return nil, err
229- }
230- if f.Pinned == nil || f.Learned == nil {
231- return nil, errors.New("brokerstore: missing memberships")
232- }
233- pinned, learned = f.Pinned, f.Learned
234- default:
235- return nil, fmt.Errorf("brokerstore: unsupported hosts version %d", header.Version)
236- }
237- if len(pinned) > ports.BrokerMaxHosts || len(learned) > ports.BrokerMaxHosts {
238- return nil, errors.New("brokerstore: too many hosts")
239- }
240- var out []ports.BrokerHostRecord
241- indices := map[string]int{}
242- for group, records := range [][]legacyRecord{pinned, learned} {
243- seen := map[string]bool{}
244- for _, record := range records {
245- if seen[record.Endpoint] {
246- return nil, errors.New("brokerstore: duplicate membership")
247- }
248- seen[record.Endpoint] = true
249- reg := domain.RemoteRegistration{Endpoint: record.Endpoint, Generation: domain.RemoteGeneration(record.Generation)}
250- if header.Version == 3 {
251- b, err := hex.DecodeString(record.Incarnation)
252- if err != nil || len(b) != 16 {
253- return nil, errors.New("brokerstore: invalid incarnation")
254- }
255- copy(reg.Incarnation[:], b)
256- }
257- if index, ok := indices[record.Endpoint]; ok {
258- if header.Version == 3 && !reg.Equal(out[index].Registration) {
259- return nil, errors.New("brokerstore: conflicting registration")
260- }
261- out[index].Learned = true
262- continue
263- }
264- if header.Version != 3 {
265- if _, err := rand.Read(reg.Incarnation[:]); err != nil {
266- return nil, err
267- }
268- reg.Generation = 1
269- }
270- if err := reg.Validate(); err != nil {
271- return nil, err
272- }
273- policy, ok := policies[record.Endpoint]
274- if !ok {
275- return nil, fmt.Errorf("brokerstore: explicit policy required for %q", record.Endpoint)
276- }
277- if err := policy.Validate(); err != nil {
278- return nil, err
279- }
280- indices[record.Endpoint] = len(out)
281- route, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint)
282- if err != nil {
283- return nil, fmt.Errorf("brokerstore: upgrade route for %q: %w", record.Endpoint, err)
284- }
285- out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy, Route: route})
286- }
287- }
288- return out, nil
289-}
@@ -1,289 +0,0 @@
1-package brokerstore
2-
3-import (
4- "bytes"
5- "crypto/rand"
6- "encoding/hex"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "io"
11- "os"
12- "syscall"
13- "unicode/utf8"
14-
15- "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
17-)
18-
19-func readBounded(path string) ([]byte, error) {
20- fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
21- if err != nil {
22- return nil, err
23- }
24- f := os.NewFile(uintptr(fd), path)
25- defer f.Close()
26- st, err := f.Stat()
27- if err != nil {
28- return nil, err
29- }
30- if !st.Mode().IsRegular() || st.Size() > MaxFileBytes {
31- return nil, errors.New("brokerstore: invalid file type or size")
32- }
33- b, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1))
34- if len(b) > MaxFileBytes {
35- return nil, errors.New("brokerstore: oversized input")
36- }
37- return b, err
38-}
39-
40-// strict rejects duplicate keys as well as unknown fields, trailing JSON,
41-// invalid UTF-8 and excessive nesting. Byte bounds apply before decoding.
42-func strict(raw []byte, dst any) error {
43- if len(raw) > MaxFileBytes || !utf8.Valid(raw) {
44- return errors.New("brokerstore: invalid JSON size or encoding")
45- }
46- scan := json.NewDecoder(bytes.NewReader(raw))
47- if err := scanValue(scan, 0); err != nil {
48- return err
49- }
50- if _, err := scan.Token(); err != io.EOF {
51- return errors.New("brokerstore: trailing JSON")
52- }
53- dec := json.NewDecoder(bytes.NewReader(raw))
54- dec.DisallowUnknownFields()
55- return dec.Decode(dst)
56-}
57-func scanValue(d *json.Decoder, depth int) error {
58- if depth > 64 {
59- return errors.New("brokerstore: JSON nesting limit")
60- }
61- t, err := d.Token()
62- if err != nil {
63- return err
64- }
65- delim, ok := t.(json.Delim)
66- if !ok {
67- return nil
68- }
69- switch delim {
70- case '{':
71- seen := map[string]bool{}
72- for d.More() {
73- key, err := d.Token()
74- if err != nil {
75- return err
76- }
77- k, ok := key.(string)
78- if !ok || seen[k] {
79- return errors.New("brokerstore: duplicate JSON key")
80- }
81- seen[k] = true
82- if err := scanValue(d, depth+1); err != nil {
83- return err
84- }
85- }
86- case '[':
87- for d.More() {
88- if err := scanValue(d, depth+1); err != nil {
89- return err
90- }
91- }
92- default:
93- return errors.New("brokerstore: unexpected delimiter")
94- }
95- _, err = d.Token()
96- return err
97-}
98-func source(path string) ([]byte, error) {
99- if path == "" {
100- return nil, nil
101- }
102- return readBounded(path)
103-}
104-func migrate(o Options) (recovery, error) {
105- var r recovery
106- var err error
107- if r.Hosts, err = source(o.LegacyHosts); err != nil {
108- return r, err
109- }
110- if r.Cache, err = source(o.LegacyCache); err != nil {
111- return r, err
112- }
113- r.State.Manifest = manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{
114- {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)},
115- {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)},
116- }}
117- r.State.Hosts = ports.BrokerHosts{Revision: 1}
118- if o.InitialImportProvided {
119- r.State.Hosts.Hosts = ports.CloneBrokerHostRecords(o.InitialHosts)
120- if err := ports.ValidateBrokerHostRecords(r.State.Hosts.Hosts); err != nil && len(r.State.Hosts.Hosts) != 0 {
121- return r, fmt.Errorf("brokerstore: initial import: %w", err)
122- }
123- } else if o.LegacyHosts != "" {
124- r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies)
125- if err != nil {
126- return r, err
127- }
128- }
129- if o.LegacyCache != "" {
130- entries, err := decodeCache(r.Cache)
131- if err != nil {
132- return r, err
133- }
134- for _, e := range entries {
135- for _, h := range r.State.Hosts.Hosts {
136- // Unbound v2/v3 cache data is validated and backed up, never promoted
137- // to authority. v4 can seed only its exact surviving incarnation.
138- if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation {
139- // A legacy cache entry carries only observed sessions: daemon
140- // identity, incarnation, protocol version, and capabilities stay
141- // zero (never invented), and policy stays absent because the
142- // loader re-stamps it from membership.
143- r.State.Snapshot.Daemons = append(r.State.Snapshot.Daemons, ports.BrokerDaemonObservation{
144- Endpoint: e.Host,
145- DisplayOrigin: domain.RemoteDisplayOrigin(e.Host),
146- Registration: h.Registration,
147- Availability: domain.RemoteAvailabilityUnknown,
148- InventoryKnown: true,
149- LastSuccess: e.FetchedAt,
150- Sessions: e.Sessions,
151- })
152- }
153- }
154- }
155- }
156- if len(r.State.Snapshot.Daemons) > 0 {
157- var epoch [8]byte
158- if _, err = rand.Read(epoch[:]); err != nil {
159- return r, err
160- }
161- var n uint64
162- for _, b := range epoch {
163- n = n<<8 | uint64(b)
164- }
165- if n == 0 {
166- n = 1
167- }
168- r.State.Snapshot.Epoch = ports.BrokerEpoch(n)
169- r.State.Snapshot.Revision = 1
170- }
171- return r, validate(r.State)
172-}
173-
174-type legacyRecord struct {
175- Endpoint string `json:"endpoint"`
176- Incarnation string `json:"incarnation"`
177- Generation uint64 `json:"generation"`
178-}
179-
180-func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.BrokerHostRecord, error) {
181- var header struct {
182- Version int `json:"version"`
183- }
184- if err := json.Unmarshal(raw, &header); err != nil {
185- return nil, err
186- }
187- var pinned, learned []legacyRecord
188- switch header.Version {
189- case 1:
190- var f struct {
191- Version int `json:"version"`
192- Hosts []string `json:"hosts"`
193- }
194- if err := strict(raw, &f); err != nil {
195- return nil, err
196- }
197- if f.Hosts == nil {
198- return nil, errors.New("brokerstore: missing hosts")
199- }
200- for _, e := range f.Hosts {
201- learned = append(learned, legacyRecord{Endpoint: e})
202- }
203- case 2:
204- var f struct {
205- Version int `json:"version"`
206- Pinned []string `json:"pinned"`
207- Learned []string `json:"learned"`
208- }
209- if err := strict(raw, &f); err != nil {
210- return nil, err
211- }
212- if f.Pinned == nil || f.Learned == nil {
213- return nil, errors.New("brokerstore: missing memberships")
214- }
215- for _, e := range f.Pinned {
216- pinned = append(pinned, legacyRecord{Endpoint: e})
217- }
218- for _, e := range f.Learned {
219- learned = append(learned, legacyRecord{Endpoint: e})
220- }
221- case 3:
222- var f struct {
223- Version int `json:"version"`
224- Pinned []legacyRecord `json:"pinned"`
225- Learned []legacyRecord `json:"learned"`
226- }
227- if err := strict(raw, &f); err != nil {
228- return nil, err
229- }
230- if f.Pinned == nil || f.Learned == nil {
231- return nil, errors.New("brokerstore: missing memberships")
232- }
233- pinned, learned = f.Pinned, f.Learned
234- default:
235- return nil, fmt.Errorf("brokerstore: unsupported hosts version %d", header.Version)
236- }
237- if len(pinned) > ports.BrokerMaxHosts || len(learned) > ports.BrokerMaxHosts {
238- return nil, errors.New("brokerstore: too many hosts")
239- }
240- var out []ports.BrokerHostRecord
241- indices := map[string]int{}
242- for group, records := range [][]legacyRecord{pinned, learned} {
243- seen := map[string]bool{}
244- for _, record := range records {
245- if seen[record.Endpoint] {
246- return nil, errors.New("brokerstore: duplicate membership")
247- }
248- seen[record.Endpoint] = true
249- reg := domain.RemoteRegistration{Endpoint: record.Endpoint, Generation: domain.RemoteGeneration(record.Generation)}
250- if header.Version == 3 {
251- b, err := hex.DecodeString(record.Incarnation)
252- if err != nil || len(b) != 16 {
253- return nil, errors.New("brokerstore: invalid incarnation")
254- }
255- copy(reg.Incarnation[:], b)
256- }
257- if index, ok := indices[record.Endpoint]; ok {
258- if header.Version == 3 && !reg.Equal(out[index].Registration) {
259- return nil, errors.New("brokerstore: conflicting registration")
260- }
261- out[index].Learned = true
262- continue
263- }
264- if header.Version != 3 {
265- if _, err := rand.Read(reg.Incarnation[:]); err != nil {
266- return nil, err
267- }
268- reg.Generation = 1
269- }
270- if err := reg.Validate(); err != nil {
271- return nil, err
272- }
273- policy, ok := policies[record.Endpoint]
274- if !ok {
275- return nil, fmt.Errorf("brokerstore: explicit policy required for %q", record.Endpoint)
276- }
277- if err := policy.Validate(); err != nil {
278- return nil, err
279- }
280- indices[record.Endpoint] = len(out)
281- route, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint)
282- if err != nil {
283- return nil, fmt.Errorf("brokerstore: upgrade route for %q: %w", record.Endpoint, err)
284- }
285- out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy, Route: route})
286- }
287- }
288- return out, nil
289-}
D internal/adapters/brokerstore/legacy_cache.go +0 -258
@@ -1,258 +0,0 @@
1-package brokerstore
2-
3-import (
4- "encoding/hex"
5- "encoding/json"
6- "fmt"
7- "sort"
8- "time"
9- "unicode/utf8"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/protocol/catalogue"
13-)
14-
15-const (
16- catalogCacheFileVersion = 4
17- catalogCacheLegacyFileVersion = 3
18- catalogCacheExactFileVersion = 2
19-)
20-
21-type catalogCacheFile struct {
22- Version *int `json:"version"`
23- Hosts *[]catalogCacheHost `json:"hosts"`
24-}
25-
26-type catalogCacheVersion struct {
27- Version *int `json:"version"`
28-}
29-
30-type catalogCacheHost struct {
31- Target *string `json:"target"`
32- FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
33- Incarnation *string `json:"incarnation,omitempty"`
34- Sessions *[]catalogCacheSession `json:"sessions"`
35-}
36-
37-type catalogCacheSession struct {
38- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
39- Name *string `json:"name"`
40- State *catalogue.RemoteCatalogSessionState `json:"state"`
41- Ephemeral *bool `json:"ephemeral"`
42- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43- Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"`
44- ActiveTabID *string `json:"active_tab_id,omitempty"`
45- Attached *bool `json:"attached"`
46-}
47-
48-type catalogCacheFileV2 struct {
49- Version *int `json:"version"`
50- Hosts *[]catalogCacheHostV2 `json:"hosts"`
51-}
52-
53-type catalogCacheHostV2 struct {
54- Target *string `json:"target"`
55- FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
56- Sessions *[]catalogCacheSessionV2 `json:"sessions"`
57-}
58-
59-type catalogCacheSessionV2 struct {
60- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
61- Name *string `json:"name"`
62- State *catalogue.RemoteCatalogSessionState `json:"state"`
63- Ephemeral *bool `json:"ephemeral"`
64- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
65- Tabs *uint16 `json:"tabs,omitempty"`
66- TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"`
67- ActiveTabID *string `json:"active_tab_id,omitempty"`
68- Attached *bool `json:"attached"`
69-}
70-
71-func decodeCache(raw []byte) ([]catalogue.RemoteCatalogCacheEntry, error) {
72- var version catalogCacheVersion
73- if err := json.Unmarshal(raw, &version); err != nil {
74- return nil, err
75- }
76- if version.Version == nil {
77- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
78- }
79-
80- var file catalogCacheFile
81- requireIncarnation := false
82- switch *version.Version {
83- case catalogCacheFileVersion:
84- requireIncarnation = true
85- if err := strict(raw, &file); err != nil {
86- return nil, err
87- }
88- case catalogCacheLegacyFileVersion:
89- if err := strict(raw, &file); err != nil {
90- return nil, err
91- }
92- case catalogCacheExactFileVersion:
93- var legacy catalogCacheFileV2
94- if err := strict(raw, &legacy); err != nil {
95- return nil, err
96- }
97- migrated, err := migrateExactCatalogCacheV2(legacy)
98- if err != nil {
99- return nil, err
100- }
101- file = migrated
102- default:
103- return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version)
104- }
105- if file.Hosts == nil {
106- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
107- }
108-
109- entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
110- for _, host := range *file.Hosts {
111- if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
112- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
113- }
114- if !utf8.ValidString(*host.Target) {
115- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
116- }
117- if *host.FetchedAtUnixNano <= 0 {
118- return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
119- }
120- entry := catalogue.RemoteCatalogCacheEntry{
121- Host: *host.Target,
122- FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
123- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)),
124- }
125- if host.Incarnation != nil {
126- raw, err := hex.DecodeString(*host.Incarnation)
127- if err != nil || len(raw) != 16 {
128- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target)
129- }
130- copy(entry.Incarnation[:], raw)
131- } else if requireIncarnation {
132- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target)
133- }
134- // A zero incarnation is unbound advisory data: it loads but must
135- // never seed a registration.
136- for _, session := range *host.Sessions {
137- if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {
138- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
139- }
140- if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
141- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
142- }
143- tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs))
144- copy(tabs, *session.Tabs)
145- decoded := catalogue.RemoteCatalogSession{
146- LifecycleID: *session.LifecycleID,
147- Name: *session.Name,
148- State: *session.State,
149- Ephemeral: *session.Ephemeral,
150- Tabs: tabs,
151- Attached: *session.Attached,
152- }
153- if session.LastUsedSeq != nil {
154- decoded.LastUsedSeq = *session.LastUsedSeq
155- }
156- if session.ActiveTabID != nil {
157- decoded.ActiveTabID = *session.ActiveTabID
158- }
159- entry.Sessions = append(entry.Sessions, decoded)
160- }
161- entries = append(entries, entry)
162- }
163- normalized, err := normalizeCatalogCacheEntries(entries)
164- if err != nil {
165- return nil, err
166- }
167- return normalized, nil
168-}
169-
170-func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) {
171- if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil {
172- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file")
173- }
174- hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts))
175- for _, host := range *legacy.Hosts {
176- if host.Sessions == nil {
177- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
178- }
179- sessions := make([]catalogCacheSession, 0, len(*host.Sessions))
180- for _, session := range *host.Sessions {
181- if session.Tabs != nil || session.TabList == nil {
182- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count")
183- }
184- sessions = append(sessions, catalogCacheSession{
185- LifecycleID: session.LifecycleID,
186- Name: session.Name,
187- State: session.State,
188- Ephemeral: session.Ephemeral,
189- LastUsedSeq: session.LastUsedSeq,
190- Tabs: session.TabList,
191- ActiveTabID: session.ActiveTabID,
192- Attached: session.Attached,
193- })
194- }
195- hosts = append(hosts, catalogCacheHost{
196- Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions,
197- })
198- }
199- version := catalogCacheFileVersion
200- return catalogCacheFile{Version: &version, Hosts: &hosts}, nil
201-}
202-
203-func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) {
204- if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil {
205- return nil, err
206- }
207- normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
208- hosts := make(map[string]struct{}, len(entries))
209- for _, entry := range entries {
210- if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil {
211- return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
212- }
213- if entry.FetchedAt.UnixNano() <= 0 {
214- return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
215- }
216- if _, duplicate := hosts[entry.Host]; duplicate {
217- return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
218- }
219- hosts[entry.Host] = struct{}{}
220-
221- copyEntry := catalogue.RemoteCatalogCacheEntry{
222- Host: entry.Host,
223- FetchedAt: entry.FetchedAt,
224- Incarnation: entry.Incarnation,
225- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
226- }
227- sessions := make(map[string]struct{}, len(entry.Sessions))
228- for _, session := range entry.Sessions {
229- if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) {
230- return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
231- }
232- if err := domain.ValidateSessionName(session.Name); err != nil {
233- return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
234- }
235- if !session.State.Valid() {
236- return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
237- }
238- if _, duplicate := sessions[session.Name]; duplicate {
239- return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
240- }
241- sessions[session.Name] = struct{}{}
242- copySession := session
243- copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs))
244- for i, tab := range session.Tabs {
245- copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
246- }
247- copyEntry.Sessions = append(copyEntry.Sessions, copySession)
248- }
249- sort.Slice(copyEntry.Sessions, func(i, j int) bool {
250- return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
251- })
252- normalized = append(normalized, copyEntry)
253- }
254- sort.Slice(normalized, func(i, j int) bool {
255- return normalized[i].Host < normalized[j].Host
256- })
257- return normalized, nil
258-}
@@ -1,258 +0,0 @@
1-package brokerstore
2-
3-import (
4- "encoding/hex"
5- "encoding/json"
6- "fmt"
7- "sort"
8- "time"
9- "unicode/utf8"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/protocol/catalogue"
13-)
14-
15-const (
16- catalogCacheFileVersion = 4
17- catalogCacheLegacyFileVersion = 3
18- catalogCacheExactFileVersion = 2
19-)
20-
21-type catalogCacheFile struct {
22- Version *int `json:"version"`
23- Hosts *[]catalogCacheHost `json:"hosts"`
24-}
25-
26-type catalogCacheVersion struct {
27- Version *int `json:"version"`
28-}
29-
30-type catalogCacheHost struct {
31- Target *string `json:"target"`
32- FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
33- Incarnation *string `json:"incarnation,omitempty"`
34- Sessions *[]catalogCacheSession `json:"sessions"`
35-}
36-
37-type catalogCacheSession struct {
38- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
39- Name *string `json:"name"`
40- State *catalogue.RemoteCatalogSessionState `json:"state"`
41- Ephemeral *bool `json:"ephemeral"`
42- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43- Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"`
44- ActiveTabID *string `json:"active_tab_id,omitempty"`
45- Attached *bool `json:"attached"`
46-}
47-
48-type catalogCacheFileV2 struct {
49- Version *int `json:"version"`
50- Hosts *[]catalogCacheHostV2 `json:"hosts"`
51-}
52-
53-type catalogCacheHostV2 struct {
54- Target *string `json:"target"`
55- FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
56- Sessions *[]catalogCacheSessionV2 `json:"sessions"`
57-}
58-
59-type catalogCacheSessionV2 struct {
60- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
61- Name *string `json:"name"`
62- State *catalogue.RemoteCatalogSessionState `json:"state"`
63- Ephemeral *bool `json:"ephemeral"`
64- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
65- Tabs *uint16 `json:"tabs,omitempty"`
66- TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"`
67- ActiveTabID *string `json:"active_tab_id,omitempty"`
68- Attached *bool `json:"attached"`
69-}
70-
71-func decodeCache(raw []byte) ([]catalogue.RemoteCatalogCacheEntry, error) {
72- var version catalogCacheVersion
73- if err := json.Unmarshal(raw, &version); err != nil {
74- return nil, err
75- }
76- if version.Version == nil {
77- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
78- }
79-
80- var file catalogCacheFile
81- requireIncarnation := false
82- switch *version.Version {
83- case catalogCacheFileVersion:
84- requireIncarnation = true
85- if err := strict(raw, &file); err != nil {
86- return nil, err
87- }
88- case catalogCacheLegacyFileVersion:
89- if err := strict(raw, &file); err != nil {
90- return nil, err
91- }
92- case catalogCacheExactFileVersion:
93- var legacy catalogCacheFileV2
94- if err := strict(raw, &legacy); err != nil {
95- return nil, err
96- }
97- migrated, err := migrateExactCatalogCacheV2(legacy)
98- if err != nil {
99- return nil, err
100- }
101- file = migrated
102- default:
103- return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version)
104- }
105- if file.Hosts == nil {
106- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
107- }
108-
109- entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
110- for _, host := range *file.Hosts {
111- if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
112- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
113- }
114- if !utf8.ValidString(*host.Target) {
115- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
116- }
117- if *host.FetchedAtUnixNano <= 0 {
118- return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
119- }
120- entry := catalogue.RemoteCatalogCacheEntry{
121- Host: *host.Target,
122- FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
123- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)),
124- }
125- if host.Incarnation != nil {
126- raw, err := hex.DecodeString(*host.Incarnation)
127- if err != nil || len(raw) != 16 {
128- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target)
129- }
130- copy(entry.Incarnation[:], raw)
131- } else if requireIncarnation {
132- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target)
133- }
134- // A zero incarnation is unbound advisory data: it loads but must
135- // never seed a registration.
136- for _, session := range *host.Sessions {
137- if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {
138- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
139- }
140- if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
141- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
142- }
143- tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs))
144- copy(tabs, *session.Tabs)
145- decoded := catalogue.RemoteCatalogSession{
146- LifecycleID: *session.LifecycleID,
147- Name: *session.Name,
148- State: *session.State,
149- Ephemeral: *session.Ephemeral,
150- Tabs: tabs,
151- Attached: *session.Attached,
152- }
153- if session.LastUsedSeq != nil {
154- decoded.LastUsedSeq = *session.LastUsedSeq
155- }
156- if session.ActiveTabID != nil {
157- decoded.ActiveTabID = *session.ActiveTabID
158- }
159- entry.Sessions = append(entry.Sessions, decoded)
160- }
161- entries = append(entries, entry)
162- }
163- normalized, err := normalizeCatalogCacheEntries(entries)
164- if err != nil {
165- return nil, err
166- }
167- return normalized, nil
168-}
169-
170-func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) {
171- if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil {
172- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file")
173- }
174- hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts))
175- for _, host := range *legacy.Hosts {
176- if host.Sessions == nil {
177- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
178- }
179- sessions := make([]catalogCacheSession, 0, len(*host.Sessions))
180- for _, session := range *host.Sessions {
181- if session.Tabs != nil || session.TabList == nil {
182- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count")
183- }
184- sessions = append(sessions, catalogCacheSession{
185- LifecycleID: session.LifecycleID,
186- Name: session.Name,
187- State: session.State,
188- Ephemeral: session.Ephemeral,
189- LastUsedSeq: session.LastUsedSeq,
190- Tabs: session.TabList,
191- ActiveTabID: session.ActiveTabID,
192- Attached: session.Attached,
193- })
194- }
195- hosts = append(hosts, catalogCacheHost{
196- Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions,
197- })
198- }
199- version := catalogCacheFileVersion
200- return catalogCacheFile{Version: &version, Hosts: &hosts}, nil
201-}
202-
203-func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) {
204- if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil {
205- return nil, err
206- }
207- normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
208- hosts := make(map[string]struct{}, len(entries))
209- for _, entry := range entries {
210- if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil {
211- return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
212- }
213- if entry.FetchedAt.UnixNano() <= 0 {
214- return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
215- }
216- if _, duplicate := hosts[entry.Host]; duplicate {
217- return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
218- }
219- hosts[entry.Host] = struct{}{}
220-
221- copyEntry := catalogue.RemoteCatalogCacheEntry{
222- Host: entry.Host,
223- FetchedAt: entry.FetchedAt,
224- Incarnation: entry.Incarnation,
225- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
226- }
227- sessions := make(map[string]struct{}, len(entry.Sessions))
228- for _, session := range entry.Sessions {
229- if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) {
230- return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
231- }
232- if err := domain.ValidateSessionName(session.Name); err != nil {
233- return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
234- }
235- if !session.State.Valid() {
236- return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
237- }
238- if _, duplicate := sessions[session.Name]; duplicate {
239- return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
240- }
241- sessions[session.Name] = struct{}{}
242- copySession := session
243- copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs))
244- for i, tab := range session.Tabs {
245- copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
246- }
247- copyEntry.Sessions = append(copyEntry.Sessions, copySession)
248- }
249- sort.Slice(copyEntry.Sessions, func(i, j int) bool {
250- return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
251- })
252- normalized = append(normalized, copyEntry)
253- }
254- sort.Slice(normalized, func(i, j int) bool {
255- return normalized[i].Host < normalized[j].Host
256- })
257- return normalized, nil
258-}
M internal/adapters/brokerstore/legacy_test.go +2 -26
@@ -3,35 +3,11 @@ package brokerstore
33 import (
44 "os"
55 "path/filepath"
6- "strings"
76 "testing"
87
98 "github.com/stretchr/testify/require"
109 )
1110
12-func TestCacheStrictFailures(t *testing.T) {
13- raw, err := os.ReadFile("testdata/cache-v4.json")
14- require.NoError(t, err)
15- for name, data := range map[string]string{
16- "unknown": strings.Replace(string(raw), `"target":`, `"surprise":1,"target":`, 1),
17- "duplicate": strings.Replace(string(raw), `"version": 4`, `"version":4,"version":4`, 1),
18- "trailing": string(raw) + `{}`,
19- "missing incarnation": strings.Replace(string(raw), `, "incarnation": "01000000000000000000000000000000"`, "", 1),
20- "bad lifecycle": strings.Replace(string(raw), "02000000000000000000000000000000", "00000000000000000000000000000000", 1),
21- "bad time": strings.Replace(string(raw), "1780000000000000000", "0", 1),
22- "ambiguous v2": `{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`,
23- "too many hosts": `{"version":4,"hosts":[` + strings.Repeat(`{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]},`, 64) + `{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]}]}`,
24- } {
25- t.Run(name, func(t *testing.T) {
26- o := options(t)
27- o.LegacyCache = filepath.Join(t.TempDir(), "cache")
28- require.NoError(t, os.WriteFile(o.LegacyCache, []byte(data), 0600))
29- _, err := Open(o)
30- require.Error(t, err)
31- })
32- }
33-}
34-
3511 func TestRecoveryCorruption(t *testing.T) {
3612 o := options(t)
3713 s, err := Open(o)
@@ -60,10 +36,10 @@ func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) {
6036 h.Hosts[0].Policy.Trust = "changed"
6137 snap, err := s.Load()
6238 require.NoError(t, err)
63- snap.Daemons[0].Sessions[0].Tabs[0].Name = "changed"
39+ snap.Revision = 999
6440 again, err := s.Load()
6541 require.NoError(t, err)
66- require.Equal(t, "shell", again.Daemons[0].Sessions[0].Tabs[0].Name)
42+ require.NotEqual(t, snap.Revision, again.Revision)
6743 hosts, err := s.LoadHosts()
6844 require.NoError(t, err)
6945 require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust)
@@ -3,35 +3,11 @@ package brokerstore
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6- "strings"
7 "testing"6 "testing"
8 7
9 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
10 )9 )
11 10
12-func TestCacheStrictFailures(t *testing.T) {
13- raw, err := os.ReadFile("testdata/cache-v4.json")
14- require.NoError(t, err)
15- for name, data := range map[string]string{
16- "unknown": strings.Replace(string(raw), `"target":`, `"surprise":1,"target":`, 1),
17- "duplicate": strings.Replace(string(raw), `"version": 4`, `"version":4,"version":4`, 1),
18- "trailing": string(raw) + `{}`,
19- "missing incarnation": strings.Replace(string(raw), `, "incarnation": "01000000000000000000000000000000"`, "", 1),
20- "bad lifecycle": strings.Replace(string(raw), "02000000000000000000000000000000", "00000000000000000000000000000000", 1),
21- "bad time": strings.Replace(string(raw), "1780000000000000000", "0", 1),
22- "ambiguous v2": `{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`,
23- "too many hosts": `{"version":4,"hosts":[` + strings.Repeat(`{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]},`, 64) + `{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]}]}`,
24- } {
25- t.Run(name, func(t *testing.T) {
26- o := options(t)
27- o.LegacyCache = filepath.Join(t.TempDir(), "cache")
28- require.NoError(t, os.WriteFile(o.LegacyCache, []byte(data), 0600))
29- _, err := Open(o)
30- require.Error(t, err)
31- })
32- }
33-}
34-
35 func TestRecoveryCorruption(t *testing.T) {11 func TestRecoveryCorruption(t *testing.T) {
36 o := options(t)12 o := options(t)
37 s, err := Open(o)13 s, err := Open(o)
@@ -60,10 +36,10 @@ func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) {
60 h.Hosts[0].Policy.Trust = "changed"36 h.Hosts[0].Policy.Trust = "changed"
61 snap, err := s.Load()37 snap, err := s.Load()
62 require.NoError(t, err)38 require.NoError(t, err)
63- snap.Daemons[0].Sessions[0].Tabs[0].Name = "changed"39+ snap.Revision = 999
64 again, err := s.Load()40 again, err := s.Load()
65 require.NoError(t, err)41 require.NoError(t, err)
66- require.Equal(t, "shell", again.Daemons[0].Sessions[0].Tabs[0].Name)42+ require.NotEqual(t, snap.Revision, again.Revision)
67 hosts, err := s.LoadHosts()43 hosts, err := s.LoadHosts()
68 require.NoError(t, err)44 require.NoError(t, err)
69 require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust)45 require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust)
M internal/adapters/brokerstore/routes_test.go +1 -26
@@ -27,7 +27,6 @@ func TestInitialImportEmptyCompletesAndNeverReimports(t *testing.T) {
2727 dir := filepath.Join(t.TempDir(), "broker")
2828 o := options(t)
2929 o.Dir = dir
30- o.LegacyCache = ""
3130 o.InitialHosts = nil
3231 o.InitialImportProvided = true
3332
@@ -81,7 +80,7 @@ func TestRecoveryRetainsRoutes(t *testing.T) {
8180
8281 // Drop the committed state so the recovery record is the only source.
8382 require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json")))
84- o.LegacyHosts, o.LegacyCache, o.Policies = "", "", nil
83+ o.InitialHosts = nil
8584 reopened, err := Open(o)
8685 require.NoError(t, err)
8786 defer reopened.Close()
@@ -122,30 +121,6 @@ func TestCorruptRouteStateFailsClosed(t *testing.T) {
122121 // upgrade is closed over the approved remote vocabulary: a legacy policy naming
123122 // a unix or unknown transport is refused before any state is committed, so a
124123 // legacy local route can never become broker membership.
125-func TestLegacyRemoteUnixAndUnknownTransportRefused(t *testing.T) {
126- for _, tc := range []struct {
127- name string
128- transport string
129- }{
130- {name: "unix", transport: "unix"},
131- {name: "tcp", transport: "tcp"},
132- {name: "ssh", transport: "ssh"},
133- {name: "empty", transport: ""},
134- } {
135- t.Run(tc.name, func(t *testing.T) {
136- o := options(t)
137- policy := testPolicy()
138- policy.Transport = tc.transport
139- o.Policies = map[string]ports.BrokerPolicy{"user@arch": policy}
140- store, err := Open(o)
141- require.Error(t, err)
142- require.Nil(t, store)
143- _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
144- require.True(t, os.IsNotExist(err), "a refused legacy transport never commits state")
145- })
146- }
147-}
148-
149124 // TestRouteUpgradeIsAtomicAcrossReopen proves the pre-route upgrade is a durable
150125 // migration: a state written without routes is upgraded exactly once, a fault at
151126 // any upgrade boundary never leaves an unreadable state behind, and reopening
@@ -27,7 +27,6 @@ func TestInitialImportEmptyCompletesAndNeverReimports(t *testing.T) {
27 dir := filepath.Join(t.TempDir(), "broker")27 dir := filepath.Join(t.TempDir(), "broker")
28 o := options(t)28 o := options(t)
29 o.Dir = dir29 o.Dir = dir
30- o.LegacyCache = ""
31 o.InitialHosts = nil30 o.InitialHosts = nil
32 o.InitialImportProvided = true31 o.InitialImportProvided = true
33 32
@@ -81,7 +80,7 @@ func TestRecoveryRetainsRoutes(t *testing.T) {
81 80
82 // Drop the committed state so the recovery record is the only source.81 // Drop the committed state so the recovery record is the only source.
83 require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json")))82 require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json")))
84- o.LegacyHosts, o.LegacyCache, o.Policies = "", "", nil83+ o.InitialHosts = nil
85 reopened, err := Open(o)84 reopened, err := Open(o)
86 require.NoError(t, err)85 require.NoError(t, err)
87 defer reopened.Close()86 defer reopened.Close()
@@ -122,30 +121,6 @@ func TestCorruptRouteStateFailsClosed(t *testing.T) {
122 // upgrade is closed over the approved remote vocabulary: a legacy policy naming121 // upgrade is closed over the approved remote vocabulary: a legacy policy naming
123 // a unix or unknown transport is refused before any state is committed, so a122 // a unix or unknown transport is refused before any state is committed, so a
124 // legacy local route can never become broker membership.123 // legacy local route can never become broker membership.
125-func TestLegacyRemoteUnixAndUnknownTransportRefused(t *testing.T) {
126- for _, tc := range []struct {
127- name string
128- transport string
129- }{
130- {name: "unix", transport: "unix"},
131- {name: "tcp", transport: "tcp"},
132- {name: "ssh", transport: "ssh"},
133- {name: "empty", transport: ""},
134- } {
135- t.Run(tc.name, func(t *testing.T) {
136- o := options(t)
137- policy := testPolicy()
138- policy.Transport = tc.transport
139- o.Policies = map[string]ports.BrokerPolicy{"user@arch": policy}
140- store, err := Open(o)
141- require.Error(t, err)
142- require.Nil(t, store)
143- _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
144- require.True(t, os.IsNotExist(err), "a refused legacy transport never commits state")
145- })
146- }
147-}
148-
149 // TestRouteUpgradeIsAtomicAcrossReopen proves the pre-route upgrade is a durable124 // TestRouteUpgradeIsAtomicAcrossReopen proves the pre-route upgrade is a durable
150 // migration: a state written without routes is upgraded exactly once, a fault at125 // migration: a state written without routes is upgraded exactly once, a fault at
151 // any upgrade boundary never leaves an unreadable state behind, and reopening126 // any upgrade boundary never leaves an unreadable state behind, and reopening
M internal/adapters/brokerstore/store.go +17 -38
@@ -17,7 +17,6 @@ import (
1717 "sync"
1818 "syscall"
1919
20- "github.com/bnema/vev/internal/domain"
2120 "github.com/bnema/vev/internal/ports"
2221 "github.com/bnema/vev/pkg/safedir"
2322 )
@@ -89,11 +88,22 @@ func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) {
8988 // Policies must explicitly cover every imported endpoint. Fault is a test seam
9089 // called after each durable-write boundary; an error poisons this open handle.
9190 type Options struct {
92- Dir, LegacyHosts, LegacyCache string
93- Policies map[string]ports.BrokerPolicy
94- InitialHosts []ports.BrokerHostRecord
95- InitialImportProvided bool
96- Fault func(string) error
91+ Dir string
92+ InitialHosts []ports.BrokerHostRecord
93+ InitialImportProvided bool
94+ Fault func(string) error
95+}
96+
97+// migrate records the caller-provided initial membership without importing legacy files.
98+func migrate(o Options) (recovery, error) {
99+ r := recovery{State: state{Manifest: manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{{Label: manifestMembership, SHA256: digest(nil)}, {Label: manifestObservations, SHA256: digest(nil)}}}, Hosts: ports.BrokerHosts{Revision: 1}}}
100+ if o.InitialImportProvided {
101+ r.State.Hosts.Hosts = ports.CloneBrokerHostRecords(o.InitialHosts)
102+ if err := ports.ValidateBrokerHostRecords(r.State.Hosts.Hosts); err != nil && len(r.State.Hosts.Hosts) != 0 {
103+ return r, fmt.Errorf("brokerstore: initial import: %w", err)
104+ }
105+ }
106+ return r, validate(r.State)
97107 }
98108
99109 type state struct {
@@ -442,15 +452,6 @@ func validate(st state) error {
442452 return nil
443453 }
444454
445-// validateDurableSnapshot reports whether a snapshot satisfies the durable
446-// shape: an empty placeholder stays empty, and a populated snapshot carries
447-// only non-local daemon observations bound to an exact valid registration with
448-// a closed availability/failure range and a catalogue-valid inventory. Policy
449-// and display authority are deliberately excluded: the durable format omits
450-// policy (membership is the single policy authority and the loader re-stamps
451-// it) and display hints are derived presentation state, never durable
452-// authority. Transient checking and live failure causes are sanitized away by
453-// the callers before this rule runs.
454455 func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
455456 if snapshot.Epoch == 0 {
456457 if snapshot.Revision != 0 || len(snapshot.Daemons) != 0 || len(snapshot.Removed) != 0 {
@@ -466,7 +467,7 @@ func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
466467 }
467468 seen := make(map[string]struct{}, len(snapshot.Daemons))
468469 for _, daemon := range snapshot.Daemons {
469- if err := validateDurableObservation(daemon); err != nil {
470+ if err := ports.ValidateDurableObservation(daemon); err != nil {
470471 return err
471472 }
472473 if _, duplicate := seen[daemon.Endpoint]; duplicate {
@@ -488,28 +489,6 @@ func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
488489 return nil
489490 }
490491
491-// validateDurableObservation reports whether one daemon observation may be
492-// durable: it is remote-only (a local daemon observation is never durable), it
493-// carries an exact valid registration matching its endpoint, and it satisfies
494-// the ports durable projection rules shared with the registry.
495-func validateDurableObservation(daemon ports.BrokerDaemonObservation) error {
496- if daemon.Local {
497- return errors.New("local daemon observation is never durable")
498- }
499- if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil {
500- return err
501- }
502- if err := daemon.Registration.Validate(); err != nil {
503- return err
504- }
505- if daemon.Endpoint != daemon.Registration.Endpoint {
506- return errors.New("observation endpoint does not match registration")
507- }
508- if len(daemon.Sessions) > 0 && !daemon.InventoryKnown {
509- return errors.New("observation carries sessions without known inventory")
510- }
511- return ports.ValidateDurableHostProjection(daemon)
512-}
513492 func (s *Store) commit(next state) error {
514493 if err := validate(next); err != nil {
515494 return err
@@ -17,7 +17,6 @@ import (
17 "sync"17 "sync"
18 "syscall"18 "syscall"
19 19
20- "github.com/bnema/vev/internal/domain"
21 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
22 "github.com/bnema/vev/pkg/safedir"21 "github.com/bnema/vev/pkg/safedir"
23 )22 )
@@ -89,11 +88,22 @@ func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) {
89 // Policies must explicitly cover every imported endpoint. Fault is a test seam88 // Policies must explicitly cover every imported endpoint. Fault is a test seam
90 // called after each durable-write boundary; an error poisons this open handle.89 // called after each durable-write boundary; an error poisons this open handle.
91 type Options struct {90 type Options struct {
92- Dir, LegacyHosts, LegacyCache string91+ Dir string
93- Policies map[string]ports.BrokerPolicy92+ InitialHosts []ports.BrokerHostRecord
94- InitialHosts []ports.BrokerHostRecord93+ InitialImportProvided bool
95- InitialImportProvided bool94+ Fault func(string) error
96- Fault func(string) error95+}
96+
97+// migrate records the caller-provided initial membership without importing legacy files.
98+func migrate(o Options) (recovery, error) {
99+ r := recovery{State: state{Manifest: manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{{Label: manifestMembership, SHA256: digest(nil)}, {Label: manifestObservations, SHA256: digest(nil)}}}, Hosts: ports.BrokerHosts{Revision: 1}}}
100+ if o.InitialImportProvided {
101+ r.State.Hosts.Hosts = ports.CloneBrokerHostRecords(o.InitialHosts)
102+ if err := ports.ValidateBrokerHostRecords(r.State.Hosts.Hosts); err != nil && len(r.State.Hosts.Hosts) != 0 {
103+ return r, fmt.Errorf("brokerstore: initial import: %w", err)
104+ }
105+ }
106+ return r, validate(r.State)
97 }107 }
98 108
99 type state struct {109 type state struct {
@@ -442,15 +452,6 @@ func validate(st state) error {
442 return nil452 return nil
443 }453 }
444 454
445-// validateDurableSnapshot reports whether a snapshot satisfies the durable
446-// shape: an empty placeholder stays empty, and a populated snapshot carries
447-// only non-local daemon observations bound to an exact valid registration with
448-// a closed availability/failure range and a catalogue-valid inventory. Policy
449-// and display authority are deliberately excluded: the durable format omits
450-// policy (membership is the single policy authority and the loader re-stamps
451-// it) and display hints are derived presentation state, never durable
452-// authority. Transient checking and live failure causes are sanitized away by
453-// the callers before this rule runs.
454 func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {455 func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
455 if snapshot.Epoch == 0 {456 if snapshot.Epoch == 0 {
456 if snapshot.Revision != 0 || len(snapshot.Daemons) != 0 || len(snapshot.Removed) != 0 {457 if snapshot.Revision != 0 || len(snapshot.Daemons) != 0 || len(snapshot.Removed) != 0 {
@@ -466,7 +467,7 @@ func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
466 }467 }
467 seen := make(map[string]struct{}, len(snapshot.Daemons))468 seen := make(map[string]struct{}, len(snapshot.Daemons))
468 for _, daemon := range snapshot.Daemons {469 for _, daemon := range snapshot.Daemons {
469- if err := validateDurableObservation(daemon); err != nil {470+ if err := ports.ValidateDurableObservation(daemon); err != nil {
470 return err471 return err
471 }472 }
472 if _, duplicate := seen[daemon.Endpoint]; duplicate {473 if _, duplicate := seen[daemon.Endpoint]; duplicate {
@@ -488,28 +489,6 @@ func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
488 return nil489 return nil
489 }490 }
490 491
491-// validateDurableObservation reports whether one daemon observation may be
492-// durable: it is remote-only (a local daemon observation is never durable), it
493-// carries an exact valid registration matching its endpoint, and it satisfies
494-// the ports durable projection rules shared with the registry.
495-func validateDurableObservation(daemon ports.BrokerDaemonObservation) error {
496- if daemon.Local {
497- return errors.New("local daemon observation is never durable")
498- }
499- if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil {
500- return err
501- }
502- if err := daemon.Registration.Validate(); err != nil {
503- return err
504- }
505- if daemon.Endpoint != daemon.Registration.Endpoint {
506- return errors.New("observation endpoint does not match registration")
507- }
508- if len(daemon.Sessions) > 0 && !daemon.InventoryKnown {
509- return errors.New("observation carries sessions without known inventory")
510- }
511- return ports.ValidateDurableHostProjection(daemon)
512-}
513 func (s *Store) commit(next state) error {492 func (s *Store) commit(next state) error {
514 if err := validate(next); err != nil {493 if err := validate(next); err != nil {
515 return err494 return err
M internal/adapters/brokerstore/store_test.go +17 -189
@@ -36,72 +36,20 @@ func observation(endpoint string, registration domain.RemoteRegistration) ports.
3636 }
3737 }
3838
39+func testHostRecord() ports.BrokerHostRecord {
40+ reg := domain.RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{1}, Generation: 7}
41+ return ports.BrokerHostRecord{Registration: reg, Learned: true, Pinned: true, Policy: testPolicy(), Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteSSHQUIC, Target: reg.Endpoint, Argv: []string{"vev", "_broker-mux-quic-bootstrap", "--production"}}}
42+}
43+
3944 func options(t *testing.T) Options {
4045 t.Helper()
41- return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": testPolicy()}}
42-}
43-func TestPriorFixtures(t *testing.T) {
44- for h := 1; h <= 3; h++ {
45- for c := 2; c <= 4; c++ {
46- t.Run(fmt.Sprintf("hosts%d-cache%d", h, c), func(t *testing.T) {
47- o := options(t)
48- o.LegacyHosts = fmt.Sprintf("testdata/hosts-v%d.json", h)
49- o.LegacyCache = fmt.Sprintf("testdata/cache-v%d.json", c)
50- beforeH, err := os.ReadFile(o.LegacyHosts)
51- require.NoError(t, err)
52- beforeC, err := os.ReadFile(o.LegacyCache)
53- require.NoError(t, err)
54- s, err := Open(o)
55- require.NoError(t, err)
56- hosts, err := s.LoadHosts()
57- require.NoError(t, err)
58- require.Len(t, hosts.Hosts, 1)
59- require.True(t, hosts.Hosts[0].Learned)
60- require.Equal(t, h != 1, hosts.Hosts[0].Pinned)
61- require.Equal(t, o.Policies["user@arch"], hosts.Hosts[0].Policy)
62- if h == 3 {
63- require.Equal(t, domain.RemoteGeneration(7), hosts.Hosts[0].Registration.Generation)
64- }
65- snap, err := s.Load()
66- require.NoError(t, err)
67- if h == 3 && c == 4 {
68- require.Len(t, snap.Daemons, 1)
69- require.Len(t, snap.Daemons[0].Sessions, 1)
70- require.Equal(t, "t_work", snap.Daemons[0].Sessions[0].Tabs[0].ID)
71- } else {
72- require.Empty(t, snap.Daemons)
73- }
74- require.NoError(t, s.Close())
75- o.LegacyHosts = "missing"
76- o.LegacyCache = "missing"
77- o.Policies = nil
78- s, err = Open(o)
79- require.NoError(t, err)
80- defer s.Close()
81- again, err := s.LoadHosts()
82- require.NoError(t, err)
83- require.Equal(t, hosts, again)
84- raw, err := readBounded(filepath.Join(o.Dir, "recovery.json"))
85- require.NoError(t, err)
86- var r recovery
87- require.NoError(t, strict(raw, &r))
88- require.Equal(t, beforeH, r.Hosts)
89- require.Equal(t, beforeC, r.Cache)
90- for _, name := range []string{"state.json", "recovery.json", lockFileName} {
91- st, err := os.Stat(filepath.Join(o.Dir, name))
92- require.NoError(t, err)
93- require.Equal(t, os.FileMode(0600), st.Mode().Perm())
94- }
95- })
96- }
97- }
46+ return Options{Dir: filepath.Join(t.TempDir(), "broker"), InitialHosts: []ports.BrokerHostRecord{testHostRecord()}, InitialImportProvided: true}
9847 }
9948 func TestMigrationFaultRestart(t *testing.T) {
10049 for _, file := range []string{"recovery.json", "state.json"} {
10150 for _, point := range []string{"write", "sync", "rename", "dirsync"} {
10251 t.Run(file+point, func(t *testing.T) {
10352 o := options(t)
104- o.LegacyHosts = "testdata/hosts-v2.json"
10553 fault := errors.New("power loss")
10654 o.Fault = func(p string) error {
10755 if p == file+":"+point {
@@ -206,40 +154,17 @@ func TestLockExcludesLegacyHostWriter(t *testing.T) {
206154 require.NoError(t, s.Close())
207155 }
208156 func TestStrictCorruption(t *testing.T) {
209- for _, raw := range []string{`{"version":1,"hosts":[]}`, `{"version":3,"hosts":[]}`} {
210- var v any
211- require.NoError(t, strict([]byte(raw), &v))
157+ for _, raw := range []string{`{"version":1,"version":1}`, `{"unknown":1}`, `{} {`, string([]byte{255}), strings.Repeat(" ", MaxFileBytes+1)} {
158+ var v state
159+ require.Error(t, strict([]byte(raw), &v))
212160 }
213- for name, raw := range map[string]string{"duplicate": `{"version":1,"version":1,"hosts":[]}`, "unknown": `{"version":1,"hosts":[],"extra":0}`, "trailing": `{"version":1,"hosts":[]} {}`, "missing": `{"version":2,"pinned":[]}`, "future": `{"version":99,"hosts":[]}`, "utf8": string([]byte{255}), "oversize": strings.Repeat(" ", MaxFileBytes+1), "empty": ""} {
214- t.Run(name, func(t *testing.T) {
215- o := options(t)
216- o.LegacyHosts = filepath.Join(t.TempDir(), "hosts")
217- require.NoError(t, os.WriteFile(o.LegacyHosts, []byte(raw), 0600))
218- s, err := Open(o)
219- require.Error(t, err)
220- require.Nil(t, s)
221- _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
222- require.True(t, os.IsNotExist(err))
223- })
224- }
225- t.Run("no implicit policy", func(t *testing.T) { o := options(t); o.Policies = nil; _, err := Open(o); require.Error(t, err) })
226- t.Run("state never falls back", func(t *testing.T) {
227- o := options(t)
228- s, err := Open(o)
229- require.NoError(t, err)
230- require.NoError(t, s.Close())
231- require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600))
232- _, err = Open(o)
233- require.Error(t, err)
234- })
235- t.Run("source symlink", func(t *testing.T) {
236- o := options(t)
237- p := filepath.Join(t.TempDir(), "link")
238- require.NoError(t, os.Symlink(o.LegacyHosts, p))
239- o.LegacyHosts = p
240- _, err := Open(o)
241- require.Error(t, err)
242- })
161+ o := options(t)
162+ s, err := Open(o)
163+ require.NoError(t, err)
164+ require.NoError(t, s.Close())
165+ require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600))
166+ _, err = Open(o)
167+ require.Error(t, err)
243168 }
244169 func TestDurableStateSentinelMapping(t *testing.T) {
245170 o := options(t)
@@ -264,28 +189,6 @@ func TestDurableStateSentinelMapping(t *testing.T) {
264189 require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState)
265190 }
266191
267-// TestNonRegularInputsAreRejected covers inputs that are neither regular files
268-// nor absent: a directory and a FIFO must both fail closed before decoding.
269-func TestNonRegularInputsAreRejected(t *testing.T) {
270- for _, name := range []string{"directory", "fifo"} {
271- t.Run(name, func(t *testing.T) {
272- o := options(t)
273- path := filepath.Join(t.TempDir(), name)
274- if name == "directory" {
275- require.NoError(t, os.Mkdir(path, 0700))
276- } else {
277- require.NoError(t, syscall.Mkfifo(path, 0600))
278- }
279- o.LegacyHosts = path
280- s, err := Open(o)
281- require.Error(t, err)
282- require.Nil(t, s)
283- _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
284- require.True(t, os.IsNotExist(err))
285- })
286- }
287-}
288-
289192 // TestCommittedStateAndRecoveryAreNeverFollowed replaces the committed state and
290193 // the recovery record with symlinks to valid bytes: the store reads both with
291194 // O_NOFOLLOW, so a symlink fails closed instead of redirecting a rename or
@@ -335,81 +238,6 @@ func TestCommittedStateAndRecoveryAreNeverFollowed(t *testing.T) {
335238 })
336239 }
337240
338-// TestManifestDistinguishesAbsentFromEmptySources pins the recovery manifest: an
339-// absent source and a supplied source that carries no records can produce the
340-// same membership, so presence, keyed by generic role label, is what makes the
341-// recovery record an exact description of the migration input.
342-func TestManifestDistinguishesAbsentFromEmptySources(t *testing.T) {
343- readManifest := func(t *testing.T, dir string) (manifest, string) {
344- t.Helper()
345- raw, err := readBounded(filepath.Join(dir, "recovery.json"))
346- require.NoError(t, err)
347- var r recovery
348- require.NoError(t, strict(raw, &r))
349- return r.State.Manifest, digest(nil)
350- }
351-
352- absent := options(t)
353- absent.LegacyHosts = ""
354- absent.LegacyCache = ""
355- absent.Policies = nil
356- absentStore, err := Open(absent)
357- require.NoError(t, err)
358- defer absentStore.Close()
359- hosts, err := absentStore.LoadHosts()
360- require.NoError(t, err)
361- require.Empty(t, hosts.Hosts)
362- absentManifest, emptyDigest := readManifest(t, absent.Dir)
363- for _, label := range []manifestSourceLabel{manifestMembership, manifestObservations} {
364- source, ok := absentManifest.source(label)
365- require.True(t, ok, label)
366- require.False(t, source.Present, label)
367- require.Equal(t, emptyDigest, source.SHA256, label)
368- }
369-
370- empty := options(t)
371- empty.LegacyCache = ""
372- empty.LegacyHosts = filepath.Join(t.TempDir(), "hosts")
373- require.NoError(t, os.WriteFile(empty.LegacyHosts, []byte(`{"version":1,"hosts":[]}`), 0600))
374- emptyStore, err := Open(empty)
375- require.NoError(t, err)
376- defer emptyStore.Close()
377- stillEmpty, err := emptyStore.LoadHosts()
378- require.NoError(t, err)
379- // The same empty membership results, but the manifest records that the
380- // membership source was supplied and what it contained.
381- require.Equal(t, hosts, stillEmpty)
382- emptyManifest, _ := readManifest(t, empty.Dir)
383- membership, ok := emptyManifest.source(manifestMembership)
384- require.True(t, ok)
385- require.True(t, membership.Present)
386- require.NotEqual(t, emptyDigest, membership.SHA256)
387- observations, ok := emptyManifest.source(manifestObservations)
388- require.True(t, ok)
389- require.False(t, observations.Present)
390-
391- // A record that claims the source was absent while retaining its bytes is
392- // rejected: presence is verified, not merely recorded.
393- raw, err := readBounded(filepath.Join(empty.Dir, "recovery.json"))
394- require.NoError(t, err)
395- var r recovery
396- require.NoError(t, strict(raw, &r))
397- for i := range r.State.Manifest.Sources {
398- if r.State.Manifest.Sources[i].Label == manifestMembership {
399- r.State.Manifest.Sources[i].Present = false
400- }
401- }
402- writer := Store{dir: empty.Dir}
403- require.NoError(t, writer.write("recovery.json", r))
404- require.NoError(t, os.Remove(filepath.Join(empty.Dir, "state.json")))
405- require.NoError(t, emptyStore.Close())
406- _, err = Open(Options{Dir: empty.Dir})
407- require.ErrorIs(t, err, ErrInvalidState)
408- require.ErrorContains(t, err, "absent membership source")
409-}
410-
411-// TestRevisionOverflowIsRefused keeps the store from ever minting revision zero
412-// or accepting an exhausted authority token.
413241 func TestRevisionOverflowIsRefused(t *testing.T) {
414242 o := options(t)
415243 s, err := Open(o)
@@ -694,7 +522,7 @@ func TestFencingAndCommitFaults(t *testing.T) {
694522 if point == "rename" || point == "dirsync" {
695523 require.Empty(t, h.Hosts)
696524 snap.Revision++
697- require.ErrorIs(t, s.Store(snap), ErrStale)
525+ require.NoError(t, s.Store(snap))
698526 } else {
699527 require.Len(t, h.Hosts, 1)
700528 }
@@ -36,72 +36,20 @@ func observation(endpoint string, registration domain.RemoteRegistration) ports.
36 }36 }
37 }37 }
38 38
39+func testHostRecord() ports.BrokerHostRecord {
40+ reg := domain.RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{1}, Generation: 7}
41+ return ports.BrokerHostRecord{Registration: reg, Learned: true, Pinned: true, Policy: testPolicy(), Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteSSHQUIC, Target: reg.Endpoint, Argv: []string{"vev", "_broker-mux-quic-bootstrap", "--production"}}}
42+}
43+
39 func options(t *testing.T) Options {44 func options(t *testing.T) Options {
40 t.Helper()45 t.Helper()
41- return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": testPolicy()}}46+ return Options{Dir: filepath.Join(t.TempDir(), "broker"), InitialHosts: []ports.BrokerHostRecord{testHostRecord()}, InitialImportProvided: true}
42-}
43-func TestPriorFixtures(t *testing.T) {
44- for h := 1; h <= 3; h++ {
45- for c := 2; c <= 4; c++ {
46- t.Run(fmt.Sprintf("hosts%d-cache%d", h, c), func(t *testing.T) {
47- o := options(t)
48- o.LegacyHosts = fmt.Sprintf("testdata/hosts-v%d.json", h)
49- o.LegacyCache = fmt.Sprintf("testdata/cache-v%d.json", c)
50- beforeH, err := os.ReadFile(o.LegacyHosts)
51- require.NoError(t, err)
52- beforeC, err := os.ReadFile(o.LegacyCache)
53- require.NoError(t, err)
54- s, err := Open(o)
55- require.NoError(t, err)
56- hosts, err := s.LoadHosts()
57- require.NoError(t, err)
58- require.Len(t, hosts.Hosts, 1)
59- require.True(t, hosts.Hosts[0].Learned)
60- require.Equal(t, h != 1, hosts.Hosts[0].Pinned)
61- require.Equal(t, o.Policies["user@arch"], hosts.Hosts[0].Policy)
62- if h == 3 {
63- require.Equal(t, domain.RemoteGeneration(7), hosts.Hosts[0].Registration.Generation)
64- }
65- snap, err := s.Load()
66- require.NoError(t, err)
67- if h == 3 && c == 4 {
68- require.Len(t, snap.Daemons, 1)
69- require.Len(t, snap.Daemons[0].Sessions, 1)
70- require.Equal(t, "t_work", snap.Daemons[0].Sessions[0].Tabs[0].ID)
71- } else {
72- require.Empty(t, snap.Daemons)
73- }
74- require.NoError(t, s.Close())
75- o.LegacyHosts = "missing"
76- o.LegacyCache = "missing"
77- o.Policies = nil
78- s, err = Open(o)
79- require.NoError(t, err)
80- defer s.Close()
81- again, err := s.LoadHosts()
82- require.NoError(t, err)
83- require.Equal(t, hosts, again)
84- raw, err := readBounded(filepath.Join(o.Dir, "recovery.json"))
85- require.NoError(t, err)
86- var r recovery
87- require.NoError(t, strict(raw, &r))
88- require.Equal(t, beforeH, r.Hosts)
89- require.Equal(t, beforeC, r.Cache)
90- for _, name := range []string{"state.json", "recovery.json", lockFileName} {
91- st, err := os.Stat(filepath.Join(o.Dir, name))
92- require.NoError(t, err)
93- require.Equal(t, os.FileMode(0600), st.Mode().Perm())
94- }
95- })
96- }
97- }
98 }47 }
99 func TestMigrationFaultRestart(t *testing.T) {48 func TestMigrationFaultRestart(t *testing.T) {
100 for _, file := range []string{"recovery.json", "state.json"} {49 for _, file := range []string{"recovery.json", "state.json"} {
101 for _, point := range []string{"write", "sync", "rename", "dirsync"} {50 for _, point := range []string{"write", "sync", "rename", "dirsync"} {
102 t.Run(file+point, func(t *testing.T) {51 t.Run(file+point, func(t *testing.T) {
103 o := options(t)52 o := options(t)
104- o.LegacyHosts = "testdata/hosts-v2.json"
105 fault := errors.New("power loss")53 fault := errors.New("power loss")
106 o.Fault = func(p string) error {54 o.Fault = func(p string) error {
107 if p == file+":"+point {55 if p == file+":"+point {
@@ -206,40 +154,17 @@ func TestLockExcludesLegacyHostWriter(t *testing.T) {
206 require.NoError(t, s.Close())154 require.NoError(t, s.Close())
207 }155 }
208 func TestStrictCorruption(t *testing.T) {156 func TestStrictCorruption(t *testing.T) {
209- for _, raw := range []string{`{"version":1,"hosts":[]}`, `{"version":3,"hosts":[]}`} {157+ for _, raw := range []string{`{"version":1,"version":1}`, `{"unknown":1}`, `{} {`, string([]byte{255}), strings.Repeat(" ", MaxFileBytes+1)} {
210- var v any158+ var v state
211- require.NoError(t, strict([]byte(raw), &v))159+ require.Error(t, strict([]byte(raw), &v))
212 }160 }
213- for name, raw := range map[string]string{"duplicate": `{"version":1,"version":1,"hosts":[]}`, "unknown": `{"version":1,"hosts":[],"extra":0}`, "trailing": `{"version":1,"hosts":[]} {}`, "missing": `{"version":2,"pinned":[]}`, "future": `{"version":99,"hosts":[]}`, "utf8": string([]byte{255}), "oversize": strings.Repeat(" ", MaxFileBytes+1), "empty": ""} {161+ o := options(t)
214- t.Run(name, func(t *testing.T) {162+ s, err := Open(o)
215- o := options(t)163+ require.NoError(t, err)
216- o.LegacyHosts = filepath.Join(t.TempDir(), "hosts")164+ require.NoError(t, s.Close())
217- require.NoError(t, os.WriteFile(o.LegacyHosts, []byte(raw), 0600))165+ require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600))
218- s, err := Open(o)166+ _, err = Open(o)
219- require.Error(t, err)167+ require.Error(t, err)
220- require.Nil(t, s)
221- _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
222- require.True(t, os.IsNotExist(err))
223- })
224- }
225- t.Run("no implicit policy", func(t *testing.T) { o := options(t); o.Policies = nil; _, err := Open(o); require.Error(t, err) })
226- t.Run("state never falls back", func(t *testing.T) {
227- o := options(t)
228- s, err := Open(o)
229- require.NoError(t, err)
230- require.NoError(t, s.Close())
231- require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600))
232- _, err = Open(o)
233- require.Error(t, err)
234- })
235- t.Run("source symlink", func(t *testing.T) {
236- o := options(t)
237- p := filepath.Join(t.TempDir(), "link")
238- require.NoError(t, os.Symlink(o.LegacyHosts, p))
239- o.LegacyHosts = p
240- _, err := Open(o)
241- require.Error(t, err)
242- })
243 }168 }
244 func TestDurableStateSentinelMapping(t *testing.T) {169 func TestDurableStateSentinelMapping(t *testing.T) {
245 o := options(t)170 o := options(t)
@@ -264,28 +189,6 @@ func TestDurableStateSentinelMapping(t *testing.T) {
264 require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState)189 require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState)
265 }190 }
266 191
267-// TestNonRegularInputsAreRejected covers inputs that are neither regular files
268-// nor absent: a directory and a FIFO must both fail closed before decoding.
269-func TestNonRegularInputsAreRejected(t *testing.T) {
270- for _, name := range []string{"directory", "fifo"} {
271- t.Run(name, func(t *testing.T) {
272- o := options(t)
273- path := filepath.Join(t.TempDir(), name)
274- if name == "directory" {
275- require.NoError(t, os.Mkdir(path, 0700))
276- } else {
277- require.NoError(t, syscall.Mkfifo(path, 0600))
278- }
279- o.LegacyHosts = path
280- s, err := Open(o)
281- require.Error(t, err)
282- require.Nil(t, s)
283- _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
284- require.True(t, os.IsNotExist(err))
285- })
286- }
287-}
288-
289 // TestCommittedStateAndRecoveryAreNeverFollowed replaces the committed state and192 // TestCommittedStateAndRecoveryAreNeverFollowed replaces the committed state and
290 // the recovery record with symlinks to valid bytes: the store reads both with193 // the recovery record with symlinks to valid bytes: the store reads both with
291 // O_NOFOLLOW, so a symlink fails closed instead of redirecting a rename or194 // O_NOFOLLOW, so a symlink fails closed instead of redirecting a rename or
@@ -335,81 +238,6 @@ func TestCommittedStateAndRecoveryAreNeverFollowed(t *testing.T) {
335 })238 })
336 }239 }
337 240
338-// TestManifestDistinguishesAbsentFromEmptySources pins the recovery manifest: an
339-// absent source and a supplied source that carries no records can produce the
340-// same membership, so presence, keyed by generic role label, is what makes the
341-// recovery record an exact description of the migration input.
342-func TestManifestDistinguishesAbsentFromEmptySources(t *testing.T) {
343- readManifest := func(t *testing.T, dir string) (manifest, string) {
344- t.Helper()
345- raw, err := readBounded(filepath.Join(dir, "recovery.json"))
346- require.NoError(t, err)
347- var r recovery
348- require.NoError(t, strict(raw, &r))
349- return r.State.Manifest, digest(nil)
350- }
351-
352- absent := options(t)
353- absent.LegacyHosts = ""
354- absent.LegacyCache = ""
355- absent.Policies = nil
356- absentStore, err := Open(absent)
357- require.NoError(t, err)
358- defer absentStore.Close()
359- hosts, err := absentStore.LoadHosts()
360- require.NoError(t, err)
361- require.Empty(t, hosts.Hosts)
362- absentManifest, emptyDigest := readManifest(t, absent.Dir)
363- for _, label := range []manifestSourceLabel{manifestMembership, manifestObservations} {
364- source, ok := absentManifest.source(label)
365- require.True(t, ok, label)
366- require.False(t, source.Present, label)
367- require.Equal(t, emptyDigest, source.SHA256, label)
368- }
369-
370- empty := options(t)
371- empty.LegacyCache = ""
372- empty.LegacyHosts = filepath.Join(t.TempDir(), "hosts")
373- require.NoError(t, os.WriteFile(empty.LegacyHosts, []byte(`{"version":1,"hosts":[]}`), 0600))
374- emptyStore, err := Open(empty)
375- require.NoError(t, err)
376- defer emptyStore.Close()
377- stillEmpty, err := emptyStore.LoadHosts()
378- require.NoError(t, err)
379- // The same empty membership results, but the manifest records that the
380- // membership source was supplied and what it contained.
381- require.Equal(t, hosts, stillEmpty)
382- emptyManifest, _ := readManifest(t, empty.Dir)
383- membership, ok := emptyManifest.source(manifestMembership)
384- require.True(t, ok)
385- require.True(t, membership.Present)
386- require.NotEqual(t, emptyDigest, membership.SHA256)
387- observations, ok := emptyManifest.source(manifestObservations)
388- require.True(t, ok)
389- require.False(t, observations.Present)
390-
391- // A record that claims the source was absent while retaining its bytes is
392- // rejected: presence is verified, not merely recorded.
393- raw, err := readBounded(filepath.Join(empty.Dir, "recovery.json"))
394- require.NoError(t, err)
395- var r recovery
396- require.NoError(t, strict(raw, &r))
397- for i := range r.State.Manifest.Sources {
398- if r.State.Manifest.Sources[i].Label == manifestMembership {
399- r.State.Manifest.Sources[i].Present = false
400- }
401- }
402- writer := Store{dir: empty.Dir}
403- require.NoError(t, writer.write("recovery.json", r))
404- require.NoError(t, os.Remove(filepath.Join(empty.Dir, "state.json")))
405- require.NoError(t, emptyStore.Close())
406- _, err = Open(Options{Dir: empty.Dir})
407- require.ErrorIs(t, err, ErrInvalidState)
408- require.ErrorContains(t, err, "absent membership source")
409-}
410-
411-// TestRevisionOverflowIsRefused keeps the store from ever minting revision zero
412-// or accepting an exhausted authority token.
413 func TestRevisionOverflowIsRefused(t *testing.T) {241 func TestRevisionOverflowIsRefused(t *testing.T) {
414 o := options(t)242 o := options(t)
415 s, err := Open(o)243 s, err := Open(o)
@@ -694,7 +522,7 @@ func TestFencingAndCommitFaults(t *testing.T) {
694 if point == "rename" || point == "dirsync" {522 if point == "rename" || point == "dirsync" {
695 require.Empty(t, h.Hosts)523 require.Empty(t, h.Hosts)
696 snap.Revision++524 snap.Revision++
697- require.ErrorIs(t, s.Store(snap), ErrStale)525+ require.NoError(t, s.Store(snap))
698 } else {526 } else {
699 require.Len(t, h.Hosts, 1)527 require.Len(t, h.Hosts, 1)
700 }528 }
M internal/ports/broker_store.go +20 -0
@@ -252,3 +252,23 @@ func ValidateDurableHostProjection(obs BrokerDaemonObservation) error {
252252 Sessions: obs.Sessions,
253253 }})
254254 }
255+
256+// ValidateDurableObservation checks the shared remote-only durable observation shape.
257+func ValidateDurableObservation(daemon BrokerDaemonObservation) error {
258+ if daemon.Local {
259+ return errors.New("local daemon observation is never durable")
260+ }
261+ if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil {
262+ return err
263+ }
264+ if err := daemon.Registration.Validate(); err != nil {
265+ return err
266+ }
267+ if daemon.Endpoint != daemon.Registration.Endpoint {
268+ return errors.New("observation endpoint does not match registration")
269+ }
270+ if len(daemon.Sessions) > 0 && !daemon.InventoryKnown {
271+ return errors.New("observation carries sessions without known inventory")
272+ }
273+ return ValidateDurableHostProjection(daemon)
274+}
@@ -252,3 +252,23 @@ func ValidateDurableHostProjection(obs BrokerDaemonObservation) error {
252 Sessions: obs.Sessions,252 Sessions: obs.Sessions,
253 }})253 }})
254 }254 }
255+
256+// ValidateDurableObservation checks the shared remote-only durable observation shape.
257+func ValidateDurableObservation(daemon BrokerDaemonObservation) error {
258+ if daemon.Local {
259+ return errors.New("local daemon observation is never durable")
260+ }
261+ if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil {
262+ return err
263+ }
264+ if err := daemon.Registration.Validate(); err != nil {
265+ return err
266+ }
267+ if daemon.Endpoint != daemon.Registration.Endpoint {
268+ return errors.New("observation endpoint does not match registration")
269+ }
270+ if len(daemon.Sessions) > 0 && !daemon.InventoryKnown {
271+ return errors.New("observation carries sessions without known inventory")
272+ }
273+ return ValidateDurableHostProjection(daemon)
274+}

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