carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io/fs"
10 "os"
11 "path"
12 "path/filepath"
13 "reflect"
14 "slices"
15 "strings"
16 "sync"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/platform/internal/hostbox"
21 "github.com/carlosframework/platform/internal/hostclaim"
22 "github.com/carlosframework/platform/internal/hostfiles"
23 "github.com/carlosframework/platform/internal/lease"
24 "github.com/carlosframework/platform/internal/moves"
25 "github.com/carlosframework/platform/internal/moves/archive"
26 "github.com/carlosframework/platform/internal/provision"
27)
28
29// The cut's files preflight (#236; Paul's ruling of 2026-10-15): a source
30// whose files manifest a recovery could not restore, because an entry path
31// is past the readers' cap, is refused at S1 before anything is staged,
32// stopped or cut.
33
34// capPath is a clean relative path of exactly n bytes: 200-byte segments
35// joined by "/", as hostfiles' own tests build them.
36func capPath(n int) string {
37 var b strings.Builder
38 for b.Len() < n {
39 if b.Len() > 0 {
40 b.WriteByte('/')
41 }
42 b.WriteString(strings.Repeat("x", min(200, n-b.Len())))
43 }
44 return b.String()
45}
46
47// capCase is one manifest shape: its one entry's kind (live, tombstone or
48// skipped) at a path of the given length.
49type capCase struct {
50 kind string
51 n int
52}
53
54// capCases are the shapes every test here runs: an ordinary manifest, a
55// live entry at the cap, and each kind of entry one byte past it.
56var capCases = map[string]capCase{
57 "an ordinary manifest": {"live", len("a.txt")},
58 "a live entry at the cap": {"live", hostfiles.MaxPathBytes},
59 "a live entry past it": {"live", hostfiles.MaxPathBytes + 1},
60 "a tombstone past it": {"tombstone", hostfiles.MaxPathBytes + 1},
61 "a skipped entry past it": {"skipped", hostfiles.MaxPathBytes + 1},
62}
63
64func (c capCase) path() string {
65 if c.n == len("a.txt") {
66 return "a.txt"
67 }
68 return capPath(c.n)
69}
70
71// manifest is the case's manifest for generation gen at location loc: a
72// live entry names an object under gen's files prefix, as the publisher
73// writes it.
74func (c capCase) manifest(loc, gen string) hostfiles.Manifest {
75 var e hostfiles.Entry
76 switch c.kind {
77 case "live":
78 sum := sha([]byte("A"))
79 e = hostfiles.Entry{Object: movesFilesPrefix(loc, gen) + fmt.Sprintf("objects/%016d-%s", 1, sum), SHA256: sum, Size: 1, Mode: 0o600, Seq: 1}
80 case "tombstone":
81 e = hostfiles.Entry{Seq: 1, Deleted: true}
82 case "skipped":
83 e = hostfiles.Entry{Seq: 1, Skipped: "too-large"}
84 }
85 return hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 1, Entries: map[string]hostfiles.Entry{c.path(): e}, Platform: map[string]hostfiles.Entry{}}
86}
87
88// readerRefuses is what the files readers do with m today: ValidateManifest
89// (checkManifest, the establishment's reader) refuses it as ErrTooDeep. The
90// tests hold the cut to this verdict rather than to a fixed answer per
91// kind: today the readers cap every entry, tombstones and skipped entries
92// included, so the cut refuses all three past the cap. If region HA
93// relaxes the readers for tombstones and skipped entries (its part of
94// #236), RestorableWithinCap follows, and the cut with it.
95func readerRefuses(m hostfiles.Manifest, loc, gen string) bool {
96 return errors.Is(hostfiles.ValidateManifest(m, []string{movesFilesPrefix(loc, gen)}), hostfiles.ErrTooDeep)
97}
98
99// putP1Manifest stores m as the current version of gen's files manifest in
100// the Phase 1 harness's data bucket, written directly (a manifest from
101// before the cap), never through the staging mirror.
102func putP1Manifest(t *testing.T, h *phase1Harness, loc, gen string, m hostfiles.Manifest) {
103 t.Helper()
104 raw, err := json.Marshal(m)
105 if err != nil {
106 t.Fatal(err)
107 }
108 key := hostfiles.ManifestKey(movesFilesPrefix(loc, gen))
109 h.f.store("data-ie", key, fakeMoveObject{lease.MirrorObject{ETag: "manifest-" + gen, Size: int64(len(raw))}, string(raw)})
110 h.f.setVersion("data-ie", key, "ver-manifest-"+gen)
111}
112
113// A source whose files manifest has an entry past the cap is refused at S1
114// with MV-N501, and nothing is staged: no staging request, no touch of the
115// target in the claim, the move still preparing. A manifest the readers
116// accept passes, and the round stages as before.
117func TestPreparingRefusesAFilesManifestPastTheCap(t *testing.T) {
118 t.Parallel()
119 for name, c := range capCases {
120 t.Run(name, func(t *testing.T) {
121 h := newPhase1Harness(t)
122 h.start(t, "manual")
123 loc := "instances/" + p1Host + "/" + h.inc
124 m := c.manifest(loc, "cur")
125 putP1Manifest(t, h, loc, "cur", m)
126 touched := h.claim(t).Touched
127 h.pass(t)
128 sr := h.sourceReport(t)
129 if sr == nil {
130 t.Fatal("no source report")
131 }
132 refuses := readerRefuses(m, loc, "cur")
133 if c.kind == "live" && refuses != (c.n > hostfiles.MaxPathBytes) {
134 t.Fatalf("the reader's verdict on a live entry of %d bytes is %v", c.n, refuses)
135 }
136 if !refuses {
137 if !sr.S1.OK || len(h.f.stagingCalls()) == 0 {
138 t.Fatalf("a manifest the readers accept: S1 %+v, %d staging calls", sr.S1, len(h.f.stagingCalls()))
139 }
140 return
141 }
142 if sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) {
143 t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilesPastCap(p1Host))
144 }
145 if calls := h.f.stagingCalls(); len(calls) != 0 {
146 t.Fatalf("staged despite the refusal: %+v", calls)
147 }
148 if got := h.claim(t).Touched; !reflect.DeepEqual(got, touched) {
149 t.Fatalf("the claim was touched: %+v, was %+v", got, touched)
150 }
151 if st := h.read(t, p1Host).Move.State; st != provision.MovePreparing {
152 t.Fatalf("the move is %s", st)
153 }
154 })
155 }
156}
157
158// A files manifest that cannot be read ends the round as the other read
159// errors do: no source report, nothing staged, the reason in the log.
160func TestPreparingEndsTheRoundWhenTheFilesManifestCannotBeRead(t *testing.T) {
161 h := newPhase1Harness(t)
162 h.start(t, "manual")
163 h.vs.deny = func(op, _, key string) error {
164 if op == "list-versions" && strings.Contains(key, "/files/g-") {
165 return errors.New("injected 403")
166 }
167 return nil
168 }
169 logs := captureLog(t)
170 h.pass(t)
171 if sr := h.sourceReport(t); sr != nil {
172 t.Fatalf("a report was written: %+v", sr)
173 }
174 if calls := h.f.stagingCalls(); len(calls) != 0 {
175 t.Fatalf("staged: %+v", calls)
176 }
177 if !strings.Contains(logs(), "read the files manifest") {
178 t.Fatalf("no log of the read:\n%s", logs())
179 }
180}
181
182// stallingVersioned is the harness's versioned reader with the files
183// manifest's download stalled: GetVersion on a manifest key blocks until
184// its context ends, as an S3 body that stops arriving does, and answers
185// the context's error.
186type stallingVersioned struct {
187 *fakeVersioned
188 stalled chan struct{} // closed when the first stalled read starts
189 once sync.Once
190}
191
192func (v *stallingVersioned) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
193 if strings.Contains(key, "/files/g-") && strings.HasSuffix(key, "/manifest.json") {
194 v.once.Do(func() { close(v.stalled) })
195 <-ctx.Done()
196 return nil, ctx.Err()
197 }
198 return v.fakeVersioned.GetVersion(ctx, bucket, key, versionID)
199}
200
201// Fix round 1, P2 1: the preflight's reads are bounded
202// (movesFilesPreflightTimeout). A stalled manifest download is cancelled at
203// the bound, even under a pass with no deadline of its own; the round ends
204// with no report and nothing staged; the staging lease is released and the
205// pass mutex is free, so the next pass runs.
206func TestPreparingBoundsAStalledFilesManifestRead(t *testing.T) {
207 const bound = 50 * time.Millisecond
208 orig := movesFilesPreflightBound
209 movesFilesPreflightBound = bound
210 defer func() { movesFilesPreflightBound = orig }()
211 if movesFilesPreflightTimeout <= bound || movesFilesPreflightTimeout > hostbox.RequestLimit {
212 t.Fatalf("the production bound %v is not a short one inside the claim's request limit", movesFilesPreflightTimeout)
213 }
214
215 h := newPhase1Harness(t)
216 h.start(t, "manual")
217 loc := "instances/" + p1Host + "/" + h.inc
218 putP1Manifest(t, h, loc, "cur", capCases["an ordinary manifest"].manifest(loc, "cur"))
219 sv := &stallingVersioned{fakeVersioned: h.vs, stalled: make(chan struct{})}
220 h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return sv, nil }
221 logs := captureLog(t)
222
223 done := make(chan error, 1)
224 start := time.Now()
225 go func() { done <- movesPass(context.Background(), h.s, h.w()) }()
226 select {
227 case <-done:
228 case <-time.After(10 * time.Second):
229 t.Fatal("the pass is still blocked on the stalled read")
230 }
231 if elapsed := time.Since(start); elapsed < bound {
232 t.Fatalf("the pass ended after %v, before the bound %v", elapsed, bound)
233 }
234 select {
235 case <-sv.stalled:
236 default:
237 t.Fatal("the manifest download was never asked for")
238 }
239 if sr := h.sourceReport(t); sr != nil {
240 t.Fatalf("a report was written: %+v", sr)
241 }
242 if calls := h.f.stagingCalls(); len(calls) != 0 {
243 t.Fatalf("staged: %+v", calls)
244 }
245 if !strings.Contains(logs(), context.DeadlineExceeded.Error()) {
246 t.Fatalf("no log of the cancelled read:\n%s", logs())
247 }
248 h.lease.mu.Lock()
249 acquired, released := h.lease.acquired, h.lease.released
250 h.lease.mu.Unlock()
251 if acquired == 0 || acquired != released {
252 t.Fatalf("staging lease acquired %d, released %d", acquired, released)
253 }
254 if !h.w().pass.TryLock() {
255 t.Fatal("the pass mutex is still held")
256 }
257 h.w().pass.Unlock()
258
259 // The download recovers: the next pass reports and stages.
260 h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.vs, nil }
261 h.pass(t)
262 if sr := h.sourceReport(t); sr == nil || !sr.S1.OK || len(h.f.stagingCalls()) == 0 {
263 t.Fatalf("the next pass: %+v, %d staging calls", sr, len(h.f.stagingCalls()))
264 }
265}
266
267// Fix round 1, P2 2: the files directory itself is held to the tree's cap.
268// A file whose path inside it is 4096 bytes (the archive format's own
269// bound, which used to pass) is refused at S1 with MV-N502 before anything
270// is staged; one of 4095 bytes passes and its archive is staged. The
271// files directory is under the data directory, so both absolute paths are
272// past PATH_MAX: the archive reads through an os.Root.
273func TestPreparingRefusesAFilePathPastTheTreeCap(t *testing.T) {
274 for _, n := range []int{hostfiles.MaxPathBytes + 1, hostfiles.MaxPathBytes} {
275 t.Run(fmt.Sprint(n), func(t *testing.T) {
276 h := newPhase1Harness(t)
277 h.start(t, "manual")
278 root, err := os.OpenRoot(filepath.Join(h.cfg.DataDir, p1Host))
279 if err != nil {
280 t.Fatal(err)
281 }
282 defer root.Close()
283 leaf := capPath(n)
284 if err := root.MkdirAll(path.Dir(leaf), 0o755); err != nil {
285 t.Fatal(err)
286 }
287 if err := root.WriteFile(leaf, []byte("deep"), 0o644); err != nil {
288 t.Fatal(err)
289 }
290 h.pass(t)
291 sr := h.sourceReport(t)
292 if sr == nil {
293 t.Fatal("no source report")
294 }
295 if n > hostfiles.MaxPathBytes {
296 if sr.S1.OK || sr.S1.Reason != s1FilePastCap(p1Host) {
297 t.Fatalf("S1 = %+v, want %q", sr.S1, s1FilePastCap(p1Host))
298 }
299 if calls := h.f.stagingCalls(); slices.ContainsFunc(calls, func(c stagingCall) bool { return strings.HasPrefix(c.Key, moves.FilesPrefix(p1Host, p1ID)) }) {
300 t.Fatalf("an archive was staged: %+v", calls)
301 }
302 return
303 }
304 if !sr.S1.OK || sr.Archive == nil {
305 t.Fatalf("a path at the cap: S1 %+v, archive %+v", sr.S1, sr.Archive)
306 }
307 })
308 }
309}
310
311// Fix round 1, P2 2(b): a path at the tree's cap goes end to end. The
312// source's archive carries it, the target's Extract places it, and the
313// prepared files manifest build stages it (hostfiles.Prepared.Stage), with
314// the extracted tree's absolute paths past PATH_MAX: a 4095-byte file, a
315// deep tree of files near it, and an ordinary one. The build reads through
316// an os.Root over the files directory, every name relative to it.
317func TestATreeCapPathReachesThePreparedManifest(t *testing.T) {
318 ctx := context.Background()
319 x := newFilesFixture(t)
320 src := filepath.Join(t.TempDir(), "h")
321 if err := os.Mkdir(src, 0o755); err != nil {
322 t.Fatal(err)
323 }
324 root, err := os.OpenRoot(src)
325 if err != nil {
326 t.Fatal(err)
327 }
328 defer root.Close()
329 files := map[string]string{"a.txt": "A"}
330 files[capPath(hostfiles.MaxPathBytes)] = "at the cap"
331 for _, n := range []int{hostfiles.MaxPathBytes - 1, 4000, 3900} {
332 files[capPath(n)+"-"+fmt.Sprint(n)] = fmt.Sprint(n) // under one deep tree
333 }
334 for rel, body := range files {
335 if len(rel) > hostfiles.MaxPathBytes {
336 rel = rel[:hostfiles.MaxPathBytes]
337 }
338 if err := root.MkdirAll(path.Dir(rel), 0o755); err != nil {
339 t.Fatal(err)
340 }
341 if err := root.WriteFile(rel, []byte(body), 0o644); err != nil {
342 t.Fatal(err)
343 }
344 }
345 var tarz bytes.Buffer
346 if _, _, _, err := archive.Build(src, "", &tarz); err != nil {
347 t.Fatalf("the source's archive: %v", err)
348 }
349 dst := filepath.Join(x.dir, filesHost)
350 if len(dst)+1+hostfiles.MaxPathBytes <= 4096 {
351 t.Fatalf("%s does not put the tree past PATH_MAX", dst)
352 }
353 if err := archive.Extract(bytes.NewReader(tarz.Bytes()), dst, x.envPath()); err != nil {
354 t.Fatalf("the target's extract: %v", err)
355 }
356 if _, err := x.f.BuildPrepared(ctx, filesLoc, "g1", "g1", 7, dst, x.envPath(), false, hostfiles.WakeState{V: 1, Host: filesHost, Timers: []hostfiles.WakeTimer{}}); err != nil {
357 t.Fatalf("the prepared manifest: %v", err)
358 }
359 m, found := x.manifest("g1")
360 if !found {
361 t.Fatal("no prepared manifest")
362 }
363 staged := 0
364 err = fs.WalkDir(root.FS(), ".", func(rel string, d fs.DirEntry, err error) error {
365 if err != nil || d.IsDir() {
366 return err
367 }
368 body, err := root.ReadFile(rel)
369 if err != nil {
370 return err
371 }
372 if e, ok := m.Entries[rel]; !ok || e.SHA256 != sha(body) || e.Skipped != "" {
373 t.Errorf("%d-byte path: entry %+v, %v", len(rel), e, ok)
374 }
375 staged++
376 return nil
377 })
378 if err != nil {
379 t.Fatal(err)
380 }
381 if staged != len(files) || len(m.Entries) != len(files) {
382 t.Fatalf("%d files, %d walked, %d entries", len(files), staged, len(m.Entries))
383 }
384 if err := movesFilesRestorable(m); err != nil {
385 t.Fatalf("the prepared manifest is not restorable: %v", err)
386 }
387}
388
389// The cut's requested round runs the same source round (G13): a manifest
390// past the cap that appeared after readiness sends the cut back to ready
391// before anything holds, fences or stops, with MV-N501 in S1, and the move
392// is not pinned again while it stands. Once an operator replaces the
393// manifest, a passing round pins it again and the cut admits.
394func TestCutRefusesAFilesManifestPastTheCap(t *testing.T) {
395 h := newCutHarness(t)
396 h.ready(t)
397 h.ev.stopped = false // stop at stopping, to look at the admission
398 past := capCases["a live entry past it"]
399 putP1Manifest(t, h.phase1Harness, cutRoot, "cur", past.manifest(cutRoot, "cur"))
400 h.cut(t)
401 h.pass(t)
402 m := h.move(t)
403 if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil {
404 t.Fatalf("not back to ready: %+v", m)
405 }
406 if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 {
407 t.Fatalf("something stopped: %d evidence calls, holds %v, %d fences", h.ev.calls, h.holds, h.fences)
408 }
409 if sr := h.sourceReport(t); sr == nil || sr.S1.OK || sr.S1.Reason != s1FilesPastCap(p1Host) {
410 t.Fatalf("S1 = %+v, want %q", sr, s1FilesPastCap(p1Host))
411 }
412 h.targetPass(t, h.target())
413 h.pass(t)
414 if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil {
415 t.Fatalf("pinned again while the manifest stands: %+v", m)
416 }
417
418 // The operator replaces the manifest.
419 putP1Manifest(t, h.phase1Harness, cutRoot, "cur", capCases["an ordinary manifest"].manifest(cutRoot, "cur"))
420 // A refused report carries no archive, so the source reports once, the
421 // target checks that report, and the next round pins.
422 h.pass(t)
423 h.targetPass(t, h.target())
424 h.pass(t)
425 if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil {
426 t.Fatalf("not pinned again after the replacement: %+v", m)
427 }
428 h.cut(t)
429 h.pass(t)
430 if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping {
431 t.Fatalf("the cut did not admit after the replacement: %+v", m)
432 }
433}
434
435// The cut's preflight and the recovery's establishment agree on every
436// manifest shape, so they cannot drift: the source's round before the flip
437// (sourceFilesRefusal over the holder at src) refuses exactly the
438// manifests a recovery's Establish refuses as ErrTooDeep, through the
439// src_gen bridge of a clean return and through a revert's own lineage, and
440// both agree with the readers (readerRefuses). Both call
441// movesFilesRestorable; a manifest the cut passes, the recovery
442// establishes from.
443func TestCutPreflightAndRecoveryAgreeOnTheCap(t *testing.T) {
444 ctx := context.Background()
445 recoveries := map[string]func(o *hostclaim.Observed){
446 "a clean return": func(*hostclaim.Observed) {},
447 "a revert": func(o *hostclaim.Observed) {
448 o.Claim.Op.Kind = hostclaim.KindReverting
449 delete(o.Claim.Op.Intent, "rollback")
450 o.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "src", Region: "uk", Predecessor: "@root", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"},
451 {Generation: "r1", Region: "uk", Predecessor: "src", PredecessorRegion: "uk", Mode: "cold", Session: 3, Box: "uk-box"}}
452 },
453 }
454 for name, c := range capCases {
455 for rname, shape := range recoveries {
456 t.Run(name+", "+rname, func(t *testing.T) {
457 x := newFilesFixture(t)
458 x.writeEnv("K=1\n")
459 m := c.manifest(filesLoc, "src")
460 raw, err := json.Marshal(m)
461 if err != nil {
462 t.Fatal(err)
463 }
464 x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "src")), raw)
465
466 // The cut's side: the source's round before the flip, its
467 // holder at src.
468 w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2",
469 Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}}
470 pre := servingObserved("src", "src:uk:@root:uk")
471 why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder)
472 if err != nil {
473 t.Fatalf("the preflight: %v", err)
474 }
475 if why != "" && why != s1FilesPastCap(filesHost) {
476 t.Fatalf("the preflight's reason %q", why)
477 }
478
479 // The recovery's side: the first recovery admission after
480 // the cut, whose src_gen is the holder's generation then.
481 o := recoveryObserved(hostclaim.KindRestoring, "src", "mv1", "r1")
482 shape(&o)
483 o = x.observe(o, "src", "r1")
484 est := x.f.Establish(ctx, o, launchInputs("r1"), x)
485 if est != nil && !errors.Is(est, hostfiles.ErrTooDeep) {
486 t.Fatalf("the establishment failed for another reason: %v", est)
487 }
488
489 cut, recovery, reader := why != "", est != nil, readerRefuses(m, filesLoc, "src")
490 if cut != recovery || cut != reader {
491 t.Fatalf("the cut refuses %v, the recovery refuses %v (%v), the readers refuse %v", cut, recovery, est, reader)
492 }
493 })
494 }
495 }
496}
497
498// The establishment applies the cap before any origin proof: a manifest
499// past it is ErrTooDeep with no Proven call, by the same function the
500// cut's preflight calls.
501func TestEstablishRefusesPastTheCapBeforeTheProof(t *testing.T) {
502 ctx := context.Background()
503 x := newFilesFixture(t)
504 x.writeEnv("K=1\n")
505 m := capCases["a live entry past it"].manifest(filesLoc, "src")
506 if err := movesFilesRestorable(m); !errors.Is(err, hostfiles.ErrTooDeep) {
507 t.Fatalf("movesFilesRestorable: %v", err)
508 }
509 proof := &countingProof{proven: map[string]bool{"src": true, "r1": true}}
510 x.f.Proof = proof
511 if _, err := x.f.movesFilesAllowed(ctx, servingObserved("src"), launchInputs("r1").Admission, filesLoc, "r1", m); !errors.Is(err, hostfiles.ErrTooDeep) {
512 t.Fatalf("movesFilesAllowed: %v", err)
513 }
514 if len(proof.calls) != 0 {
515 t.Fatalf("the proof was asked %v", proof.calls)
516 }
517}
518
519// The preflight reads by exact version through the data bucket's versioned
520// reader; a holder with no location, or a chain with no manifest, has
521// nothing to check.
522func TestSourceFilesRefusalNeedsAManifestToRefuse(t *testing.T) {
523 ctx := context.Background()
524 x := newFilesFixture(t)
525 w := &movesWorker{cfg: moveCfg{Region: "uk", OwnBucket: "data", OwnBucketRegion: "eu-west-2",
526 Versioned: func(context.Context, string, string) (lease.VersionedStore, error) { return x.bucket, nil }}}
527 pre := servingObserved("src", "src:uk:@root:uk")
528 if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); why != "" || err != nil {
529 t.Fatalf("no manifest: %q, %v", why, err)
530 }
531 noLoc := *pre.Claim.Holder
532 noLoc.Location = ""
533 if why, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, &noLoc); why != "" || err != nil {
534 t.Fatalf("no location: %q, %v", why, err)
535 }
536 w.cfg.Versioned = nil
537 if _, err := w.sourceFilesRefusal(ctx, provision.Record{Host: filesHost}, pre.Claim.Holder); err == nil {
538 t.Fatal("no versioned reader passed")
539 }
540}
541