oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package home
4
5import (
6 "bytes"
7 "context"
8 "crypto/sha256"
9 "database/sql"
10 "encoding/base64"
11 "encoding/hex"
12 stdjson "encoding/json"
13 "errors"
14 "io"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "strings"
19 "testing"
20 "time"
21
22 "amadan.net/carlos/platform.git/launchctx"
23 "amadan.net/oficina/home/internal/operator"
24 family "amadan.net/rastrillo/rastrillo/crypto"
25 rdb "amadan.net/rastrillo/rastrillo/db"
26 "amadan.net/rastrillo/rastrillo/migrate"
27 "golang.org/x/sys/unix"
28)
29
30func TestOperatorCommandChild(t *testing.T) {
31 if os.Getenv("HOME_OPERATOR_CHILD") != "1" {
32 return
33 }
34 os.Exit(RunOperatorCommand(context.Background(), os.Getenv("HOME_OPERATOR_DATABASE")))
35}
36
37type commandFixture struct {
38 path string
39 env []string
40 pin operator.Pins
41 signer, owner *family.Keypair
42 imageHash string
43 outputMode string
44 executable string
45 args []string
46}
47
48func newCommandFixture(t *testing.T, role string) commandFixture {
49 t.Helper()
50 makeKey := func() *family.Keypair {
51 key, err := family.Generate()
52 if err != nil {
53 t.Fatal(err)
54 }
55 return key
56 }
57 encodeKey := func(key *family.Keypair) string {
58 raw, err := family.MarshalKeypair(key)
59 if err != nil {
60 t.Fatal(err)
61 }
62 return base64.StdEncoding.EncodeToString(raw)
63 }
64 signer, owner, browser, provisioning, page := makeKey(), makeKey(), makeKey(), makeKey(), makeKey()
65 path := filepath.Join(t.TempDir(), "home.db")
66 db, err := rdb.Open(path, nil)
67 if err != nil {
68 t.Fatal(err)
69 }
70 if _, err = migrate.Apply(t.Context(), db, compatibilitySchema(role)); err != nil {
71 t.Fatal(err)
72 }
73 if _, err = db.Writer().Exec(replicationCompatibilityDDL); err != nil {
74 t.Fatal(err)
75 }
76 if err = db.Close(); err != nil {
77 t.Fatal(err)
78 }
79 credential, directory := "https://id.example.test", "https://home.example.test"
80 origin := credential
81 actions := []string{"credential.owner-seed"}
82 if role == "directory" {
83 origin = directory
84 actions = []string{"directory.inspect", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer"}
85 }
86 pins := operator.Pins{Role: role, Origin: origin, Installation: opToken(1), Registry: opToken(2), Device: opToken(3), PublicKey: base64.RawURLEncoding.EncodeToString(signer.SignPub()), Actions: actions}
87 config := map[string]any{"version": 1, "account": "bds", "app": "home", "host": "home.example.test", "role": role, "origin": origin, "installation": pins.Installation, "registry": pins.Registry, "device": pins.Device, "public_key": pins.PublicKey, "key_id": rosterKeyID(signer), "actions": actions}
88 raw, err := stdjson.Marshal(config)
89 if err != nil {
90 t.Fatal(err)
91 }
92 env := []string{"HOME_OPERATOR_CHILD=1", "HOME_OPERATOR_DATABASE=" + path, "HOME_ROLE=" + role, "HOME_CREDENTIAL_ORIGIN=" + credential, "HOME_DIRECTORY_ORIGIN=" + directory, "HOME_OPERATOR_COMMAND=" + string(raw), "HOME_DEK=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), "GODEBUG=containermaxprocs=0,updatemaxprocs=0"}
93 material := map[string]any{"version": 1, "role": role, "mac_key": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}
94 if role == "credential" {
95 material["owner_seed_key"] = encodeKey(owner)
96 env = append(env, "HOME_PEPPER="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32)), "HOME_ASSERTION_KEY="+encodeKey(browser))
97 } else {
98 material["owner_seed_public"] = base64.RawURLEncoding.EncodeToString(owner.SignPub())
99 material["provisioning_key"] = encodeKey(provisioning)
100 material["roster_keys"] = []map[string]string{{"id": rosterKeyID(page), "public": base64.RawURLEncoding.EncodeToString(page.SignPub())}}
101 keys, _ := stdjson.Marshal(map[string]string{rosterKeyID(browser): base64.StdEncoding.EncodeToString(browser.SignPub())})
102 env = append(env, "HOME_ASSERTION_KEYS="+string(keys))
103 }
104 raw, err = stdjson.Marshal(material)
105 if err != nil {
106 t.Fatal(err)
107 }
108 env = append(env, "HOME_OPERATOR_MATERIAL="+string(raw))
109 image, err := os.ReadFile("/proc/self/exe")
110 if err != nil {
111 t.Fatal(err)
112 }
113 hash := sha256.Sum256(image)
114 return commandFixture{path: path, env: env, pin: pins, signer: signer, owner: owner, imageHash: hex.EncodeToString(hash[:]), executable: "/proc/self/exe", args: []string{"-test.run=^TestOperatorCommandChild$"}}
115}
116func (f commandFixture) request(t *testing.T, action string, input any, sequence int64, previous string, op byte) []byte {
117 t.Helper()
118 now := time.Now().Unix()
119 auth := operator.Authorization{Version: 1, Purpose: "operator-input", Role: f.pin.Role, Origin: f.pin.Origin, Installation: f.pin.Installation, Registry: f.pin.Registry, Device: f.pin.Device, Sequence: sequence, Initial: sequence == 1, Previous: previous, JournalSequence: sequence, OperationID: opToken(op), Action: action, Issued: now, Expires: now + 300}
120 raw, err := operator.SignRequest(f.signer, auth, input)
121 if err != nil {
122 t.Fatal(err)
123 }
124 return raw
125}
126func (f commandFixture) run(t *testing.T, input []byte) ([]byte, int, bool) {
127 t.Helper()
128 var st unix.Stat_t
129 if err := unix.Stat(f.path, &st); err != nil {
130 t.Fatal(err)
131 }
132 digest := strings.Repeat("a", 64)
133 sum := sha256.Sum256(input)
134 launch := launchctx.LaunchContext{Version: 1, Account: "bds", App: "home", Host: "home.example.test", RequestID: opToken(30), AttemptID: opToken(31), RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: f.imageHash, EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(sum[:]), DatabasePath: f.path, DatabaseDevice: uint64(st.Dev), DatabaseInode: st.Ino, ExecutionDeadline: time.Now().Add(20 * time.Second).UTC().Format(time.RFC3339Nano)}
135 raw, err := launchctx.EncodeLaunch(launch)
136 if err != nil {
137 t.Fatal(err)
138 }
139 pin, err := os.Open(f.executable)
140 if err != nil {
141 t.Fatal(err)
142 }
143 defer pin.Close()
144 read, write, err := os.Pipe()
145 if err != nil {
146 t.Fatal(err)
147 }
148 defer read.Close()
149 defer write.Close()
150 watch, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
151 if err != nil {
152 t.Fatal(err)
153 }
154 defer unix.Close(watch)
155 if _, err = unix.InotifyAddWatch(watch, f.path, unix.IN_OPEN); err != nil {
156 t.Fatal(err)
157 }
158 ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second)
159 defer cancel()
160 cmd := exec.CommandContext(ctx, f.executable, f.args...)
161 cmd.Dir = "/"
162 cmd.Env = f.env
163 cmd.Stdin = bytes.NewReader(input)
164 cmd.ExtraFiles = []*os.File{read, pin}
165 var out, stderr bytes.Buffer
166 cmd.Stdout = &out
167 if f.outputMode == "closed-pipe" {
168 reader, writer, err := os.Pipe()
169 if err != nil {
170 t.Fatal(err)
171 }
172 reader.Close()
173 defer writer.Close()
174 cmd.Stdout = writer
175 }
176 if f.outputMode == "regular-file" {
177 file, err := os.CreateTemp(t.TempDir(), "output")
178 if err != nil {
179 t.Fatal(err)
180 }
181 defer file.Close()
182 cmd.Stdout = file
183 }
184 cmd.Stderr = &stderr
185 if err = cmd.Start(); err != nil {
186 t.Fatal(err)
187 }
188 read.Close()
189 done := make(chan error, 1)
190 go func() { _, err := io.Copy(write, bytes.NewReader(raw)); write.Close(); done <- err }()
191 err = cmd.Wait()
192 writeErr := <-done
193 code := 0
194 if err != nil {
195 var exit *exec.ExitError
196 if !errors.As(err, &exit) {
197 t.Fatal(err)
198 }
199 code = exit.ExitCode()
200 }
201 if code == 0 && writeErr != nil {
202 t.Fatal(writeErr)
203 }
204 if stderr.Len() != 0 {
205 t.Fatal("command wrote stderr")
206 }
207 buf := make([]byte, 4096)
208 n, err := unix.Read(watch, buf)
209 if err != nil && !errors.Is(err, unix.EAGAIN) {
210 t.Fatal(err)
211 }
212 return out.Bytes(), code, n > 0
213}
214func commandRowCount(t *testing.T, path, table string) int {
215 t.Helper()
216 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
217 if err != nil {
218 t.Fatal(err)
219 }
220 defer db.Close()
221 var n int
222 if err = db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil {
223 t.Fatal(err)
224 }
225 return n
226}
227func TestOperatorCommandCredentialPrivateAtomicReplay(t *testing.T) {
228 f := newCommandFixture(t, "credential")
229 address := "operator-command-private@example.invalid"
230 raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: address}, 1, "", 10)
231 out, code, opened := f.run(t, raw)
232 if code != 0 || !opened {
233 t.Fatal("credential command failed", code, opened)
234 }
235 var result operator.Result
236 if stdjson.Unmarshal(out, &result) != nil {
237 t.Fatal("invalid result")
238 }
239 owner, err := operator.NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(f.owner.SignPub()), f.pin.Origin, "https://home.example.test")
240 if err != nil {
241 t.Fatal(err)
242 }
243 receipt, err := owner.Verify(result.Receipt, time.Now())
244 if err != nil || !ValidPersonRef(receipt.PersonRef) {
245 t.Fatal("owner receipt failed", err)
246 }
247 again, code, _ := f.run(t, raw)
248 if code != 0 || !bytes.Equal(out, again) {
249 t.Fatal("replay changed result", code)
250 }
251 if commandRowCount(t, f.path, "people") != 1 || commandRowCount(t, f.path, "credential_operator_results") != 1 {
252 t.Fatal("replay duplicated data")
253 }
254 for _, suffix := range []string{"", "-wal"} {
255 disk, err := os.ReadFile(f.path + suffix)
256 if err != nil && !errors.Is(err, os.ErrNotExist) {
257 t.Fatal(err)
258 }
259 if bytes.Contains(disk, []byte(address)) || bytes.Contains(disk, result.Receipt) {
260 t.Fatal("private values stored in plaintext")
261 }
262 }
263}
264func TestOperatorCommandRefusesBeforeDatabaseOpen(t *testing.T) {
265 for _, mode := range []string{"signer", "missing-material", "unknown-material", "wrong-role"} {
266 t.Run(mode, func(t *testing.T) {
267 f := newCommandFixture(t, "credential")
268 if mode == "signer" {
269 var err error
270 f.signer, err = family.Generate()
271 if err != nil {
272 t.Fatal(err)
273 }
274 }
275 for i, value := range f.env {
276 if strings.HasPrefix(value, "HOME_OPERATOR_MATERIAL=") {
277 switch mode {
278 case "missing-material":
279 f.env[i] = "HOME_OPERATOR_MATERIAL="
280 case "unknown-material":
281 f.env[i] = strings.Replace(value, `"version":1`, `"version":1,"unknown":true`, 1)
282 }
283 }
284 if mode == "wrong-role" && strings.HasPrefix(value, "HOME_ROLE=") {
285 f.env[i] = "HOME_ROLE=directory"
286 }
287 }
288 raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "synthetic@example.invalid"}, 1, "", 10)
289 out, code, opened := f.run(t, raw)
290 if code != 2 || len(out) != 0 || opened {
291 t.Fatal("refusal boundary failed", code, opened)
292 }
293 })
294 }
295}
296
297func TestOperatorCommandDirectoryInspectAndSeed(t *testing.T) {
298 f := newCommandFixture(t, "directory")
299 now := time.Now().Unix()
300 challenge := operator.InspectChallenge{Version: 1, Purpose: "operator-inspect", Role: "directory", Origin: f.pin.Origin, Installation: f.pin.Installation, Registry: f.pin.Registry, Device: f.pin.Device, Challenge: opToken(45), Issued: now, Expires: now + 300}
301 inspect, err := operator.SignInspect(f.signer, challenge)
302 if err != nil {
303 t.Fatal(err)
304 }
305 out, code, opened := f.run(t, inspect)
306 if code != 0 || !opened {
307 t.Fatal("inspect failed", code)
308 }
309 var observation operator.Inspection
310 if stdjson.Unmarshal(out, &observation) != nil || observation.Generation != nil || observation.TeamID != 1 || observation.TeamSQID != "Uk" {
311 t.Fatal("wrong initial inspection")
312 }
313 if commandRowCount(t, f.path, "directory_operator_state") != 0 || commandRowCount(t, f.path, "directory_operator_results") != 0 {
314 t.Fatal("inspect changed operator state")
315 }
316 generation := opToken(40)
317 initialize := f.request(t, "directory.initialize", operator.InitializeInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.initialize", OperationID: opToken(10)}, Generation: generation, Baseline: "operator-attests-never-provisioned"}, 1, "", 10)
318 out, code, _ = f.run(t, initialize)
319 if code != 0 {
320 t.Fatal("initialize failed", code)
321 }
322 v, err := operator.NewVerifier(f.pin)
323 if err != nil {
324 t.Fatal(err)
325 }
326 request, err := v.Verify(initialize)
327 if err != nil {
328 t.Fatal(err)
329 }
330 receipt, err := operator.SignOwner(f.owner, operator.OwnerReceipt{Version: 1, Purpose: "owner-seed", OperationID: opToken(20), Issuer: "https://id.example.test", Directory: f.pin.Origin, TeamID: 1, TeamSQID: "Uk", PersonRef: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", Role: "owner", Issued: now, Expires: now + 300})
331 if err != nil {
332 t.Fatal(err)
333 }
334 seed := f.request(t, "directory.seed-team", operator.SeedInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.seed-team", OperationID: opToken(11)}, Generation: generation, TeamID: 1, TeamSQID: "Uk", Label: "test-command", Name: "private-command-team", Receipt: receipt}, 2, request.Head(), 11)
335 out, code, _ = f.run(t, seed)
336 if code != 0 {
337 t.Fatal("team seed failed", code)
338 }
339 again, code, _ := f.run(t, seed)
340 if code != 0 || !bytes.Equal(out, again) {
341 t.Fatal("team replay failed", code)
342 }
343 if commandRowCount(t, f.path, "teams") != 1 || commandRowCount(t, f.path, "memberships") != 1 || commandRowCount(t, f.path, "directory_operator_results") != 2 {
344 t.Fatal("wrong seed state")
345 }
346 out, code, _ = f.run(t, inspect)
347 if code != 0 || stdjson.Unmarshal(out, &observation) != nil || observation.Generation == nil || *observation.Generation != generation || observation.TeamID != 2 || observation.TeamSQID != "gb" {
348 t.Fatal("inspect after seed failed", code)
349 }
350 if commandRowCount(t, f.path, "directory_operator_results") != 2 {
351 t.Fatal("inspect persisted result")
352 }
353 for _, suffix := range []string{"", "-wal"} {
354 disk, err := os.ReadFile(f.path + suffix)
355 if err != nil && !errors.Is(err, os.ErrNotExist) {
356 t.Fatal(err)
357 }
358 if bytes.Contains(disk, []byte("private-command-team")) || bytes.Contains(disk, receipt) {
359 t.Fatal("private seed data stored in plaintext")
360 }
361 }
362}
363
364func TestOperatorCommandOutputFailureRecoversCommittedResult(t *testing.T) {
365 for _, mode := range []string{"closed-pipe", "regular-file"} {
366 t.Run(mode, func(t *testing.T) {
367 f := newCommandFixture(t, "credential")
368 f.outputMode = mode
369 raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "output-private@example.invalid"}, 1, "", 10)
370 out, code, opened := f.run(t, raw)
371 want := 3
372 if mode == "regular-file" {
373 want = 2
374 }
375 if code != want || len(out) != 0 || opened != (mode == "closed-pipe") {
376 t.Fatal("output refusal failed", code, opened)
377 }
378 count := commandRowCount(t, f.path, "credential_operator_results")
379 if (mode == "closed-pipe" && count != 1) || (mode == "regular-file" && count != 0) {
380 t.Fatal("wrong commit state", count)
381 }
382 f.outputMode = ""
383 out, code, _ = f.run(t, raw)
384 if code != 0 || len(out) == 0 {
385 t.Fatal("recovery failed", code)
386 }
387 again, code, _ := f.run(t, raw)
388 if code != 0 || !bytes.Equal(out, again) || commandRowCount(t, f.path, "people") != 1 {
389 t.Fatal("recovery changed identity or result", code)
390 }
391 })
392 }
393}
394
395func TestOperatorCommandSchemaRefusalMakesNoIdentity(t *testing.T) {
396 for _, statement := range []string{"DELETE FROM rastrillo_migrations WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", "CREATE TRIGGER unexpected AFTER INSERT ON people BEGIN SELECT 1; END", "CREATE TABLE unexpected(value TEXT)"} {
397 t.Run(statement, func(t *testing.T) {
398 f := newCommandFixture(t, "credential")
399 db, err := sql.Open("sqlite", f.path)
400 if err != nil {
401 t.Fatal(err)
402 }
403 if _, err = db.Exec(statement); err != nil {
404 t.Fatal(err)
405 }
406 if err = db.Close(); err != nil {
407 t.Fatal(err)
408 }
409 raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "schema-private@example.invalid"}, 1, "", 10)
410 out, code, opened := f.run(t, raw)
411 if code != 2 || !opened || len(out) != 0 {
412 t.Fatal("schema refusal failed", code, opened)
413 }
414 if commandRowCount(t, f.path, "people") != 0 || commandRowCount(t, f.path, "credential_operator_results") != 0 {
415 t.Fatal("schema refusal wrote identity")
416 }
417 })
418 }
419}
420
421func TestOperatorCommandActualBinaryDispatch(t *testing.T) {
422 binary := filepath.Join(t.TempDir(), "home")
423 build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "../../cmd/home")
424 if out, err := build.CombinedOutput(); err != nil {
425 t.Fatalf("build failed: %v %s", err, out)
426 }
427 for _, args := range [][]string{{"operator-v1"}, {"operator-v1", "--db"}, {"operator-v1", "--db", "relative.db"}, {"operator-v1", "--db", "/tmp/example", "--extra"}} {
428 cmd := exec.CommandContext(t.Context(), binary, args...)
429 cmd.Env = []string{}
430 out, err := cmd.CombinedOutput()
431 var exited *exec.ExitError
432 if !errors.As(err, &exited) || exited.ExitCode() != 2 || len(out) != 0 {
433 t.Fatal("bad command argv was not silently refused", args, err)
434 }
435 }
436 f := newCommandFixture(t, "credential")
437 f.executable = binary
438 f.args = []string{"operator-v1", "--db", f.path}
439 image, err := os.ReadFile(binary)
440 if err != nil {
441 t.Fatal(err)
442 }
443 sum := sha256.Sum256(image)
444 f.imageHash = hex.EncodeToString(sum[:])
445 raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "actual-binary-private@example.invalid"}, 1, "", 10)
446 out, code, opened := f.run(t, raw)
447 if code != 0 || !opened || len(out) == 0 || commandRowCount(t, f.path, "people") != 1 {
448 t.Fatal("real binary dispatch failed", code, opened)
449 }
450}
451
452func TestOperatorCommandKeyAndScopeCollisionsRefuseBeforeOpen(t *testing.T) {
453 for _, mode := range []string{"mac-dek", "zero-mac", "owner-browser", "owner-authorizer", "pinned-origin", "pinned-role"} {
454 t.Run(mode, func(t *testing.T) {
455 f := newCommandFixture(t, "credential")
456 vars := map[string]string{}
457 for _, value := range f.env {
458 name, value, _ := strings.Cut(value, "=")
459 vars[name] = value
460 }
461 var material, config map[string]any
462 if stdjson.Unmarshal([]byte(vars["HOME_OPERATOR_MATERIAL"]), &material) != nil || stdjson.Unmarshal([]byte(vars["HOME_OPERATOR_COMMAND"]), &config) != nil {
463 t.Fatal("fixture JSON")
464 }
465 switch mode {
466 case "mac-dek":
467 material["mac_key"] = vars["HOME_DEK"]
468 case "zero-mac":
469 material["mac_key"] = base64.StdEncoding.EncodeToString(make([]byte, 32))
470 case "owner-browser":
471 material["owner_seed_key"] = vars["HOME_ASSERTION_KEY"]
472 case "owner-authorizer":
473 config["public_key"] = base64.RawURLEncoding.EncodeToString(f.owner.SignPub())
474 config["key_id"] = rosterKeyID(f.owner)
475 f.signer = f.owner
476 case "pinned-origin":
477 config["origin"] = "https://other.example.test"
478 case "pinned-role":
479 config["role"] = "directory"
480 config["actions"] = []string{"directory.initialize"}
481 }
482 raw, _ := stdjson.Marshal(material)
483 vars["HOME_OPERATOR_MATERIAL"] = string(raw)
484 raw, _ = stdjson.Marshal(config)
485 vars["HOME_OPERATOR_COMMAND"] = string(raw)
486 f.env = nil
487 for name, value := range vars {
488 f.env = append(f.env, name+"="+value)
489 }
490 input := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "collision-private@example.invalid"}, 1, "", 10)
491 out, code, opened := f.run(t, input)
492 if code != 2 || opened || len(out) != 0 {
493 t.Fatal("key/scope collision accepted", code, opened)
494 }
495 })
496 }
497}
498