fix(runtime): harden dev browser launch sockets
- commit
- 9c26b134f47546e03d408de37c07c8ce86cb64bc
- author
- brice · · 2026-06-26 13:33 UTC
- parent
- f150f41
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +173 -13
M internal/bootstrap/runtime_profile_test.go +6 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package bootstrap | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "path/filepath" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| @@ -53,7 +54,11 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |||
| 53 | 54 | wd := t.TempDir() | |
| 54 | 55 | t.Chdir(wd) | |
| 55 | 56 | ||
| 56 | - | runtimeDir := t.TempDir() | |
| 57 | + | runtimeDir, err := os.MkdirTemp("", "dbr-") | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | t.Cleanup(func() { | |
| 60 | + | _ = os.RemoveAll(runtimeDir) | |
| 61 | + | }) | |
| 57 | 62 | t.Setenv("ENV", "dev") | |
| 58 | 63 | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | |
| 59 | 64 | profile, err := ResolveRuntimeProfile(cfg) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package bootstrap | 1 | package bootstrap | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 4 | "path/filepath" | 5 | "path/filepath" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -53,7 +54,11 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |||||
| 53 | wd := t.TempDir() | 54 | wd := t.TempDir() | ||
| 54 | t.Chdir(wd) | 55 | t.Chdir(wd) | ||
| 55 | 56 | ||||
| 56 | - | runtimeDir := t.TempDir() | 57 | + | runtimeDir, err := os.MkdirTemp("", "dbr-") |
| 58 | + | require.NoError(t, err) | |||
| 59 | + | t.Cleanup(func() { | |||
| 60 | + | _ = os.RemoveAll(runtimeDir) | |||
| 61 | + | }) | |||
| 57 | t.Setenv("ENV", "dev") | 62 | t.Setenv("ENV", "dev") | ||
| 58 | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | 63 | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | ||
| 59 | profile, err := ResolveRuntimeProfile(cfg) | 64 | profile, err := ResolveRuntimeProfile(cfg) | ||
M internal/infrastructure/cef/engine_init_test.go +19 -2
| @@ -181,12 +181,18 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||
| 181 | 181 | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |
| 182 | 182 | t.Helper() | |
| 183 | 183 | cwd := t.TempDir() | |
| 184 | + | runtimeDir := shortRuntimeDir(t) | |
| 185 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | |
| 184 | 186 | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 185 | 187 | Env: func(key string) string { | |
| 186 | - | if key == "ENV" { | |
| 188 | + | switch key { | |
| 189 | + | case "ENV": | |
| 187 | 190 | return "dev" | |
| 191 | + | case "XDG_RUNTIME_DIR": | |
| 192 | + | return runtimeDir | |
| 193 | + | default: | |
| 194 | + | return "" | |
| 188 | 195 | } | |
| 189 | - | return "" | |
| 190 | 196 | }, | |
| 191 | 197 | Engine: "cef", | |
| 192 | 198 | CWD: func() (string, error) { return cwd, nil }, | |
| @@ -195,6 +201,17 @@ func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |||
| 195 | 201 | return profile | |
| 196 | 202 | } | |
| 197 | 203 | ||
| 204 | + | func shortRuntimeDir(t *testing.T) string { | |
| 205 | + | t.Helper() | |
| 206 | + | ||
| 207 | + | dir, err := os.MkdirTemp("", "dbr-") | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | t.Cleanup(func() { | |
| 210 | + | _ = os.RemoveAll(dir) | |
| 211 | + | }) | |
| 212 | + | return dir | |
| 213 | + | } | |
| 214 | + | ||
| 198 | 215 | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | |
| 199 | 216 | t.Helper() | |
| 200 | 217 | root := t.TempDir() | |
| @@ -181,12 +181,18 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||||
| 181 | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | 181 | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | ||
| 182 | t.Helper() | 182 | t.Helper() | ||
| 183 | cwd := t.TempDir() | 183 | cwd := t.TempDir() | ||
| 184 | + | runtimeDir := shortRuntimeDir(t) | |||
| 185 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | |||
| 184 | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | 186 | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | ||
| 185 | Env: func(key string) string { | 187 | Env: func(key string) string { | ||
| 186 | - | if key == "ENV" { | 188 | + | switch key { |
| 189 | + | case "ENV": | |||
| 187 | return "dev" | 190 | return "dev" | ||
| 191 | + | case "XDG_RUNTIME_DIR": | |||
| 192 | + | return runtimeDir | |||
| 193 | + | default: | |||
| 194 | + | return "" | |||
| 188 | } | 195 | } | ||
| 189 | - | return "" | |||
| 190 | }, | 196 | }, | ||
| 191 | Engine: "cef", | 197 | Engine: "cef", | ||
| 192 | CWD: func() (string, error) { return cwd, nil }, | 198 | CWD: func() (string, error) { return cwd, nil }, | ||
| @@ -195,6 +201,17 @@ func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |||||
| 195 | return profile | 201 | return profile | ||
| 196 | } | 202 | } | ||
| 197 | 203 | ||||
| 204 | + | func shortRuntimeDir(t *testing.T) string { | |||
| 205 | + | t.Helper() | |||
| 206 | + | ||||
| 207 | + | dir, err := os.MkdirTemp("", "dbr-") | |||
| 208 | + | require.NoError(t, err) | |||
| 209 | + | t.Cleanup(func() { | |||
| 210 | + | _ = os.RemoveAll(dir) | |||
| 211 | + | }) | |||
| 212 | + | return dir | |||
| 213 | + | } | |||
| 214 | + | ||||
| 198 | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | 215 | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | ||
| 199 | t.Helper() | 216 | t.Helper() | ||
| 200 | root := t.TempDir() | 217 | root := t.TempDir() | ||
M internal/infrastructure/desktop/browser_launch_relay.go +32 -0
| @@ -186,6 +186,31 @@ func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error { | |||
| 186 | 186 | return conn.SetDeadline(deadline) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | + | func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error { | |
| 190 | + | for dir := filepath.Dir(socketPath); ; dir = filepath.Dir(dir) { | |
| 191 | + | info, err := os.Stat(dir) | |
| 192 | + | if err != nil { | |
| 193 | + | return fmt.Errorf("stat browser launch dir: %w", err) | |
| 194 | + | } | |
| 195 | + | stat, ok := info.Sys().(*syscall.Stat_t) | |
| 196 | + | if !ok { | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | if stat.Uid != expectedUID { | |
| 200 | + | if stat.Uid == 0 { | |
| 201 | + | return nil | |
| 202 | + | } | |
| 203 | + | return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID) | |
| 204 | + | } | |
| 205 | + | if info.Mode().Perm()&0o022 != 0 { | |
| 206 | + | return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm()) | |
| 207 | + | } | |
| 208 | + | if parent := filepath.Dir(dir); parent == dir { | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 189 | 214 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | |
| 190 | 215 | socketPath, err := r.socketPath() | |
| 191 | 216 | if err != nil { | |
| @@ -195,6 +220,9 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||
| 195 | 220 | if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil { | |
| 196 | 221 | return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr) | |
| 197 | 222 | } | |
| 223 | + | if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil { | |
| 224 | + | return nil, ownerErr | |
| 225 | + | } | |
| 198 | 226 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 199 | 227 | if err != nil { | |
| 200 | 228 | if !errors.Is(err, syscall.EADDRINUSE) { | |
| @@ -209,6 +237,10 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||
| 209 | 237 | return nil, errors.New("browser launch relay already running") | |
| 210 | 238 | } | |
| 211 | 239 | ||
| 240 | + | if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil { | |
| 241 | + | return nil, ownerErr | |
| 242 | + | } | |
| 243 | + | ||
| 212 | 244 | if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) { | |
| 213 | 245 | return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr) | |
| 214 | 246 | } | |
| @@ -186,6 +186,31 @@ func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error { | |||||
| 186 | return conn.SetDeadline(deadline) | 186 | return conn.SetDeadline(deadline) | ||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | + | func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error { | |||
| 190 | + | for dir := filepath.Dir(socketPath); ; dir = filepath.Dir(dir) { | |||
| 191 | + | info, err := os.Stat(dir) | |||
| 192 | + | if err != nil { | |||
| 193 | + | return fmt.Errorf("stat browser launch dir: %w", err) | |||
| 194 | + | } | |||
| 195 | + | stat, ok := info.Sys().(*syscall.Stat_t) | |||
| 196 | + | if !ok { | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 199 | + | if stat.Uid != expectedUID { | |||
| 200 | + | if stat.Uid == 0 { | |||
| 201 | + | return nil | |||
| 202 | + | } | |||
| 203 | + | return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID) | |||
| 204 | + | } | |||
| 205 | + | if info.Mode().Perm()&0o022 != 0 { | |||
| 206 | + | return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm()) | |||
| 207 | + | } | |||
| 208 | + | if parent := filepath.Dir(dir); parent == dir { | |||
| 209 | + | return nil | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 189 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | 214 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | ||
| 190 | socketPath, err := r.socketPath() | 215 | socketPath, err := r.socketPath() | ||
| 191 | if err != nil { | 216 | if err != nil { | ||
| @@ -195,6 +220,9 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||||
| 195 | if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil { | 220 | if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil { | ||
| 196 | return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr) | 221 | return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr) | ||
| 197 | } | 222 | } | ||
| 223 | + | if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil { | |||
| 224 | + | return nil, ownerErr | |||
| 225 | + | } | |||
| 198 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | 226 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | ||
| 199 | if err != nil { | 227 | if err != nil { | ||
| 200 | if !errors.Is(err, syscall.EADDRINUSE) { | 228 | if !errors.Is(err, syscall.EADDRINUSE) { | ||
| @@ -209,6 +237,10 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||||
| 209 | return nil, errors.New("browser launch relay already running") | 237 | return nil, errors.New("browser launch relay already running") | ||
| 210 | } | 238 | } | ||
| 211 | 239 | ||||
| 240 | + | if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil { | |||
| 241 | + | return nil, ownerErr | |||
| 242 | + | } | |||
| 243 | + | ||||
| 212 | if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) { | 244 | if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) { | ||
| 213 | return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr) | 245 | return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr) | ||
| 214 | } | 246 | } | ||
M internal/infrastructure/desktop/browser_launch_relay_test.go +38 -0
| @@ -51,6 +51,44 @@ func shortTempDir(t *testing.T) string { | |||
| 51 | 51 | return dir | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | func TestValidateBrowserLaunchSocketDirOwnedAcceptsCurrentUser(t *testing.T) { | |
| 55 | + | ipc := testIPC(shortTempDir(t)) | |
| 56 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 57 | + | ||
| 58 | + | require.NoError(t, validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid()))) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestValidateBrowserLaunchSocketDirOwnedRejectsUnexpectedOwner(t *testing.T) { | |
| 62 | + | ipc := testIPC(shortTempDir(t)) | |
| 63 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 64 | + | ||
| 65 | + | unexpectedUID := uint32(os.Geteuid() + 1) | |
| 66 | + | if unexpectedUID == uint32(os.Geteuid()) { | |
| 67 | + | unexpectedUID++ | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, unexpectedUID) | |
| 71 | + | require.Error(t, err) | |
| 72 | + | require.Contains(t, err.Error(), "browser launch dir owner") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestValidateBrowserLaunchSocketDirOwnedRejectsWritableUserParent(t *testing.T) { | |
| 76 | + | root := shortTempDir(t) | |
| 77 | + | ipc := runtimeprofile.IPCPaths{ | |
| 78 | + | RuntimeDir: filepath.Join(root, "dumber", "dev-abc123", "cef"), | |
| 79 | + | BrowserLaunchSocket: filepath.Join(root, "dumber", "dev-abc123", "cef", browserLaunchSocketName), | |
| 80 | + | } | |
| 81 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 82 | + | require.NoError(t, os.Chmod(filepath.Join(root, "dumber"), 0o777)) | |
| 83 | + | t.Cleanup(func() { | |
| 84 | + | _ = os.Chmod(filepath.Join(root, "dumber"), 0o700) | |
| 85 | + | }) | |
| 86 | + | ||
| 87 | + | err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid())) | |
| 88 | + | require.Error(t, err) | |
| 89 | + | require.Contains(t, err.Error(), "browser launch dir permissions") | |
| 90 | + | } | |
| 91 | + | ||
| 54 | 92 | func testIPC(root string) runtimeprofile.IPCPaths { | |
| 55 | 93 | runtimeDir := filepath.Join(root, "runtime") | |
| 56 | 94 | return runtimeprofile.IPCPaths{ | |
| @@ -51,6 +51,44 @@ func shortTempDir(t *testing.T) string { | |||||
| 51 | return dir | 51 | return dir | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | func TestValidateBrowserLaunchSocketDirOwnedAcceptsCurrentUser(t *testing.T) { | |||
| 55 | + | ipc := testIPC(shortTempDir(t)) | |||
| 56 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |||
| 57 | + | ||||
| 58 | + | require.NoError(t, validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid()))) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestValidateBrowserLaunchSocketDirOwnedRejectsUnexpectedOwner(t *testing.T) { | |||
| 62 | + | ipc := testIPC(shortTempDir(t)) | |||
| 63 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |||
| 64 | + | ||||
| 65 | + | unexpectedUID := uint32(os.Geteuid() + 1) | |||
| 66 | + | if unexpectedUID == uint32(os.Geteuid()) { | |||
| 67 | + | unexpectedUID++ | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, unexpectedUID) | |||
| 71 | + | require.Error(t, err) | |||
| 72 | + | require.Contains(t, err.Error(), "browser launch dir owner") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestValidateBrowserLaunchSocketDirOwnedRejectsWritableUserParent(t *testing.T) { | |||
| 76 | + | root := shortTempDir(t) | |||
| 77 | + | ipc := runtimeprofile.IPCPaths{ | |||
| 78 | + | RuntimeDir: filepath.Join(root, "dumber", "dev-abc123", "cef"), | |||
| 79 | + | BrowserLaunchSocket: filepath.Join(root, "dumber", "dev-abc123", "cef", browserLaunchSocketName), | |||
| 80 | + | } | |||
| 81 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |||
| 82 | + | require.NoError(t, os.Chmod(filepath.Join(root, "dumber"), 0o777)) | |||
| 83 | + | t.Cleanup(func() { | |||
| 84 | + | _ = os.Chmod(filepath.Join(root, "dumber"), 0o700) | |||
| 85 | + | }) | |||
| 86 | + | ||||
| 87 | + | err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid())) | |||
| 88 | + | require.Error(t, err) | |||
| 89 | + | require.Contains(t, err.Error(), "browser launch dir permissions") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 54 | func testIPC(root string) runtimeprofile.IPCPaths { | 92 | func testIPC(root string) runtimeprofile.IPCPaths { | ||
| 55 | runtimeDir := filepath.Join(root, "runtime") | 93 | runtimeDir := filepath.Join(root, "runtime") | ||
| 56 | return runtimeprofile.IPCPaths{ | 94 | return runtimeprofile.IPCPaths{ | ||
M internal/infrastructure/runtimeprofile/profile.go +32 -8
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | const ( | |
| 12 | 12 | browserLaunchSocketName = "browser-launch.sock" | |
| 13 | + | devIPCSocketPathLimit = 104 | |
| 13 | 14 | engineWebKit = "webkit" | |
| 14 | 15 | engineCEF = "cef" | |
| 15 | 16 | ) | |
| @@ -84,7 +85,10 @@ func Resolve(input ResolveInput) (Profile, error) { | |||
| 84 | 85 | } | |
| 85 | 86 | root := filepath.Join(cwd, ".dev", "dumber") | |
| 86 | 87 | engineRoot := filepath.Join(root, "engines", engine) | |
| 87 | - | ipcRoot := devIPCRoot(input.Env, root, engine) | |
| 88 | + | ipcRoot, err := devIPCRoot(input.Env, root, engine) | |
| 89 | + | if err != nil { | |
| 90 | + | return Profile{}, err | |
| 91 | + | } | |
| 88 | 92 | return Profile{ | |
| 89 | 93 | Mode: ModeDev, | |
| 90 | 94 | Engine: engine, | |
| @@ -135,16 +139,36 @@ func Resolve(input ResolveInput) (Profile, error) { | |||
| 135 | 139 | }, nil | |
| 136 | 140 | } | |
| 137 | 141 | ||
| 138 | - | func devIPCRoot(env func(string) string, root, engine string) string { | |
| 139 | - | base := envValue(env, "XDG_RUNTIME_DIR") | |
| 140 | - | if base == "" { | |
| 141 | - | base = envValue(env, "TMPDIR") | |
| 142 | + | func devIPCRoot(env func(string) string, root, engine string) (string, error) { | |
| 143 | + | rootHash := devIPCRootHash(root) | |
| 144 | + | for _, base := range []string{envValue(env, "XDG_RUNTIME_DIR"), envValue(env, "TMPDIR")} { | |
| 145 | + | if base == "" { | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | ipcRoot := devIPCRootFromBase(base, rootHash, engine) | |
| 149 | + | if len(filepath.Join(ipcRoot, browserLaunchSocketName)) < devIPCSocketPathLimit { | |
| 150 | + | return ipcRoot, nil | |
| 151 | + | } | |
| 142 | 152 | } | |
| 143 | - | if base == "" { | |
| 144 | - | base = "/tmp" | |
| 153 | + | ||
| 154 | + | ipcRoot := filepath.Join(root, "runtime", engine) | |
| 155 | + | socketPath := filepath.Join(ipcRoot, browserLaunchSocketName) | |
| 156 | + | if len(socketPath) < devIPCSocketPathLimit { | |
| 157 | + | return ipcRoot, nil | |
| 145 | 158 | } | |
| 159 | + | return "", fmt.Errorf( | |
| 160 | + | "browser launch socket path too long: %d bytes; set XDG_RUNTIME_DIR or TMPDIR to a shorter runtime directory", | |
| 161 | + | len(socketPath), | |
| 162 | + | ) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func devIPCRootFromBase(base, rootHash, engine string) string { | |
| 166 | + | return filepath.Join(base, "dumber", "dev-"+rootHash, engine) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func devIPCRootHash(root string) string { | |
| 146 | 170 | sum := sha256.Sum256([]byte(root)) | |
| 147 | - | return filepath.Join(base, "dumber", "dev-"+hex.EncodeToString(sum[:])[:12], engine) | |
| 171 | + | return hex.EncodeToString(sum[:])[:12] | |
| 148 | 172 | } | |
| 149 | 173 | ||
| 150 | 174 | // normalizeEngine trims and lowercases the runtime engine name. | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | const ( | 11 | const ( | ||
| 12 | browserLaunchSocketName = "browser-launch.sock" | 12 | browserLaunchSocketName = "browser-launch.sock" | ||
| 13 | + | devIPCSocketPathLimit = 104 | |||
| 13 | engineWebKit = "webkit" | 14 | engineWebKit = "webkit" | ||
| 14 | engineCEF = "cef" | 15 | engineCEF = "cef" | ||
| 15 | ) | 16 | ) | ||
| @@ -84,7 +85,10 @@ func Resolve(input ResolveInput) (Profile, error) { | |||||
| 84 | } | 85 | } | ||
| 85 | root := filepath.Join(cwd, ".dev", "dumber") | 86 | root := filepath.Join(cwd, ".dev", "dumber") | ||
| 86 | engineRoot := filepath.Join(root, "engines", engine) | 87 | engineRoot := filepath.Join(root, "engines", engine) | ||
| 87 | - | ipcRoot := devIPCRoot(input.Env, root, engine) | 88 | + | ipcRoot, err := devIPCRoot(input.Env, root, engine) |
| 89 | + | if err != nil { | |||
| 90 | + | return Profile{}, err | |||
| 91 | + | } | |||
| 88 | return Profile{ | 92 | return Profile{ | ||
| 89 | Mode: ModeDev, | 93 | Mode: ModeDev, | ||
| 90 | Engine: engine, | 94 | Engine: engine, | ||
| @@ -135,16 +139,36 @@ func Resolve(input ResolveInput) (Profile, error) { | |||||
| 135 | }, nil | 139 | }, nil | ||
| 136 | } | 140 | } | ||
| 137 | 141 | ||||
| 138 | - | func devIPCRoot(env func(string) string, root, engine string) string { | 142 | + | func devIPCRoot(env func(string) string, root, engine string) (string, error) { |
| 139 | - | base := envValue(env, "XDG_RUNTIME_DIR") | 143 | + | rootHash := devIPCRootHash(root) |
| 140 | - | if base == "" { | 144 | + | for _, base := range []string{envValue(env, "XDG_RUNTIME_DIR"), envValue(env, "TMPDIR")} { |
| 141 | - | base = envValue(env, "TMPDIR") | 145 | + | if base == "" { |
| 146 | + | continue | |||
| 147 | + | } | |||
| 148 | + | ipcRoot := devIPCRootFromBase(base, rootHash, engine) | |||
| 149 | + | if len(filepath.Join(ipcRoot, browserLaunchSocketName)) < devIPCSocketPathLimit { | |||
| 150 | + | return ipcRoot, nil | |||
| 151 | + | } | |||
| 142 | } | 152 | } | ||
| 143 | - | if base == "" { | 153 | + | |
| 144 | - | base = "/tmp" | 154 | + | ipcRoot := filepath.Join(root, "runtime", engine) |
| 155 | + | socketPath := filepath.Join(ipcRoot, browserLaunchSocketName) | |||
| 156 | + | if len(socketPath) < devIPCSocketPathLimit { | |||
| 157 | + | return ipcRoot, nil | |||
| 145 | } | 158 | } | ||
| 159 | + | return "", fmt.Errorf( | |||
| 160 | + | "browser launch socket path too long: %d bytes; set XDG_RUNTIME_DIR or TMPDIR to a shorter runtime directory", | |||
| 161 | + | len(socketPath), | |||
| 162 | + | ) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func devIPCRootFromBase(base, rootHash, engine string) string { | |||
| 166 | + | return filepath.Join(base, "dumber", "dev-"+rootHash, engine) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func devIPCRootHash(root string) string { | |||
| 146 | sum := sha256.Sum256([]byte(root)) | 170 | sum := sha256.Sum256([]byte(root)) | ||
| 147 | - | return filepath.Join(base, "dumber", "dev-"+hex.EncodeToString(sum[:])[:12], engine) | 171 | + | return hex.EncodeToString(sum[:])[:12] |
| 148 | } | 172 | } | ||
| 149 | 173 | ||||
| 150 | // normalizeEngine trims and lowercases the runtime engine name. | 174 | // normalizeEngine trims and lowercases the runtime engine name. | ||
M internal/infrastructure/runtimeprofile/profile_test.go +46 -2
| @@ -48,8 +48,7 @@ func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) { | |||
| 48 | 48 | require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir) | |
| 49 | 49 | require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir) | |
| 50 | 50 | require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir()) | |
| 51 | - | require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, "/tmp/dumber/dev-")) | |
| 52 | - | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | |
| 51 | + | require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) | |
| 53 | 52 | } | |
| 54 | 53 | ||
| 55 | 54 | func TestResolve_DevIPCPathStaysShortForLongWorktrees(t *testing.T) { | |
| @@ -76,6 +75,51 @@ func TestResolve_DevIPCPathStaysShortForLongWorktrees(t *testing.T) { | |||
| 76 | 75 | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | |
| 77 | 76 | } | |
| 78 | 77 | ||
| 78 | + | func TestResolve_DevIPCPathFallsBackToTMPDIRWhenRuntimeDirWouldExceedSocketLimit(t *testing.T) { | |
| 79 | + | longRuntimeDir := "/tmp/" + strings.Repeat("long-runtime-dir-name-", 5) + "run" | |
| 80 | + | ||
| 81 | + | profile, err := Resolve(ResolveInput{ | |
| 82 | + | Env: func(key string) string { | |
| 83 | + | switch key { | |
| 84 | + | case "ENV": | |
| 85 | + | return "dev" | |
| 86 | + | case "XDG_RUNTIME_DIR": | |
| 87 | + | return longRuntimeDir | |
| 88 | + | case "TMPDIR": | |
| 89 | + | return "/run/user/1000" | |
| 90 | + | default: | |
| 91 | + | return "" | |
| 92 | + | } | |
| 93 | + | }, | |
| 94 | + | Engine: "cef", | |
| 95 | + | CWD: func() (string, error) { return "/repo", nil }, | |
| 96 | + | }) | |
| 97 | + | ||
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.Less(t, len(profile.IPC.BrowserLaunchSocket), 104) | |
| 100 | + | require.NotContains(t, profile.IPC.BrowserLaunchSocket, longRuntimeDir) | |
| 101 | + | require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, "/run/user/1000/dumber/dev-")) | |
| 102 | + | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestResolve_DevIPCPathErrorsWhenNoShortBaseExists(t *testing.T) { | |
| 106 | + | longWorktree := "/tmp/" + strings.Repeat("long-worktree-name-", 8) + "project" | |
| 107 | + | ||
| 108 | + | _, err := Resolve(ResolveInput{ | |
| 109 | + | Env: func(key string) string { | |
| 110 | + | if key == "ENV" { | |
| 111 | + | return "dev" | |
| 112 | + | } | |
| 113 | + | return "" | |
| 114 | + | }, | |
| 115 | + | Engine: "cef", | |
| 116 | + | CWD: func() (string, error) { return longWorktree, nil }, | |
| 117 | + | }) | |
| 118 | + | ||
| 119 | + | require.Error(t, err) | |
| 120 | + | require.Contains(t, err.Error(), "browser launch socket path too long") | |
| 121 | + | } | |
| 122 | + | ||
| 79 | 123 | func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) { | |
| 80 | 124 | devEnv := func(key string) string { | |
| 81 | 125 | if key == "ENV" { | |
| @@ -48,8 +48,7 @@ func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) { | |||||
| 48 | require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir) | 48 | require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir) | ||
| 49 | require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir) | 49 | require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir) | ||
| 50 | require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir()) | 50 | require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir()) | ||
| 51 | - | require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, "/tmp/dumber/dev-")) | 51 | + | require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket) |
| 52 | - | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | |||
| 53 | } | 52 | } | ||
| 54 | 53 | ||||
| 55 | func TestResolve_DevIPCPathStaysShortForLongWorktrees(t *testing.T) { | 54 | func TestResolve_DevIPCPathStaysShortForLongWorktrees(t *testing.T) { | ||
| @@ -76,6 +75,51 @@ func TestResolve_DevIPCPathStaysShortForLongWorktrees(t *testing.T) { | |||||
| 76 | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | 75 | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | ||
| 77 | } | 76 | } | ||
| 78 | 77 | ||||
| 78 | + | func TestResolve_DevIPCPathFallsBackToTMPDIRWhenRuntimeDirWouldExceedSocketLimit(t *testing.T) { | |||
| 79 | + | longRuntimeDir := "/tmp/" + strings.Repeat("long-runtime-dir-name-", 5) + "run" | |||
| 80 | + | ||||
| 81 | + | profile, err := Resolve(ResolveInput{ | |||
| 82 | + | Env: func(key string) string { | |||
| 83 | + | switch key { | |||
| 84 | + | case "ENV": | |||
| 85 | + | return "dev" | |||
| 86 | + | case "XDG_RUNTIME_DIR": | |||
| 87 | + | return longRuntimeDir | |||
| 88 | + | case "TMPDIR": | |||
| 89 | + | return "/run/user/1000" | |||
| 90 | + | default: | |||
| 91 | + | return "" | |||
| 92 | + | } | |||
| 93 | + | }, | |||
| 94 | + | Engine: "cef", | |||
| 95 | + | CWD: func() (string, error) { return "/repo", nil }, | |||
| 96 | + | }) | |||
| 97 | + | ||||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.Less(t, len(profile.IPC.BrowserLaunchSocket), 104) | |||
| 100 | + | require.NotContains(t, profile.IPC.BrowserLaunchSocket, longRuntimeDir) | |||
| 101 | + | require.True(t, strings.HasPrefix(profile.IPC.BrowserLaunchSocket, "/run/user/1000/dumber/dev-")) | |||
| 102 | + | require.Contains(t, profile.IPC.BrowserLaunchSocket, "/cef/browser-launch.sock") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestResolve_DevIPCPathErrorsWhenNoShortBaseExists(t *testing.T) { | |||
| 106 | + | longWorktree := "/tmp/" + strings.Repeat("long-worktree-name-", 8) + "project" | |||
| 107 | + | ||||
| 108 | + | _, err := Resolve(ResolveInput{ | |||
| 109 | + | Env: func(key string) string { | |||
| 110 | + | if key == "ENV" { | |||
| 111 | + | return "dev" | |||
| 112 | + | } | |||
| 113 | + | return "" | |||
| 114 | + | }, | |||
| 115 | + | Engine: "cef", | |||
| 116 | + | CWD: func() (string, error) { return longWorktree, nil }, | |||
| 117 | + | }) | |||
| 118 | + | ||||
| 119 | + | require.Error(t, err) | |||
| 120 | + | require.Contains(t, err.Error(), "browser launch socket path too long") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 79 | func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) { | 123 | func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) { | ||
| 80 | devEnv := func(key string) string { | 124 | devEnv := func(key string) string { | ||
| 81 | if key == "ENV" { | 125 | if key == "ENV" { | ||