1package remote
2
3import (
4 "encoding/json"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "slices"
9 "sort"
10 "strconv"
11 "strings"
12 "sync"
13 "testing"
14)
15
16func TestHostStorePath(t *testing.T) {
17 stateDir := filepath.Join(t.TempDir(), "state")
18 want := filepath.Join(stateDir, "hosts.json")
19 if got := HostStorePath(stateDir); got != want {
20 t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want)
21 }
22}
23
24func TestHostStoreCrossProcessHelper(t *testing.T) {
25 if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
26 t.Skip("helper process")
27 }
28
29 path := os.Getenv("VEV_HOST_STORE_PATH")
30 hosts := strings.Split(os.Getenv("VEV_HOST_STORE_HOSTS"), ",")
31 store := NewFileHostStore(path)
32 for _, host := range hosts {
33 if host == "" {
34 continue
35 }
36 if err := store.Remember(host); err != nil {
37 t.Fatalf("Remember(%q) error = %v", host, err)
38 }
39 }
40}
41
42func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) {
43 path := filepath.Join(t.TempDir(), "vev", "hosts.json")
44 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
45 t.Fatalf("mkdir: %v", err)
46 }
47
48 left := make([]string, 0, 40)
49 right := make([]string, 0, 40)
50 for i := 0; i < 40; i++ {
51 left = append(left, "left-"+strconv.Itoa(i))
52 right = append(right, "right-"+strconv.Itoa(i))
53 }
54
55 startHelper := func(hosts []string) *exec.Cmd {
56 cmd := exec.Command(os.Args[0], "-test.run=^TestHostStoreCrossProcessHelper$")
57 cmd.Env = append(os.Environ(),
58 "VEV_HOST_STORE_HELPER=1",
59 "VEV_HOST_STORE_PATH="+path,
60 "VEV_HOST_STORE_HOSTS="+strings.Join(hosts, ","),
61 )
62 cmd.Stderr = os.Stderr
63 return cmd
64 }
65
66 leftCmd := startHelper(left)
67 rightCmd := startHelper(right)
68 if err := leftCmd.Start(); err != nil {
69 t.Fatalf("start left helper: %v", err)
70 }
71 if err := rightCmd.Start(); err != nil {
72 t.Fatalf("start right helper: %v", err)
73 }
74 if err := leftCmd.Wait(); err != nil {
75 t.Fatalf("left helper: %v", err)
76 }
77 if err := rightCmd.Wait(); err != nil {
78 t.Fatalf("right helper: %v", err)
79 }
80
81 _, learned, err := NewFileHostStore(path).Hosts()
82 if err != nil {
83 t.Fatalf("Hosts() error = %v", err)
84 }
85 want := append(append([]string{}, left...), right...)
86 sort.Strings(want)
87 if !slices.Equal(learned, want) {
88 t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want))
89 }
90}
91
92func TestHostStore(t *testing.T) {
93 t.Parallel()
94
95 storePath := func(t *testing.T) string {
96 t.Helper()
97 dir := filepath.Join(t.TempDir(), "vev")
98 return filepath.Join(dir, "hosts.json")
99 }
100
101 writeHosts := func(t *testing.T, path string, raw []byte) {
102 t.Helper()
103 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
104 t.Fatalf("mkdir: %v", err)
105 }
106 if err := os.WriteFile(path, raw, 0o600); err != nil {
107 t.Fatalf("write hosts: %v", err)
108 }
109 }
110
111 t.Run("missing file returns empty lists", func(t *testing.T) {
112 t.Parallel()
113 path := storePath(t)
114 store := NewFileHostStore(path)
115
116 pinned, learned, err := store.Hosts()
117 if err != nil {
118 t.Fatalf("Hosts() error = %v", err)
119 }
120 if len(pinned) != 0 || len(learned) != 0 {
121 t.Fatalf("Hosts() = pinned %v learned %v, want empty", pinned, learned)
122 }
123 })
124
125 t.Run("pinned order preserved and learned sorted with private perms", func(t *testing.T) {
126 t.Parallel()
127 path := storePath(t)
128 store := NewFileHostStore(path)
129
130 if err := store.AddPinned("zebra"); err != nil {
131 t.Fatalf("AddPinned(zebra) error = %v", err)
132 }
133 if err := store.AddPinned("arch"); err != nil {
134 t.Fatalf("AddPinned(arch) error = %v", err)
135 }
136 if err := store.Remember("mule"); err != nil {
137 t.Fatalf("Remember(mule) error = %v", err)
138 }
139 if err := store.Remember("beta"); err != nil {
140 t.Fatalf("Remember(beta) error = %v", err)
141 }
142
143 pinned, learned, err := store.Hosts()
144 if err != nil {
145 t.Fatalf("Hosts() error = %v", err)
146 }
147 wantPinned := []string{"zebra", "arch"}
148 wantLearned := []string{"beta", "mule"}
149 if !slices.Equal(pinned, wantPinned) {
150 t.Fatalf("pinned = %v, want %v", pinned, wantPinned)
151 }
152 if !slices.Equal(learned, wantLearned) {
153 t.Fatalf("learned = %v, want %v", learned, wantLearned)
154 }
155
156 info, err := os.Stat(path)
157 if err != nil {
158 t.Fatalf("stat store: %v", err)
159 }
160 if perm := info.Mode().Perm(); perm != 0o600 {
161 t.Fatalf("store permissions = %04o, want 0600", perm)
162 }
163 dirInfo, err := os.Lstat(filepath.Dir(path))
164 if err != nil {
165 t.Fatalf("stat parent: %v", err)
166 }
167 if perm := dirInfo.Mode().Perm(); perm != 0o700 {
168 t.Fatalf("parent permissions = %04o, want 0700", perm)
169 }
170
171 raw, err := os.ReadFile(path)
172 if err != nil {
173 t.Fatalf("read store: %v", err)
174 }
175 var decoded hostsFile
176 if err := json.Unmarshal(raw, &decoded); err != nil {
177 t.Fatalf("decode store: %v", err)
178 }
179 if decoded.Version != 2 {
180 t.Fatalf("version = %d, want 2", decoded.Version)
181 }
182 if !slices.Equal(decoded.Pinned, wantPinned) {
183 t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned)
184 }
185 if !slices.Equal(decoded.Learned, wantLearned) {
186 t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned)
187 }
188 })
189
190 t.Run("overlap allowed across pinned and learned", func(t *testing.T) {
191 t.Parallel()
192 path := storePath(t)
193 store := NewFileHostStore(path)
194
195 if err := store.AddPinned("arch"); err != nil {
196 t.Fatalf("AddPinned(arch) error = %v", err)
197 }
198 if err := store.Remember("arch"); err != nil {
199 t.Fatalf("Remember(arch) error = %v", err)
200 }
201 pinned, learned, err := store.Hosts()
202 if err != nil {
203 t.Fatalf("Hosts() error = %v", err)
204 }
205 if !slices.Equal(pinned, []string{"arch"}) {
206 t.Fatalf("pinned = %v, want [arch]", pinned)
207 }
208 if !slices.Equal(learned, []string{"arch"}) {
209 t.Fatalf("learned = %v, want [arch]", learned)
210 }
211 })
212
213 t.Run("idempotent mutations and remove from both sources", func(t *testing.T) {
214 t.Parallel()
215 path := storePath(t)
216 store := NewFileHostStore(path)
217
218 if err := store.AddPinned("arch"); err != nil {
219 t.Fatalf("AddPinned(arch) error = %v", err)
220 }
221 if err := store.AddPinned("arch"); err != nil {
222 t.Fatalf("AddPinned(arch) again error = %v", err)
223 }
224 if err := store.Remember("mule"); err != nil {
225 t.Fatalf("Remember(mule) error = %v", err)
226 }
227 if err := store.Remember("mule"); err != nil {
228 t.Fatalf("Remember(mule) again error = %v", err)
229 }
230 if err := store.Remember("arch"); err != nil {
231 t.Fatalf("Remember(arch) error = %v", err)
232 }
233
234 if err := store.RemovePinned("missing"); err != nil {
235 t.Fatalf("RemovePinned(missing) error = %v", err)
236 }
237 if err := store.Forget("missing"); err != nil {
238 t.Fatalf("Forget(missing) error = %v", err)
239 }
240 if deleted, err := store.Remove("missing"); err != nil {
241 t.Fatalf("Remove(missing) error = %v", err)
242 } else if deleted {
243 t.Fatal("Remove(missing) deleted = true, want false")
244 }
245
246 if deleted, err := store.Remove("arch"); err != nil {
247 t.Fatalf("Remove(arch) error = %v", err)
248 } else if !deleted {
249 t.Fatal("Remove(arch) deleted = false, want true")
250 }
251 pinned, learned, err := store.Hosts()
252 if err != nil {
253 t.Fatalf("Hosts() error = %v", err)
254 }
255 if len(pinned) != 0 {
256 t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned)
257 }
258 if !slices.Equal(learned, []string{"mule"}) {
259 t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned)
260 }
261
262 if err := store.Forget("mule"); err != nil {
263 t.Fatalf("Forget(mule) error = %v", err)
264 }
265 if err := store.Forget("mule"); err != nil {
266 t.Fatalf("Forget(mule) again error = %v", err)
267 }
268 _, learned, err = store.Hosts()
269 if err != nil {
270 t.Fatalf("Hosts() after forget error = %v", err)
271 }
272 if len(learned) != 0 {
273 t.Fatalf("learned = %v, want empty", learned)
274 }
275 })
276
277 t.Run("malformed file is preserved and fails closed", func(t *testing.T) {
278 t.Parallel()
279 path := storePath(t)
280 malformed := []byte(`{"version":2,"pinned":`)
281 writeHosts(t, path, malformed)
282
283 store := NewFileHostStore(path)
284 if _, _, err := store.Hosts(); err == nil {
285 t.Fatal("Hosts() error = nil, want malformed error")
286 }
287 if err := store.AddPinned("arch"); err == nil {
288 t.Fatal("AddPinned() error = nil, want malformed error")
289 }
290 if err := store.Remember("arch"); err == nil {
291 t.Fatal("Remember() error = nil, want malformed error")
292 }
293 if _, err := store.Remove("arch"); err == nil {
294 t.Fatal("Remove() error = nil, want malformed error")
295 }
296
297 raw, err := os.ReadFile(path)
298 if err != nil {
299 t.Fatalf("read preserved file: %v", err)
300 }
301 if string(raw) != string(malformed) {
302 t.Fatalf("malformed file changed: got %q, want %q", raw, malformed)
303 }
304 })
305
306 t.Run("legacy version 1 migrates on mutation", func(t *testing.T) {
307 t.Parallel()
308 cases := []struct {
309 name string
310 raw string
311 wantLearned []string
312 }{
313 {name: "empty", raw: `{"version":1,"hosts":[]}`, wantLearned: []string{}},
314 {name: "hosts", raw: `{"version":1,"hosts":["mule","arch"]}`, wantLearned: []string{"arch", "mule"}},
315 }
316 for _, tc := range cases {
317 t.Run(tc.name, func(t *testing.T) {
318 t.Parallel()
319 path := storePath(t)
320 writeHosts(t, path, []byte(tc.raw))
321 store := NewFileHostStore(path)
322
323 pinned, learned, err := store.Hosts()
324 if err != nil {
325 t.Fatalf("Hosts() error = %v", err)
326 }
327 if len(pinned) != 0 || !slices.Equal(learned, tc.wantLearned) {
328 t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned)
329 }
330 raw, err := os.ReadFile(path)
331 if err != nil {
332 t.Fatalf("read legacy store: %v", err)
333 }
334 if string(raw) != tc.raw {
335 t.Fatalf("read-only Hosts rewrote file: got %q, want %q", raw, tc.raw)
336 }
337
338 if err := store.AddPinned("zebra"); err != nil {
339 t.Fatalf("AddPinned() error = %v", err)
340 }
341 raw, err = os.ReadFile(path)
342 if err != nil {
343 t.Fatalf("read migrated store: %v", err)
344 }
345 var decoded hostsFile
346 if err := json.Unmarshal(raw, &decoded); err != nil {
347 t.Fatalf("decode migrated store: %v", err)
348 }
349 if decoded.Version != hostsFileVersion {
350 t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
351 }
352 if !slices.Equal(decoded.Pinned, []string{"zebra"}) || !slices.Equal(decoded.Learned, tc.wantLearned) {
353 t.Fatalf("migrated store = pinned %v learned %v", decoded.Pinned, decoded.Learned)
354 }
355 })
356 }
357 })
358
359 t.Run("schema failures fail closed and preserve file", func(t *testing.T) {
360 t.Parallel()
361 cases := []struct {
362 name string
363 raw string
364 wantError string
365 }{
366 {name: "unsupported version", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "unsupported hosts file version 3"},
367 {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"},
368 {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"},
369 {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"},
370 {name: "duplicate learned", raw: `{"version":2,"pinned":[],"learned":["mule","mule"]}`, wantError: "malformed hosts file"},
371 {name: "invalid target", raw: `{"version":2,"pinned":[" arch"],"learned":[]}`, wantError: "malformed hosts file"},
372 {name: "invalid utf8", raw: "{\"version\":2,\"pinned\":[\"\xff\"],\"learned\":[]}", wantError: "malformed hosts file"},
373 {name: "legacy missing hosts", raw: `{"version":1}`, wantError: "malformed hosts file: missing hosts"},
374 {name: "legacy null hosts", raw: `{"version":1,"hosts":null}`, wantError: "malformed hosts file: missing hosts"},
375 {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"},
376 {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"},
377 {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"},
378 }
379 for _, tc := range cases {
380 t.Run(tc.name, func(t *testing.T) {
381 t.Parallel()
382 path := storePath(t)
383 writeHosts(t, path, []byte(tc.raw))
384
385 store := NewFileHostStore(path)
386 if _, _, err := store.Hosts(); err == nil {
387 t.Fatal("Hosts() error = nil, want schema error")
388 } else if !strings.Contains(err.Error(), tc.wantError) {
389 t.Fatalf("Hosts() error = %q, want substring %q", err, tc.wantError)
390 }
391 if err := store.Remember("arch"); err == nil {
392 t.Fatal("Remember() error = nil, want schema error")
393 }
394 raw, err := os.ReadFile(path)
395 if err != nil {
396 t.Fatalf("read preserved file: %v", err)
397 }
398 if string(raw) != tc.raw {
399 t.Fatalf("file changed: got %q, want %q", raw, tc.raw)
400 }
401 })
402 }
403 })
404
405 t.Run("sorts unsorted learned on read without rewriting until mutation", func(t *testing.T) {
406 t.Parallel()
407 path := storePath(t)
408 unsorted := []byte(`{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` + "\n")
409 writeHosts(t, path, unsorted)
410
411 store := NewFileHostStore(path)
412 pinned, learned, err := store.Hosts()
413 if err != nil {
414 t.Fatalf("Hosts() error = %v", err)
415 }
416 if !slices.Equal(pinned, []string{"zebra", "arch"}) {
417 t.Fatalf("pinned = %v, want [zebra arch]", pinned)
418 }
419 if !slices.Equal(learned, []string{"beta", "mule"}) {
420 t.Fatalf("learned = %v, want [beta mule]", learned)
421 }
422 raw, err := os.ReadFile(path)
423 if err != nil {
424 t.Fatalf("read store: %v", err)
425 }
426 if string(raw) != string(unsorted) {
427 t.Fatalf("read-only Hosts rewrote file: got %q", raw)
428 }
429
430 if err := store.Remember("gamma"); err != nil {
431 t.Fatalf("Remember(gamma) error = %v", err)
432 }
433 raw, err = os.ReadFile(path)
434 if err != nil {
435 t.Fatalf("read after mutation: %v", err)
436 }
437 var decoded hostsFile
438 if err := json.Unmarshal(raw, &decoded); err != nil {
439 t.Fatalf("decode: %v", err)
440 }
441 if !slices.Equal(decoded.Pinned, []string{"zebra", "arch"}) {
442 t.Fatalf("pinned after mutation = %v", decoded.Pinned)
443 }
444 if !slices.Equal(decoded.Learned, []string{"beta", "gamma", "mule"}) {
445 t.Fatalf("learned after mutation = %v", decoded.Learned)
446 }
447 })
448
449 t.Run("rejects invalid targets", func(t *testing.T) {
450 t.Parallel()
451 path := storePath(t)
452 store := NewFileHostStore(path)
453
454 for _, target := range []string{"", " arch", "arch ", "ar ch", "arch\n", string([]byte{0xff})} {
455 if err := store.AddPinned(target); err == nil {
456 t.Fatalf("AddPinned(%q) error = nil, want validation error", target)
457 }
458 if err := store.Remember(target); err == nil {
459 t.Fatalf("Remember(%q) error = nil, want validation error", target)
460 }
461 if _, err := store.Remove(target); err == nil {
462 t.Fatalf("Remove(%q) error = nil, want validation error", target)
463 }
464 }
465 })
466
467 t.Run("rejects symlink parent", func(t *testing.T) {
468 t.Parallel()
469 root := t.TempDir()
470 realDir := filepath.Join(root, "real")
471 if err := os.MkdirAll(realDir, 0o700); err != nil {
472 t.Fatalf("mkdir real: %v", err)
473 }
474 linkDir := filepath.Join(root, "link")
475 if err := os.Symlink(realDir, linkDir); err != nil {
476 t.Fatalf("symlink parent: %v", err)
477 }
478 path := filepath.Join(linkDir, "hosts.json")
479 store := NewFileHostStore(path)
480
481 if err := store.Remember("arch"); err == nil {
482 t.Fatal("Remember() error = nil, want symlink parent rejection")
483 }
484 })
485
486 t.Run("rejects non-private parent permissions", func(t *testing.T) {
487 t.Parallel()
488 path := storePath(t)
489 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
490 t.Fatalf("mkdir: %v", err)
491 }
492 store := NewFileHostStore(path)
493 if err := store.Remember("arch"); err == nil {
494 t.Fatal("Remember() error = nil, want permission rejection")
495 }
496 })
497
498 t.Run("atomic replacement leaves no temp files", func(t *testing.T) {
499 t.Parallel()
500 path := storePath(t)
501 store := NewFileHostStore(path)
502 if err := store.AddPinned("arch"); err != nil {
503 t.Fatalf("AddPinned() error = %v", err)
504 }
505 if err := store.Remember("mule"); err != nil {
506 t.Fatalf("Remember() error = %v", err)
507 }
508
509 entries, err := os.ReadDir(filepath.Dir(path))
510 if err != nil {
511 t.Fatalf("readdir: %v", err)
512 }
513 base := filepath.Base(path)
514 for _, entry := range entries {
515 name := entry.Name()
516 if name == base || strings.HasSuffix(name, ".lock") {
517 continue
518 }
519 if strings.Contains(name, ".tmp") {
520 t.Fatalf("leftover temp file %q", name)
521 }
522 t.Fatalf("unexpected leftover entry %q", name)
523 }
524 })
525
526 t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) {
527 t.Parallel()
528 path := storePath(t)
529 dir := filepath.Dir(path)
530 if err := os.MkdirAll(dir, 0o700); err != nil {
531 t.Fatalf("mkdir: %v", err)
532 }
533 if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil {
534 t.Fatalf("seed hosts: %v", err)
535 }
536 lockPath := path + ".lock"
537 if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
538 t.Fatalf("seed lock: %v", err)
539 }
540 info, err := os.Stat(lockPath)
541 if err != nil {
542 t.Fatalf("stat seeded lock: %v", err)
543 }
544 if perm := info.Mode().Perm(); perm != 0o644 {
545 t.Fatalf("seeded lock permissions = %04o, want 0644", perm)
546 }
547
548 store := NewFileHostStore(path)
549 if err := store.Remember("arch"); err != nil {
550 t.Fatalf("Remember() error = %v", err)
551 }
552
553 info, err = os.Stat(path)
554 if err != nil {
555 t.Fatalf("stat hosts: %v", err)
556 }
557 if perm := info.Mode().Perm(); perm != 0o600 {
558 t.Fatalf("hosts permissions = %04o, want 0600", perm)
559 }
560 info, err = os.Stat(lockPath)
561 if err != nil {
562 t.Fatalf("stat lock: %v", err)
563 }
564 if perm := info.Mode().Perm(); perm != 0o600 {
565 t.Fatalf("lock permissions = %04o, want 0600", perm)
566 }
567 })
568
569 t.Run("failed mutation rolls back without partial write", func(t *testing.T) {
570 t.Parallel()
571 path := storePath(t)
572 store := NewFileHostStore(path)
573 if err := store.AddPinned("arch"); err != nil {
574 t.Fatalf("AddPinned() error = %v", err)
575 }
576 if err := store.Remember("mule"); err != nil {
577 t.Fatalf("Remember() error = %v", err)
578 }
579 before, err := os.ReadFile(path)
580 if err != nil {
581 t.Fatalf("read before: %v", err)
582 }
583
584 // Replace with malformed content while holding no store lock; next mutation must fail closed.
585 malformed := []byte(`{"version":2,"pinned":`)
586 if err := os.WriteFile(path, malformed, 0o600); err != nil {
587 t.Fatalf("inject malformed: %v", err)
588 }
589 if err := store.AddPinned("zebra"); err == nil {
590 t.Fatal("AddPinned() error = nil, want malformed error")
591 }
592 after, err := os.ReadFile(path)
593 if err != nil {
594 t.Fatalf("read after failed mutation: %v", err)
595 }
596 if string(after) != string(malformed) {
597 t.Fatalf("failed mutation changed file: got %q want %q (previous good was %q)", after, malformed, before)
598 }
599 })
600
601 t.Run("concurrent independent stores lose no hosts", func(t *testing.T) {
602 t.Parallel()
603 path := storePath(t)
604
605 left := make([]string, 0, 40)
606 right := make([]string, 0, 40)
607 for i := 0; i < 40; i++ {
608 left = append(left, "left-"+strconv.Itoa(i))
609 right = append(right, "right-"+strconv.Itoa(i))
610 }
611
612 start := make(chan struct{})
613 var wg sync.WaitGroup
614 errs := make(chan error, 2)
615
616 wg.Add(2)
617 go func() {
618 defer wg.Done()
619 store := NewFileHostStore(path)
620 <-start
621 for _, host := range left {
622 if err := store.Remember(host); err != nil {
623 errs <- err
624 return
625 }
626 }
627 }()
628 go func() {
629 defer wg.Done()
630 store := NewFileHostStore(path)
631 <-start
632 for _, host := range right {
633 if err := store.AddPinned(host); err != nil {
634 errs <- err
635 return
636 }
637 }
638 }()
639
640 close(start)
641 wg.Wait()
642 close(errs)
643 for err := range errs {
644 t.Fatalf("concurrent mutation error = %v", err)
645 }
646
647 pinned, learned, err := NewFileHostStore(path).Hosts()
648 if err != nil {
649 t.Fatalf("Hosts() error = %v", err)
650 }
651 wantLearned := append([]string{}, left...)
652 sort.Strings(wantLearned)
653 if !slices.Equal(learned, wantLearned) {
654 t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned))
655 }
656 if len(pinned) != len(right) {
657 t.Fatalf("pinned len=%d want %d", len(pinned), len(right))
658 }
659 pinnedSet := make(map[string]struct{}, len(pinned))
660 for _, host := range pinned {
661 pinnedSet[host] = struct{}{}
662 }
663 for _, host := range right {
664 if _, ok := pinnedSet[host]; !ok {
665 t.Fatalf("pinned missing %q", host)
666 }
667 }
668 })
669}