//go:build linux package operator import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" "amadan.net/carlos/platform.git/launchctx" family "amadan.net/rastrillo/rastrillo/crypto" "golang.org/x/sys/unix" ) func commandConfigFixture(v *Verifier) commandConfig { p := v.pins return commandConfig{1, "bds", "home", "home.example", p.Role, p.Origin, p.Installation, p.Registry, p.Device, p.PublicKey, v.kid, p.Actions} } func TestCommandConfigRequiresExactPins(t *testing.T) { _, v, _ := fixture(t) c := commandConfigFixture(v) raw, err := json.Marshal(c) if err != nil { t.Fatal(err) } if _, _, err := parseCommandConfig(string(raw)); err != nil { t.Fatal(err) } var fields map[string]json.RawMessage if json.Unmarshal(raw, &fields) != nil { t.Fatal("fixture") } for key, value := range fields { delete(fields, key) missing, _ := json.Marshal(fields) if _, _, err := parseCommandConfig(string(missing)); err == nil { t.Fatal("missing pin accepted", key) } fields[key] = value } for _, bad := range []string{"", string(raw) + string(raw), strings.Repeat("x", 8193), string(raw) + strings.Repeat(" ", 8193-len(raw)), strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), strings.Replace(string(raw), `"version":1`, `"version":1,"extra":"x"`, 1), strings.Replace(string(raw), v.kid, strings.Repeat("a", 64), 1)} { if _, _, err := parseCommandConfig(bad); err == nil { t.Fatal("bad configuration accepted") } } } func TestCommandInvocationChild(t *testing.T) { if os.Getenv("HOME_COMMAND_FIXTURE") == "" { return } ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() i, err := openCommandInvocation(ctx, os.Getenv("HOME_COMMAND_DATABASE")) accepted := err == nil if accepted { var count int if i.request.Authorization().OperationID != token(4) || i.launch.RequestID != token(5) || i.launch.AttemptID != token(6) || i.database.db.QueryRowContext(i.ctx, "SELECT count(*) FROM changes").Scan(&count) != nil || count != 0 { os.Exit(3) } var wantClose error switch os.Getenv("HOME_COMMAND_FIXTURE") { case "swap-before-close": path := os.Getenv("HOME_COMMAND_DATABASE") if os.Rename(path, path+".saved") != nil || os.Rename(path+".other", path) != nil { os.Exit(7) } wantClose = ErrUnavailable case "deadline-before-close": <-ctx.Done() wantClose = ErrUnavailable } if err := i.close(); err != wantClose { os.Exit(4) } if i.close() != ErrRefused { os.Exit(5) } } else if err != ErrRefused { os.Exit(6) } json.NewEncoder(os.Stdout).Encode(map[string]bool{"accepted": accepted}) os.Exit(0) } func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { image, err := os.ReadFile("/proc/self/exe") if err != nil { t.Fatal(err) } imageHash := sha256.Sum256(image) for _, mode := range []string{"valid", "runtime-default", "swap-before-close", "deadline-before-close", "expired-authentic", "signer", "origin", "registry", "role", "action", "config-key", "transport-account", "transport-app", "transport-host", "inode"} { t.Run(mode, func(t *testing.T) { k, v, a := fixture(t) cfg := commandConfigFixture(v) if mode == "expired-authentic" { a.Issued, a.Expires = 100, 200 } switch mode { case "signer": otherKey, generateErr := family.Generate() if generateErr != nil { t.Fatal(generateErr) } k = otherKey case "origin": a.Origin = "https://other.example" case "registry": a.Registry = token(9) case "role": a.Role, a.Action = "credential", "credential.owner-seed" case "action": a.Action = "directory.seed-team" case "config-key": cfg.KeyID = strings.Repeat("a", 64) } input := signed(t, k, a) inputHash := sha256.Sum256(input) dir := t.TempDir() database := filepath.Join(dir, "home.db") seedOpenDB(t, database) if mode == "swap-before-close" { seedOpenDB(t, database+".other") } var st unix.Stat_t if unix.Stat(database, &st) != nil { t.Fatal("database stat") } path := filepath.Join(dir, "image") if err := os.WriteFile(path, image, 0700); err != nil { t.Fatal(err) } pin, err := os.Open(path) if err != nil { t.Fatal(err) } defer pin.Close() digest := strings.Repeat("a", 64) launch := launchctx.LaunchContext{Version: 1, Account: cfg.Account, App: cfg.App, Host: cfg.Host, RequestID: token(5), AttemptID: token(6), RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: hex.EncodeToString(imageHash[:]), EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(inputHash[:]), DatabasePath: database, DatabaseDevice: uint64(st.Dev), DatabaseInode: st.Ino, ExecutionDeadline: time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339Nano)} switch mode { case "transport-account": launch.Account = "abc" case "transport-app": launch.App = "another" case "transport-host": launch.Host = "another.example" case "inode": launch.DatabaseInode++ } raw, err := launchctx.EncodeLaunch(launch) if err != nil { t.Fatal(err) } config, err := json.Marshal(cfg) if err != nil { t.Fatal(err) } inR, inW, err := os.Pipe() if err != nil { t.Fatal(err) } defer inR.Close() defer inW.Close() contextR, contextW, err := os.Pipe() if err != nil { t.Fatal(err) } defer contextR.Close() defer contextW.Close() watch, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) if err != nil { t.Fatal(err) } defer unix.Close(watch) if _, err := unix.InotifyAddWatch(watch, database, unix.IN_OPEN); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second) defer cancel() cmd := exec.CommandContext(ctx, path, "-test.run=^TestCommandInvocationChild$") cmd.Dir = "/" cmd.Env = []string{"HOME_COMMAND_FIXTURE=" + mode, "HOME_COMMAND_DATABASE=" + database, commandConfigEnvironment + "=" + string(config)} if mode != "runtime-default" { cmd.Env = append(cmd.Env, "GODEBUG=containermaxprocs=0,updatemaxprocs=0") } cmd.Stdin = inR cmd.ExtraFiles = []*os.File{contextR, pin} var output bytes.Buffer cmd.Stdout = &output if err := cmd.Start(); err != nil { t.Fatal(err) } inR.Close() contextR.Close() writes := make(chan struct{}, 2) for _, item := range []struct { fd *os.File raw []byte }{{inW, input}, {contextW, raw}} { go func() { io.Copy(item.fd, bytes.NewReader(item.raw)) item.fd.Close() writes <- struct{}{} }() } if err := cmd.Wait(); err != nil { t.Fatal("child refused fixture", err) } <-writes <-writes var result struct{ Accepted bool } want := mode == "valid" || mode == "runtime-default" || mode == "swap-before-close" || mode == "deadline-before-close" || mode == "expired-authentic" if json.Unmarshal(output.Bytes(), &result) != nil || result.Accepted != want { t.Fatal("wrong invocation result", output.String()) } buf := make([]byte, 4096) n, err := unix.Read(watch, buf) opened := n > 0 if err != nil && !errors.Is(err, unix.EAGAIN) { t.Fatal(err) } if mode == "swap-before-close" || mode == "deadline-before-close" { original := database if mode == "swap-before-close" { original += ".saved" } db, err := sql.Open("sqlite", "file:"+original+"?mode=ro") if err != nil { t.Fatal(err) } var count int queryErr := db.QueryRow("SELECT count(*) FROM changes").Scan(&count) closeErr := db.Close() if queryErr != nil || closeErr != nil || count != 0 { t.Fatal("original database changed", queryErr, closeErr, count) } } if opened != (want || mode == "inode") { t.Fatal("database opened before signed scope validation", mode, opened) } }) } }