test(examples): fix stderr race, wait for focus, check the secret never leaks
- commit
- 53d222496a0ce1d7b3089f15035145ab412baa0d
- author
- brice · · 2026-10-01 19:23 UTC
- parent
- 63e3cbe
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +50 -9
M examples/headless_test.go +50 -9
| @@ -10,7 +10,9 @@ import ( | |||
| 10 | 10 | "os" | |
| 11 | 11 | "os/exec" | |
| 12 | 12 | "path/filepath" | |
| 13 | + | "slices" | |
| 13 | 14 | "strings" | |
| 15 | + | "sync" | |
| 14 | 16 | "syscall" | |
| 15 | 17 | "testing" | |
| 16 | 18 | "time" | |
| @@ -20,8 +22,9 @@ import ( | |||
| 20 | 22 | // headless in an isolated runtime directory, and are skipped unless | |
| 21 | 23 | // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to | |
| 22 | 24 | // that nested compositor: the child environment is stripped of the caller's | |
| 23 | - | // session variables, and the socket is checked to live in the temporary | |
| 24 | - | // directory. Never point these tests at a real session. | |
| 25 | + | // session variables, and the socket is checked to be absolute (an absolute | |
| 26 | + | // WAYLAND_DISPLAY is how the example finds it). Never point these tests at a | |
| 27 | + | // real session. | |
| 25 | 28 | ||
| 26 | 29 | var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | |
| 27 | 30 | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | |
| @@ -48,6 +51,25 @@ func must(t *testing.T, err error, what ...any) { | |||
| 48 | 51 | } | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 54 | + | // syncBuffer is a bytes buffer safe for the copying goroutine of exec and the | |
| 55 | + | // test goroutine. | |
| 56 | + | type syncBuffer struct { | |
| 57 | + | mu sync.Mutex | |
| 58 | + | b strings.Builder | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (s *syncBuffer) Write(p []byte) (int, error) { | |
| 62 | + | s.mu.Lock() | |
| 63 | + | defer s.mu.Unlock() | |
| 64 | + | return s.b.Write(p) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (s *syncBuffer) String() string { | |
| 68 | + | s.mu.Lock() | |
| 69 | + | defer s.mu.Unlock() | |
| 70 | + | return s.b.String() | |
| 71 | + | } | |
| 72 | + | ||
| 51 | 73 | type compositor struct { | |
| 52 | 74 | socket, shots string | |
| 53 | 75 | env []string | |
| @@ -94,8 +116,8 @@ func startHeadless(t *testing.T) *compositor { | |||
| 94 | 116 | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | |
| 95 | 117 | for _, p := range paths { | |
| 96 | 118 | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | |
| 97 | - | if !strings.HasPrefix(p, root) { | |
| 98 | - | t.Fatalf("socket %s is outside the test directory", p) | |
| 119 | + | if !filepath.IsAbs(p) { | |
| 120 | + | t.Fatalf("socket %q is not absolute", p) | |
| 99 | 121 | } | |
| 100 | 122 | return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin} | |
| 101 | 123 | } | |
| @@ -125,7 +147,8 @@ func build(t *testing.T, pkg string) string { | |||
| 125 | 147 | // command prepares an example to run against c, and only against c. | |
| 126 | 148 | func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd { | |
| 127 | 149 | cmd := exec.CommandContext(ctx, exe, args...) | |
| 128 | - | cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket) | |
| 150 | + | // slices.Concat copies: c.env is never aliased or modified. | |
| 151 | + | cmd.Env = slices.Concat(c.env, []string{"WAYLAND_DISPLAY=" + c.socket}) | |
| 129 | 152 | return cmd | |
| 130 | 153 | } | |
| 131 | 154 | ||
| @@ -153,8 +176,8 @@ func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool { | |||
| 153 | 176 | } | |
| 154 | 177 | ||
| 155 | 178 | func TestHeadlessLayer(t *testing.T) { | |
| 156 | - | c := startHeadless(t) | |
| 157 | 179 | exe := build(t, "./layer") | |
| 180 | + | c := startHeadless(t) | |
| 158 | 181 | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | |
| 159 | 182 | defer cancel() | |
| 160 | 183 | cmd := c.command(ctx, exe, "-frames", "20") | |
| @@ -182,20 +205,31 @@ func TestHeadlessLayer(t *testing.T) { | |||
| 182 | 205 | } | |
| 183 | 206 | ||
| 184 | 207 | func TestHeadlessLock(t *testing.T) { | |
| 185 | - | c := startHeadless(t) | |
| 186 | 208 | exe := build(t, "./lock") | |
| 209 | + | c := startHeadless(t) | |
| 187 | 210 | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | |
| 188 | 211 | defer cancel() | |
| 189 | 212 | cmd := c.command(ctx, exe) | |
| 190 | 213 | stdout, err := cmd.StdoutPipe() | |
| 191 | 214 | must(t, err) | |
| 192 | - | var stderr strings.Builder | |
| 215 | + | var stderr syncBuffer | |
| 193 | 216 | cmd.Stderr = &stderr | |
| 194 | 217 | must(t, cmd.Start()) | |
| 218 | + | // Wait runs once: here, after the program is done, or from the cleanup if | |
| 219 | + | // the test fails first (the context kill ends the process). | |
| 220 | + | var waitOnce sync.Once | |
| 221 | + | var waitErr error | |
| 222 | + | wait := func() error { | |
| 223 | + | waitOnce.Do(func() { waitErr = cmd.Wait() }) | |
| 224 | + | return waitErr | |
| 225 | + | } | |
| 226 | + | t.Cleanup(func() { cancel(); _ = wait() }) | |
| 227 | + | var all syncBuffer // everything the program printed on stdout | |
| 195 | 228 | lines := make(chan string, 8) | |
| 196 | 229 | go func() { | |
| 197 | 230 | sc := bufio.NewScanner(stdout) | |
| 198 | 231 | for sc.Scan() { | |
| 232 | + | _, _ = all.Write(append(sc.Bytes(), '\n')) | |
| 199 | 233 | lines <- sc.Text() | |
| 200 | 234 | } | |
| 201 | 235 | close(lines) | |
| @@ -212,10 +246,17 @@ func TestHeadlessLock(t *testing.T) { | |||
| 212 | 246 | } | |
| 213 | 247 | } | |
| 214 | 248 | expect("locked") | |
| 249 | + | expect("focused") // the keyboard reached a lock surface: typing is safe | |
| 215 | 250 | // Only the nested compositor receives this input; it types into the | |
| 216 | 251 | // password field (shown masked) and presses Enter. | |
| 217 | 252 | _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n") | |
| 218 | 253 | must(t, err) | |
| 219 | 254 | expect("unlocked") | |
| 220 | - | must(t, cmd.Wait(), stderr.String()) | |
| 255 | + | for range lines { // drain until the program closes stdout | |
| 256 | + | } | |
| 257 | + | must(t, wait(), stderr.String()) | |
| 258 | + | // The typed text must not leak to the program's output. | |
| 259 | + | if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") { | |
| 260 | + | t.Fatal("the typed text appears in the program output") | |
| 261 | + | } | |
| 221 | 262 | } | |
| @@ -10,7 +10,9 @@ import ( | |||||
| 10 | "os" | 10 | "os" | ||
| 11 | "os/exec" | 11 | "os/exec" | ||
| 12 | "path/filepath" | 12 | "path/filepath" | ||
| 13 | + | "slices" | |||
| 13 | "strings" | 14 | "strings" | ||
| 15 | + | "sync" | |||
| 14 | "syscall" | 16 | "syscall" | ||
| 15 | "testing" | 17 | "testing" | ||
| 16 | "time" | 18 | "time" | ||
| @@ -20,8 +22,9 @@ import ( | |||||
| 20 | // headless in an isolated runtime directory, and are skipped unless | 22 | // headless in an isolated runtime directory, and are skipped unless | ||
| 21 | // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to | 23 | // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to | ||
| 22 | // that nested compositor: the child environment is stripped of the caller's | 24 | // that nested compositor: the child environment is stripped of the caller's | ||
| 23 | - | // session variables, and the socket is checked to live in the temporary | 25 | + | // session variables, and the socket is checked to be absolute (an absolute |
| 24 | - | // directory. Never point these tests at a real session. | 26 | + | // WAYLAND_DISPLAY is how the example finds it). Never point these tests at a |
| 27 | + | // real session. | |||
| 25 | 28 | ||||
| 26 | var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | 29 | var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | ||
| 27 | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | 30 | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | ||
| @@ -48,6 +51,25 @@ func must(t *testing.T, err error, what ...any) { | |||||
| 48 | } | 51 | } | ||
| 49 | } | 52 | } | ||
| 50 | 53 | ||||
| 54 | + | // syncBuffer is a bytes buffer safe for the copying goroutine of exec and the | |||
| 55 | + | // test goroutine. | |||
| 56 | + | type syncBuffer struct { | |||
| 57 | + | mu sync.Mutex | |||
| 58 | + | b strings.Builder | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (s *syncBuffer) Write(p []byte) (int, error) { | |||
| 62 | + | s.mu.Lock() | |||
| 63 | + | defer s.mu.Unlock() | |||
| 64 | + | return s.b.Write(p) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (s *syncBuffer) String() string { | |||
| 68 | + | s.mu.Lock() | |||
| 69 | + | defer s.mu.Unlock() | |||
| 70 | + | return s.b.String() | |||
| 71 | + | } | |||
| 72 | + | ||||
| 51 | type compositor struct { | 73 | type compositor struct { | ||
| 52 | socket, shots string | 74 | socket, shots string | ||
| 53 | env []string | 75 | env []string | ||
| @@ -94,8 +116,8 @@ func startHeadless(t *testing.T) *compositor { | |||||
| 94 | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | 116 | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | ||
| 95 | for _, p := range paths { | 117 | for _, p := range paths { | ||
| 96 | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | 118 | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | ||
| 97 | - | if !strings.HasPrefix(p, root) { | 119 | + | if !filepath.IsAbs(p) { |
| 98 | - | t.Fatalf("socket %s is outside the test directory", p) | 120 | + | t.Fatalf("socket %q is not absolute", p) |
| 99 | } | 121 | } | ||
| 100 | return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin} | 122 | return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin} | ||
| 101 | } | 123 | } | ||
| @@ -125,7 +147,8 @@ func build(t *testing.T, pkg string) string { | |||||
| 125 | // command prepares an example to run against c, and only against c. | 147 | // command prepares an example to run against c, and only against c. | ||
| 126 | func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd { | 148 | func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd { | ||
| 127 | cmd := exec.CommandContext(ctx, exe, args...) | 149 | cmd := exec.CommandContext(ctx, exe, args...) | ||
| 128 | - | cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket) | 150 | + | // slices.Concat copies: c.env is never aliased or modified. |
| 151 | + | cmd.Env = slices.Concat(c.env, []string{"WAYLAND_DISPLAY=" + c.socket}) | |||
| 129 | return cmd | 152 | return cmd | ||
| 130 | } | 153 | } | ||
| 131 | 154 | ||||
| @@ -153,8 +176,8 @@ func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool { | |||||
| 153 | } | 176 | } | ||
| 154 | 177 | ||||
| 155 | func TestHeadlessLayer(t *testing.T) { | 178 | func TestHeadlessLayer(t *testing.T) { | ||
| 156 | - | c := startHeadless(t) | |||
| 157 | exe := build(t, "./layer") | 179 | exe := build(t, "./layer") | ||
| 180 | + | c := startHeadless(t) | |||
| 158 | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | 181 | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | ||
| 159 | defer cancel() | 182 | defer cancel() | ||
| 160 | cmd := c.command(ctx, exe, "-frames", "20") | 183 | cmd := c.command(ctx, exe, "-frames", "20") | ||
| @@ -182,20 +205,31 @@ func TestHeadlessLayer(t *testing.T) { | |||||
| 182 | } | 205 | } | ||
| 183 | 206 | ||||
| 184 | func TestHeadlessLock(t *testing.T) { | 207 | func TestHeadlessLock(t *testing.T) { | ||
| 185 | - | c := startHeadless(t) | |||
| 186 | exe := build(t, "./lock") | 208 | exe := build(t, "./lock") | ||
| 209 | + | c := startHeadless(t) | |||
| 187 | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | 210 | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | ||
| 188 | defer cancel() | 211 | defer cancel() | ||
| 189 | cmd := c.command(ctx, exe) | 212 | cmd := c.command(ctx, exe) | ||
| 190 | stdout, err := cmd.StdoutPipe() | 213 | stdout, err := cmd.StdoutPipe() | ||
| 191 | must(t, err) | 214 | must(t, err) | ||
| 192 | - | var stderr strings.Builder | 215 | + | var stderr syncBuffer |
| 193 | cmd.Stderr = &stderr | 216 | cmd.Stderr = &stderr | ||
| 194 | must(t, cmd.Start()) | 217 | must(t, cmd.Start()) | ||
| 218 | + | // Wait runs once: here, after the program is done, or from the cleanup if | |||
| 219 | + | // the test fails first (the context kill ends the process). | |||
| 220 | + | var waitOnce sync.Once | |||
| 221 | + | var waitErr error | |||
| 222 | + | wait := func() error { | |||
| 223 | + | waitOnce.Do(func() { waitErr = cmd.Wait() }) | |||
| 224 | + | return waitErr | |||
| 225 | + | } | |||
| 226 | + | t.Cleanup(func() { cancel(); _ = wait() }) | |||
| 227 | + | var all syncBuffer // everything the program printed on stdout | |||
| 195 | lines := make(chan string, 8) | 228 | lines := make(chan string, 8) | ||
| 196 | go func() { | 229 | go func() { | ||
| 197 | sc := bufio.NewScanner(stdout) | 230 | sc := bufio.NewScanner(stdout) | ||
| 198 | for sc.Scan() { | 231 | for sc.Scan() { | ||
| 232 | + | _, _ = all.Write(append(sc.Bytes(), '\n')) | |||
| 199 | lines <- sc.Text() | 233 | lines <- sc.Text() | ||
| 200 | } | 234 | } | ||
| 201 | close(lines) | 235 | close(lines) | ||
| @@ -212,10 +246,17 @@ func TestHeadlessLock(t *testing.T) { | |||||
| 212 | } | 246 | } | ||
| 213 | } | 247 | } | ||
| 214 | expect("locked") | 248 | expect("locked") | ||
| 249 | + | expect("focused") // the keyboard reached a lock surface: typing is safe | |||
| 215 | // Only the nested compositor receives this input; it types into the | 250 | // Only the nested compositor receives this input; it types into the | ||
| 216 | // password field (shown masked) and presses Enter. | 251 | // password field (shown masked) and presses Enter. | ||
| 217 | _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n") | 252 | _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n") | ||
| 218 | must(t, err) | 253 | must(t, err) | ||
| 219 | expect("unlocked") | 254 | expect("unlocked") | ||
| 220 | - | must(t, cmd.Wait(), stderr.String()) | 255 | + | for range lines { // drain until the program closes stdout |
| 256 | + | } | |||
| 257 | + | must(t, wait(), stderr.String()) | |||
| 258 | + | // The typed text must not leak to the program's output. | |||
| 259 | + | if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") { | |||
| 260 | + | t.Fatal("the typed text appears in the program output") | |||
| 261 | + | } | |||
| 221 | } | 262 | } | ||