| 1 | //go:build linux |
| 2 | |
| 3 | package operatorlaunch |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "encoding/hex" |
| 11 | "encoding/json" |
| 12 | "io" |
| 13 | "os" |
| 14 | "os/exec" |
| 15 | "path/filepath" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "amadan.net/carlos/platform.git/launchctx" |
| 21 | "golang.org/x/sys/unix" |
| 22 | ) |
| 23 | |
| 24 | func TestStartupChild(t *testing.T) { |
| 25 | if os.Getenv("HOME_STARTUP_FIXTURE") != "1" { |
| 26 | return |
| 27 | } |
| 28 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 29 | defer cancel() |
| 30 | c, raw, err := ReadOperator(ctx, os.Getenv("HOME_STARTUP_DATABASE")) |
| 31 | closed := true |
| 32 | for _, fd := range []uintptr{0, 3, 4} { |
| 33 | var st unix.Stat_t |
| 34 | flags, e := unix.FcntlInt(fd, unix.F_GETFL, 0) |
| 35 | if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || unix.Fstat(int(fd), &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFCHR || unix.Major(st.Rdev) != 1 || unix.Minor(st.Rdev) != 3 { |
| 36 | closed = false |
| 37 | } |
| 38 | } |
| 39 | if err != nil && (err != ErrRefused || len(raw) != 0 || c.Version != 0) { |
| 40 | os.Exit(3) |
| 41 | } |
| 42 | if err == nil { |
| 43 | file, e := os.Open(os.Getenv("HOME_STARTUP_DATABASE")) |
| 44 | if e != nil || file.Fd() <= 4 { |
| 45 | os.Exit(4) |
| 46 | } |
| 47 | if os.Stdin.Close() != nil { |
| 48 | os.Exit(5) |
| 49 | } |
| 50 | if _, e := file.Stat(); e != nil || file.Close() != nil { |
| 51 | os.Exit(6) |
| 52 | } |
| 53 | } |
| 54 | json.NewEncoder(os.Stdout).Encode(map[string]any{"accepted": err == nil, "closed": closed, "input_bytes": len(raw)}) |
| 55 | clear(raw) |
| 56 | os.Exit(0) |
| 57 | } |
| 58 | |
| 59 | func TestStartupTransportInSubprocess(t *testing.T) { |
| 60 | image, e := os.ReadFile("/proc/self/exe") |
| 61 | if e != nil { |
| 62 | t.Fatal(e) |
| 63 | } |
| 64 | imageSum := sha256.Sum256(image) |
| 65 | for _, mode := range []string{"valid", "runtime-default", "context", "context-file", "context-overflow", "input", "input-file", "input-overflow", "selector", "artifact-hash", "artifact-identity", "artifact-flags", "artifact-path", "expired", "checker", "stalled-context", "stalled-input"} { |
| 66 | t.Run(mode, func(t *testing.T) { |
| 67 | dir := t.TempDir() |
| 68 | path := filepath.Join(dir, "image") |
| 69 | if e := os.WriteFile(path, image, 0700); e != nil { |
| 70 | t.Fatal(e) |
| 71 | } |
| 72 | database := filepath.Join(dir, "home.db") |
| 73 | marker := []byte("untouched database sentinel") |
| 74 | if e := os.WriteFile(database, marker, 0600); e != nil { |
| 75 | t.Fatal(e) |
| 76 | } |
| 77 | input := []byte(`{"private":"` + strings.Repeat("x", 60000) + `"}`) |
| 78 | inputSum := sha256.Sum256(input) |
| 79 | id := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| 80 | digest := strings.Repeat("a", 64) |
| 81 | c := launchctx.LaunchContext{Version: 1, Account: "bds", App: "home", Host: "home.example", RequestID: id, AttemptID: id, RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: hex.EncodeToString(imageSum[:]), EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(inputSum[:]), DatabasePath: database, DatabaseDevice: 1, DatabaseInode: 1, ExecutionDeadline: time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339Nano)} |
| 82 | selector := database |
| 83 | artifactPath, artifactFlags := path, os.O_RDONLY |
| 84 | switch mode { |
| 85 | case "input": |
| 86 | input[20] = 'y' |
| 87 | case "input-overflow": |
| 88 | input = bytes.Repeat([]byte("x"), inputLimit+1) |
| 89 | case "selector": |
| 90 | selector += ".other" |
| 91 | case "artifact-hash": |
| 92 | c.ArtifactDigest = digest |
| 93 | case "artifact-path": |
| 94 | artifactFlags = unix.O_PATH |
| 95 | case "artifact-identity", "artifact-flags": |
| 96 | artifactPath = filepath.Join(dir, "other") |
| 97 | if e := os.WriteFile(artifactPath, image, 0600); e != nil { |
| 98 | t.Fatal(e) |
| 99 | } |
| 100 | if mode == "artifact-flags" { |
| 101 | artifactFlags = os.O_RDWR |
| 102 | } |
| 103 | case "expired": |
| 104 | c.ExecutionDeadline = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) |
| 105 | case "checker": |
| 106 | c.Version = 2 |
| 107 | c.ExecutionKind = "compatibility-check" |
| 108 | c.OperationID = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) |
| 109 | c.ExpectedStateSHA256 = digest |
| 110 | c.ClosedCheckpoint = &launchctx.ClosedCheckpoint{Version: 1, PreparationID: id, CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), MainLength: 1, MainSHA256: digest} |
| 111 | c.ClosedCheckpoint.EvidenceSHA256, e = c.CheckpointDigest() |
| 112 | if e != nil { |
| 113 | t.Fatal(e) |
| 114 | } |
| 115 | } |
| 116 | raw, e := launchctx.EncodeLaunch(c) |
| 117 | if e != nil { |
| 118 | t.Fatal(e) |
| 119 | } |
| 120 | switch mode { |
| 121 | case "context": |
| 122 | raw = []byte(`{"version":1}`) |
| 123 | case "context-overflow": |
| 124 | raw = bytes.Repeat([]byte(" "), launchctx.MaxLaunchBytes+1) |
| 125 | } |
| 126 | pin, e := os.OpenFile(artifactPath, artifactFlags, 0) |
| 127 | if e != nil { |
| 128 | t.Fatal(e) |
| 129 | } |
| 130 | defer pin.Close() |
| 131 | inR, inW, e := os.Pipe() |
| 132 | if e != nil { |
| 133 | t.Fatal(e) |
| 134 | } |
| 135 | defer inR.Close() |
| 136 | defer inW.Close() |
| 137 | contextR, contextW, e := os.Pipe() |
| 138 | if e != nil { |
| 139 | t.Fatal(e) |
| 140 | } |
| 141 | defer contextR.Close() |
| 142 | defer contextW.Close() |
| 143 | ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second) |
| 144 | defer cancel() |
| 145 | cmd := exec.CommandContext(ctx, path, "-test.run=^TestStartupChild$") |
| 146 | cmd.Dir = "/" |
| 147 | // Keep the fixture's descriptor inventory independent of runtime CPU detection. |
| 148 | cmd.Env = []string{"HOME_STARTUP_FIXTURE=1", "HOME_STARTUP_DATABASE=" + selector, "GODEBUG=containermaxprocs=0,updatemaxprocs=0"} |
| 149 | if mode == "runtime-default" { |
| 150 | cmd.Env = cmd.Env[:2] |
| 151 | } |
| 152 | cmd.Stdin = inR |
| 153 | cmd.ExtraFiles = []*os.File{contextR, pin} |
| 154 | if mode == "input-file" || mode == "context-file" { |
| 155 | content := input |
| 156 | if mode == "context-file" { |
| 157 | content = raw |
| 158 | } |
| 159 | filePath := filepath.Join(dir, "invalid-transport") |
| 160 | if e := os.WriteFile(filePath, content, 0600); e != nil { |
| 161 | t.Fatal(e) |
| 162 | } |
| 163 | file, e := os.Open(filePath) |
| 164 | if e != nil { |
| 165 | t.Fatal(e) |
| 166 | } |
| 167 | defer file.Close() |
| 168 | if mode == "input-file" { |
| 169 | cmd.Stdin = file |
| 170 | } else { |
| 171 | cmd.ExtraFiles[0] = file |
| 172 | } |
| 173 | } |
| 174 | var output bytes.Buffer |
| 175 | cmd.Stdout = &output |
| 176 | if e := cmd.Start(); e != nil { |
| 177 | t.Fatal(e) |
| 178 | } |
| 179 | inR.Close() |
| 180 | contextR.Close() |
| 181 | writes := make(chan struct{}, 2) |
| 182 | for _, item := range []struct { |
| 183 | fd *os.File |
| 184 | raw []byte |
| 185 | stall bool |
| 186 | }{{inW, input, mode == "stalled-input"}, {contextW, raw, mode == "stalled-context"}} { |
| 187 | go func() { |
| 188 | if !item.stall { |
| 189 | io.Copy(item.fd, bytes.NewReader(item.raw)) |
| 190 | item.fd.Close() |
| 191 | } |
| 192 | writes <- struct{}{} |
| 193 | }() |
| 194 | } |
| 195 | if e := cmd.Wait(); e != nil { |
| 196 | t.Fatal("startup child failed", e) |
| 197 | } |
| 198 | <-writes |
| 199 | <-writes |
| 200 | var result struct { |
| 201 | Accepted bool `json:"accepted"` |
| 202 | Closed bool `json:"closed"` |
| 203 | InputBytes int `json:"input_bytes"` |
| 204 | } |
| 205 | wantAccepted := mode == "valid" || mode == "runtime-default" |
| 206 | if json.Unmarshal(output.Bytes(), &result) != nil || !result.Closed || result.Accepted != wantAccepted { |
| 207 | t.Fatal("wrong startup result", output.String()) |
| 208 | } |
| 209 | if wantAccepted && result.InputBytes != len(input) { |
| 210 | t.Fatal("private input changed") |
| 211 | } |
| 212 | unchanged, e := os.ReadFile(database) |
| 213 | if e != nil || !bytes.Equal(unchanged, marker) { |
| 214 | t.Fatal("startup touched database") |
| 215 | } |
| 216 | }) |
| 217 | } |
| 218 | } |
| 219 | |