fix(runtime): isolate development environment
- commit
- 0a69a7466a2c461d348b4c149d3f1cfd058ae3b6
- author
- brice · · 2026-08-14 09:30 UTC
- committer
- brice · · 2026-08-14 09:38 UTC
- parent
- d3c8f2e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +224 -1
M README.md +1 -1
| @@ -200,7 +200,7 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream | |||
| 200 | 200 | ||
| 201 | 201 | Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend. | |
| 202 | 202 | ||
| 203 | - | Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths. | |
| 203 | + | Set `ENV=dev` to isolate the development process under `.dev/dumber/`. Dumber sets `HOME` plus `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` there before GTK or the browser engine starts, so development runs do not use production profiles, cookies, caches, or logs. | |
| 204 | 204 | ||
| 205 | 205 | ### Build from source | |
| 206 | 206 | ||
| @@ -200,7 +200,7 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream | |||||
| 200 | 200 | ||||
| 201 | Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend. | 201 | Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend. | ||
| 202 | 202 | ||||
| 203 | - | Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths. | 203 | + | Set `ENV=dev` to isolate the development process under `.dev/dumber/`. Dumber sets `HOME` plus `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` there before GTK or the browser engine starts, so development runs do not use production profiles, cookies, caches, or logs. |
| 204 | 204 | ||||
| 205 | ### Build from source | 205 | ### Build from source | ||
| 206 | 206 | ||||
M cmd/dumber/main.go +5 -0
| @@ -164,6 +164,11 @@ type startupTiming struct { | |||
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | 166 | func main() { | |
| 167 | + | if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil { | |
| 168 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |
| 169 | + | os.Exit(1) | |
| 170 | + | } | |
| 171 | + | ||
| 167 | 172 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | |
| 168 | 173 | // invalidate frames retained across those boundaries and make the GC abort | |
| 169 | 174 | // with "traceback did not unwind completely". Re-exec once so the runtime | |
| @@ -164,6 +164,11 @@ type startupTiming struct { | |||||
| 164 | } | 164 | } | ||
| 165 | 165 | ||||
| 166 | func main() { | 166 | func main() { | ||
| 167 | + | if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil { | |||
| 168 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |||
| 169 | + | os.Exit(1) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 167 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | 172 | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | ||
| 168 | // invalidate frames retained across those boundaries and make the GC abort | 173 | // invalidate frames retained across those boundaries and make the GC abort | ||
| 169 | // with "traceback did not unwind completely". Re-exec once so the runtime | 174 | // with "traceback did not unwind completely". Re-exec once so the runtime | ||
A internal/bootstrap/development_environment.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const developmentEnvironmentName = "dev" | |
| 10 | + | ||
| 11 | + | // ApplyDevelopmentEnvironment isolates the process from the user's XDG and | |
| 12 | + | // home directories when ENV=dev. It must run before GTK, CEF, or any adapter | |
| 13 | + | // can initialize and read process environment settings. | |
| 14 | + | func ApplyDevelopmentEnvironment() error { | |
| 15 | + | if os.Getenv("ENV") != developmentEnvironmentName { | |
| 16 | + | return nil | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | cwd, err := os.Getwd() | |
| 20 | + | if err != nil { | |
| 21 | + | return fmt.Errorf("resolve development environment root: %w", err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | devDir := filepath.Join(cwd, ".dev") | |
| 25 | + | if err := ensureDevelopmentDirectory(devDir, false); err != nil { | |
| 26 | + | return err | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | root := filepath.Join(devDir, "dumber") | |
| 30 | + | paths := map[string]string{ | |
| 31 | + | "HOME": filepath.Join(root, "home"), | |
| 32 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |
| 33 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |
| 34 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |
| 35 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |
| 36 | + | } | |
| 37 | + | for _, path := range developmentPrivateDirectories(root, paths) { | |
| 38 | + | if err := ensureDevelopmentDirectory(path, true); err != nil { | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | for name, path := range paths { | |
| 43 | + | if err := os.Setenv(name, path); err != nil { | |
| 44 | + | return fmt.Errorf("set %s for development environment: %w", name, err) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // developmentPrivateDirectories returns every currently known path that Dumber | |
| 52 | + | // creates below the development sandbox. Creating and validating them before | |
| 53 | + | // GTK, CEF, logging, or the browser profile starts prevents those subsystems | |
| 54 | + | // from following a pre-existing sandbox symlink. | |
| 55 | + | func developmentPrivateDirectories(root string, paths map[string]string) []string { | |
| 56 | + | enginesDir := filepath.Join(root, "engines") | |
| 57 | + | cefDir := filepath.Join(enginesDir, "cef") | |
| 58 | + | webkitDir := filepath.Join(enginesDir, "webkit") | |
| 59 | + | runtimeDir := filepath.Join(root, "runtime") | |
| 60 | + | return []string{ | |
| 61 | + | root, | |
| 62 | + | paths["HOME"], | |
| 63 | + | paths["XDG_CONFIG_HOME"], | |
| 64 | + | paths["XDG_DATA_HOME"], | |
| 65 | + | paths["XDG_STATE_HOME"], | |
| 66 | + | paths["XDG_CACHE_HOME"], | |
| 67 | + | filepath.Join(root, "logs"), | |
| 68 | + | enginesDir, | |
| 69 | + | cefDir, | |
| 70 | + | filepath.Join(cefDir, "data"), | |
| 71 | + | filepath.Join(cefDir, "runtime"), | |
| 72 | + | filepath.Join(cefDir, "logs"), | |
| 73 | + | webkitDir, | |
| 74 | + | filepath.Join(webkitDir, "data"), | |
| 75 | + | filepath.Join(webkitDir, "cache"), | |
| 76 | + | filepath.Join(webkitDir, "runtime"), | |
| 77 | + | filepath.Join(webkitDir, "logs"), | |
| 78 | + | runtimeDir, | |
| 79 | + | filepath.Join(runtimeDir, "cef"), | |
| 80 | + | filepath.Join(runtimeDir, "webkit"), | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func ensureDevelopmentDirectory(path string, private bool) error { | |
| 85 | + | if err := os.MkdirAll(path, 0o700); err != nil { | |
| 86 | + | return fmt.Errorf("create development environment directory %q: %w", path, err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | info, err := os.Lstat(path) | |
| 90 | + | if err != nil { | |
| 91 | + | return fmt.Errorf("inspect development environment directory %q: %w", path, err) | |
| 92 | + | } | |
| 93 | + | if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |
| 94 | + | return fmt.Errorf("development environment path %q must be a directory, not %s", path, info.Mode().Type()) | |
| 95 | + | } | |
| 96 | + | if !private { | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | if err := os.Chmod(path, 0o700); err != nil { | |
| 100 | + | return fmt.Errorf("restrict development environment directory %q: %w", path, err) | |
| 101 | + | } | |
| 102 | + | info, err = os.Stat(path) | |
| 103 | + | if err != nil { | |
| 104 | + | return fmt.Errorf("verify development environment directory %q: %w", path, err) | |
| 105 | + | } | |
| 106 | + | if info.Mode().Perm() != 0o700 { | |
| 107 | + | return fmt.Errorf("development environment directory %q permissions are %04o, want 0700", path, info.Mode().Perm()) | |
| 108 | + | } | |
| 109 | + | return nil | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const developmentEnvironmentName = "dev" | |||
| 10 | + | ||||
| 11 | + | // ApplyDevelopmentEnvironment isolates the process from the user's XDG and | |||
| 12 | + | // home directories when ENV=dev. It must run before GTK, CEF, or any adapter | |||
| 13 | + | // can initialize and read process environment settings. | |||
| 14 | + | func ApplyDevelopmentEnvironment() error { | |||
| 15 | + | if os.Getenv("ENV") != developmentEnvironmentName { | |||
| 16 | + | return nil | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | cwd, err := os.Getwd() | |||
| 20 | + | if err != nil { | |||
| 21 | + | return fmt.Errorf("resolve development environment root: %w", err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | devDir := filepath.Join(cwd, ".dev") | |||
| 25 | + | if err := ensureDevelopmentDirectory(devDir, false); err != nil { | |||
| 26 | + | return err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | root := filepath.Join(devDir, "dumber") | |||
| 30 | + | paths := map[string]string{ | |||
| 31 | + | "HOME": filepath.Join(root, "home"), | |||
| 32 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |||
| 33 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |||
| 34 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |||
| 35 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |||
| 36 | + | } | |||
| 37 | + | for _, path := range developmentPrivateDirectories(root, paths) { | |||
| 38 | + | if err := ensureDevelopmentDirectory(path, true); err != nil { | |||
| 39 | + | return err | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | for name, path := range paths { | |||
| 43 | + | if err := os.Setenv(name, path); err != nil { | |||
| 44 | + | return fmt.Errorf("set %s for development environment: %w", name, err) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | return nil | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // developmentPrivateDirectories returns every currently known path that Dumber | |||
| 52 | + | // creates below the development sandbox. Creating and validating them before | |||
| 53 | + | // GTK, CEF, logging, or the browser profile starts prevents those subsystems | |||
| 54 | + | // from following a pre-existing sandbox symlink. | |||
| 55 | + | func developmentPrivateDirectories(root string, paths map[string]string) []string { | |||
| 56 | + | enginesDir := filepath.Join(root, "engines") | |||
| 57 | + | cefDir := filepath.Join(enginesDir, "cef") | |||
| 58 | + | webkitDir := filepath.Join(enginesDir, "webkit") | |||
| 59 | + | runtimeDir := filepath.Join(root, "runtime") | |||
| 60 | + | return []string{ | |||
| 61 | + | root, | |||
| 62 | + | paths["HOME"], | |||
| 63 | + | paths["XDG_CONFIG_HOME"], | |||
| 64 | + | paths["XDG_DATA_HOME"], | |||
| 65 | + | paths["XDG_STATE_HOME"], | |||
| 66 | + | paths["XDG_CACHE_HOME"], | |||
| 67 | + | filepath.Join(root, "logs"), | |||
| 68 | + | enginesDir, | |||
| 69 | + | cefDir, | |||
| 70 | + | filepath.Join(cefDir, "data"), | |||
| 71 | + | filepath.Join(cefDir, "runtime"), | |||
| 72 | + | filepath.Join(cefDir, "logs"), | |||
| 73 | + | webkitDir, | |||
| 74 | + | filepath.Join(webkitDir, "data"), | |||
| 75 | + | filepath.Join(webkitDir, "cache"), | |||
| 76 | + | filepath.Join(webkitDir, "runtime"), | |||
| 77 | + | filepath.Join(webkitDir, "logs"), | |||
| 78 | + | runtimeDir, | |||
| 79 | + | filepath.Join(runtimeDir, "cef"), | |||
| 80 | + | filepath.Join(runtimeDir, "webkit"), | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func ensureDevelopmentDirectory(path string, private bool) error { | |||
| 85 | + | if err := os.MkdirAll(path, 0o700); err != nil { | |||
| 86 | + | return fmt.Errorf("create development environment directory %q: %w", path, err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | info, err := os.Lstat(path) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return fmt.Errorf("inspect development environment directory %q: %w", path, err) | |||
| 92 | + | } | |||
| 93 | + | if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |||
| 94 | + | return fmt.Errorf("development environment path %q must be a directory, not %s", path, info.Mode().Type()) | |||
| 95 | + | } | |||
| 96 | + | if !private { | |||
| 97 | + | return nil | |||
| 98 | + | } | |||
| 99 | + | if err := os.Chmod(path, 0o700); err != nil { | |||
| 100 | + | return fmt.Errorf("restrict development environment directory %q: %w", path, err) | |||
| 101 | + | } | |||
| 102 | + | info, err = os.Stat(path) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return fmt.Errorf("verify development environment directory %q: %w", path, err) | |||
| 105 | + | } | |||
| 106 | + | if info.Mode().Perm() != 0o700 { | |||
| 107 | + | return fmt.Errorf("development environment directory %q permissions are %04o, want 0700", path, info.Mode().Perm()) | |||
| 108 | + | } | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
A internal/bootstrap/development_environment_test.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestApplyDevelopmentEnvironment_DevIsolatesHomeAndXDGDirectories(t *testing.T) { | |
| 12 | + | worktree := t.TempDir() | |
| 13 | + | t.Chdir(worktree) | |
| 14 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 15 | + | t.Setenv("HOME", "/home/production") | |
| 16 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |
| 17 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |
| 18 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |
| 19 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |
| 20 | + | t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") | |
| 21 | + | ||
| 22 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 23 | + | ||
| 24 | + | root := filepath.Join(worktree, ".dev", "dumber") | |
| 25 | + | want := map[string]string{ | |
| 26 | + | "HOME": filepath.Join(root, "home"), | |
| 27 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |
| 28 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |
| 29 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |
| 30 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |
| 31 | + | } | |
| 32 | + | for name, path := range want { | |
| 33 | + | require.Equal(t, path, os.Getenv(name), name) | |
| 34 | + | info, err := os.Stat(path) | |
| 35 | + | require.NoError(t, err, name) | |
| 36 | + | require.True(t, info.IsDir(), name) | |
| 37 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm(), name) | |
| 38 | + | } | |
| 39 | + | require.Equal(t, "/run/user/1000", os.Getenv("XDG_RUNTIME_DIR")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxSymlink(t *testing.T) { | |
| 43 | + | worktree := t.TempDir() | |
| 44 | + | t.Chdir(worktree) | |
| 45 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 46 | + | t.Setenv("HOME", "/home/production") | |
| 47 | + | ||
| 48 | + | devDir := filepath.Join(worktree, ".dev") | |
| 49 | + | require.NoError(t, os.Mkdir(devDir, 0o755)) | |
| 50 | + | target := t.TempDir() | |
| 51 | + | require.NoError(t, os.Symlink(target, filepath.Join(devDir, "dumber"))) | |
| 52 | + | ||
| 53 | + | err := ApplyDevelopmentEnvironment() | |
| 54 | + | require.Error(t, err) | |
| 55 | + | require.Contains(t, err.Error(), "must be a directory") | |
| 56 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxChildSymlinks(t *testing.T) { | |
| 60 | + | for _, child := range []string{"logs", "engines"} { | |
| 61 | + | t.Run(child, func(t *testing.T) { | |
| 62 | + | worktree := t.TempDir() | |
| 63 | + | t.Chdir(worktree) | |
| 64 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 65 | + | ||
| 66 | + | root := filepath.Join(worktree, ".dev", "dumber") | |
| 67 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 68 | + | require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(root, child))) | |
| 69 | + | ||
| 70 | + | err := ApplyDevelopmentEnvironment() | |
| 71 | + | require.Error(t, err) | |
| 72 | + | require.Contains(t, err.Error(), "must be a directory") | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestApplyDevelopmentEnvironment_DevTightensExistingSandboxPermissions(t *testing.T) { | |
| 78 | + | worktree := t.TempDir() | |
| 79 | + | t.Chdir(worktree) | |
| 80 | + | t.Setenv("ENV", developmentEnvironmentName) | |
| 81 | + | ||
| 82 | + | root := filepath.Join(worktree, ".dev", "dumber") | |
| 83 | + | require.NoError(t, os.MkdirAll(root, 0o755)) | |
| 84 | + | require.NoError(t, os.Chmod(root, 0o755)) | |
| 85 | + | ||
| 86 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 87 | + | ||
| 88 | + | info, err := os.Stat(root) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestApplyDevelopmentEnvironment_ProductionPreservesEnvironment(t *testing.T) { | |
| 94 | + | t.Setenv("ENV", "") | |
| 95 | + | t.Setenv("HOME", "/home/production") | |
| 96 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |
| 97 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |
| 98 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |
| 99 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |
| 100 | + | ||
| 101 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |
| 102 | + | ||
| 103 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |
| 104 | + | require.Equal(t, "/production/config", os.Getenv("XDG_CONFIG_HOME")) | |
| 105 | + | require.Equal(t, "/production/data", os.Getenv("XDG_DATA_HOME")) | |
| 106 | + | require.Equal(t, "/production/state", os.Getenv("XDG_STATE_HOME")) | |
| 107 | + | require.Equal(t, "/production/cache", os.Getenv("XDG_CACHE_HOME")) | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestApplyDevelopmentEnvironment_DevIsolatesHomeAndXDGDirectories(t *testing.T) { | |||
| 12 | + | worktree := t.TempDir() | |||
| 13 | + | t.Chdir(worktree) | |||
| 14 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 15 | + | t.Setenv("HOME", "/home/production") | |||
| 16 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |||
| 17 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |||
| 18 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |||
| 19 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |||
| 20 | + | t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") | |||
| 21 | + | ||||
| 22 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 23 | + | ||||
| 24 | + | root := filepath.Join(worktree, ".dev", "dumber") | |||
| 25 | + | want := map[string]string{ | |||
| 26 | + | "HOME": filepath.Join(root, "home"), | |||
| 27 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |||
| 28 | + | "XDG_DATA_HOME": filepath.Join(root, "data"), | |||
| 29 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |||
| 30 | + | "XDG_CACHE_HOME": filepath.Join(root, "cache"), | |||
| 31 | + | } | |||
| 32 | + | for name, path := range want { | |||
| 33 | + | require.Equal(t, path, os.Getenv(name), name) | |||
| 34 | + | info, err := os.Stat(path) | |||
| 35 | + | require.NoError(t, err, name) | |||
| 36 | + | require.True(t, info.IsDir(), name) | |||
| 37 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm(), name) | |||
| 38 | + | } | |||
| 39 | + | require.Equal(t, "/run/user/1000", os.Getenv("XDG_RUNTIME_DIR")) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxSymlink(t *testing.T) { | |||
| 43 | + | worktree := t.TempDir() | |||
| 44 | + | t.Chdir(worktree) | |||
| 45 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 46 | + | t.Setenv("HOME", "/home/production") | |||
| 47 | + | ||||
| 48 | + | devDir := filepath.Join(worktree, ".dev") | |||
| 49 | + | require.NoError(t, os.Mkdir(devDir, 0o755)) | |||
| 50 | + | target := t.TempDir() | |||
| 51 | + | require.NoError(t, os.Symlink(target, filepath.Join(devDir, "dumber"))) | |||
| 52 | + | ||||
| 53 | + | err := ApplyDevelopmentEnvironment() | |||
| 54 | + | require.Error(t, err) | |||
| 55 | + | require.Contains(t, err.Error(), "must be a directory") | |||
| 56 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestApplyDevelopmentEnvironment_DevRejectsSandboxChildSymlinks(t *testing.T) { | |||
| 60 | + | for _, child := range []string{"logs", "engines"} { | |||
| 61 | + | t.Run(child, func(t *testing.T) { | |||
| 62 | + | worktree := t.TempDir() | |||
| 63 | + | t.Chdir(worktree) | |||
| 64 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 65 | + | ||||
| 66 | + | root := filepath.Join(worktree, ".dev", "dumber") | |||
| 67 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 68 | + | require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(root, child))) | |||
| 69 | + | ||||
| 70 | + | err := ApplyDevelopmentEnvironment() | |||
| 71 | + | require.Error(t, err) | |||
| 72 | + | require.Contains(t, err.Error(), "must be a directory") | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestApplyDevelopmentEnvironment_DevTightensExistingSandboxPermissions(t *testing.T) { | |||
| 78 | + | worktree := t.TempDir() | |||
| 79 | + | t.Chdir(worktree) | |||
| 80 | + | t.Setenv("ENV", developmentEnvironmentName) | |||
| 81 | + | ||||
| 82 | + | root := filepath.Join(worktree, ".dev", "dumber") | |||
| 83 | + | require.NoError(t, os.MkdirAll(root, 0o755)) | |||
| 84 | + | require.NoError(t, os.Chmod(root, 0o755)) | |||
| 85 | + | ||||
| 86 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 87 | + | ||||
| 88 | + | info, err := os.Stat(root) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestApplyDevelopmentEnvironment_ProductionPreservesEnvironment(t *testing.T) { | |||
| 94 | + | t.Setenv("ENV", "") | |||
| 95 | + | t.Setenv("HOME", "/home/production") | |||
| 96 | + | t.Setenv("XDG_CONFIG_HOME", "/production/config") | |||
| 97 | + | t.Setenv("XDG_DATA_HOME", "/production/data") | |||
| 98 | + | t.Setenv("XDG_STATE_HOME", "/production/state") | |||
| 99 | + | t.Setenv("XDG_CACHE_HOME", "/production/cache") | |||
| 100 | + | ||||
| 101 | + | require.NoError(t, ApplyDevelopmentEnvironment()) | |||
| 102 | + | ||||
| 103 | + | require.Equal(t, "/home/production", os.Getenv("HOME")) | |||
| 104 | + | require.Equal(t, "/production/config", os.Getenv("XDG_CONFIG_HOME")) | |||
| 105 | + | require.Equal(t, "/production/data", os.Getenv("XDG_DATA_HOME")) | |||
| 106 | + | require.Equal(t, "/production/state", os.Getenv("XDG_STATE_HOME")) | |||
| 107 | + | require.Equal(t, "/production/cache", os.Getenv("XDG_CACHE_HOME")) | |||
| 108 | + | } | |||