detached 0a69a74

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
200200
201201 Dumber uses pure-Go bindings. The GUI uses GTK4, runs on CEF by default, and can use WebKitGTK as a fallback backend.
202202
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.
204204
205205 ### Build from source
206206
@@ -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 source205 ### Build from source
206 206
M cmd/dumber/main.go +5 -0
@@ -164,6 +164,11 @@ type startupTiming struct {
164164 }
165165
166166 func main() {
167+ if err := bootstrap.ApplyDevelopmentEnvironment(); err != nil {
168+ _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
169+ os.Exit(1)
170+ }
171+
167172 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
168173 // invalidate frames retained across those boundaries and make the GC abort
169174 // 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 can172 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
168 // invalidate frames retained across those boundaries and make the GC abort173 // invalidate frames retained across those boundaries and make the GC abort
169 // with "traceback did not unwind completely". Re-exec once so the runtime174 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close