Skip to content

Commit 17f32f5

Browse files
committed
centralize sandbox config, add new type selector for sandbox type
1 parent 4ab1742 commit 17f32f5

11 files changed

+333
-40
lines changed

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,27 @@ Built for the [itch.io app](https://itch.io/itch) to launch game binaries, smaug
2626

2727
### Linux
2828

29-
Three sandbox backends are supported. `GetRunner()` selects one automatically when `Sandbox` is enabled:
29+
Three sandbox backends are supported when `Sandbox` is enabled.
30+
Shared sandbox settings are configured through `RunnerParams.SandboxConfig`:
31+
- `Type`: explicit backend (`"bubblewrap"`, `"firejail"`, `"flatpak"`) or auto (`""`)
32+
- `NoNetwork`: disable network access for the selected backend
33+
- `AllowEnv`: additional environment variable names to pass through from the host
3034

31-
1. **Flatpak-spawn** — chosen when running inside a [Flatpak](https://flatpak.org/) environment (detected by the presence of `/.flatpak-info`). Uses `flatpak-spawn --sandbox` to create a sub-sandbox within the Flatpak container. Supports environment variable forwarding (`--env`), working directory (`--directory`), and optional network isolation (`--no-network`). The `--watch-bus` flag ties the sandboxed process lifetime to the caller's session bus.
35+
Sandbox backends:
3236

33-
2. **Bubblewrap**chosen when `BubblewrapParams.BinaryPath` is set (and not inside a Flatpak). Uses [bubblewrap](https://github.com/containers/bubblewrap) to create a lightweight user-namespace sandbox. Mounts system directories read-only, bind-mounts the game's install folder read-write, and forwards display/audio sockets (X11, Wayland, PulseAudio, PipeWire). Namespace isolation covers user, PID, and UTS; IPC stays shared for X11 MIT-SHM compatibility. Network access is shared by default, with optional isolation via `BubblewrapParams.NoNetwork`.
37+
1. **Flatpak-spawn**uses `flatpak-spawn --sandbox` to create a sub-sandbox within the Flatpak container. Supports environment variable forwarding (`--env`), working directory (`--directory`), and optional network isolation via `SandboxConfig.NoNetwork` (`--no-network`). The `--watch-bus` flag ties the sandboxed process lifetime to the caller's session bus.
3438

35-
3. **Firejail**the fallback when neither of the above apply. Uses [firejail](https://firejail.wordpress.com/) with a generated profile at `{InstallFolder}/.itch/isolate-app.profile` that blacklists sensitive directories and whitelists the game's install folder and temp directory. Environment forwarding follows the same allowlist baseline as bubblewrap (including itch launch vars and temp vars), and network access can be disabled with `FirejailParams.NoNetwork`. Per-game local overrides can be placed in `/etc/firejail/` (e.g. `itch_game_{name}.local`), and a global override file `itch_games_globals.local` is also included if present.
39+
2. **Bubblewrap**uses [bubblewrap](https://github.com/containers/bubblewrap) to create a lightweight user-namespace sandbox. Mounts system directories read-only, bind-mounts the game's install folder read-write, and forwards display/audio sockets (X11, Wayland, PulseAudio, PipeWire). Namespace isolation covers user, PID, and UTS; IPC stays shared for X11 MIT-SHM compatibility. Network access is shared by default, with optional isolation via `SandboxConfig.NoNetwork`.
3640

37-
Selection priority: **Flatpak-spawn > Bubblewrap > Firejail**.
41+
3. **Firejail** — uses [firejail](https://firejail.wordpress.com/) with a generated profile at `{InstallFolder}/.itch/isolate-app.profile` that blacklists sensitive directories and whitelists the game's install folder and temp directory. Environment forwarding follows the same allowlist baseline as bubblewrap (including itch launch vars and temp vars), supports additional passthrough via `SandboxConfig.AllowEnv`, and network access can be disabled with `SandboxConfig.NoNetwork`. Per-game local overrides can be placed in `/etc/firejail/` (e.g. `itch_game_{name}.local`), and a global override file `itch_games_globals.local` is also included if present.
42+
43+
Backend selection:
44+
- Explicit selection: set `SandboxConfig.Type` to `"flatpak"`, `"bubblewrap"`, or `"firejail"`.
45+
- Auto selection: leave `SandboxConfig.Type` empty (`""`).
46+
- Linux auto priority: **Flatpak-spawn > Bubblewrap > Firejail**.
47+
- Linux auto rule: choose Flatpak-spawn when running inside a [Flatpak](https://flatpak.org/) environment (`/.flatpak-info` present).
48+
- Linux auto rule: choose Bubblewrap when `BubblewrapParams.BinaryPath` is configured.
49+
- Linux auto rule: choose Firejail otherwise.
3850

3951
### macOS
4052

runner/bubblewrap_linux.go

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func (br *bubblewrapRunner) Run() error {
180180
// - keep IPC shared for X11 MIT-SHM compatibility
181181
// - optionally isolate network when NoNetwork is requested
182182
args = append(args, "--unshare-user")
183-
if params.BubblewrapParams.NoNetwork {
183+
if params.SandboxConfig.NoNetwork {
184184
args = append(args, "--unshare-net")
185185
}
186186
args = append(args, "--unshare-pid")
@@ -195,7 +195,14 @@ func (br *bubblewrapRunner) Run() error {
195195

196196
// Environment passthrough
197197
for _, key := range SandboxEnvAllowlist() {
198-
if val := envLookup(params.Env, key); val != "" {
198+
if val, found := envLookupWithPresence(params.Env, key); found {
199+
args = append(args, "--setenv", key, val)
200+
} else if val := os.Getenv(key); val != "" {
201+
args = append(args, "--setenv", key, val)
202+
}
203+
}
204+
for _, key := range params.SandboxConfig.AllowEnv {
205+
if val, found := envLookupWithPresence(params.Env, key); found {
199206
args = append(args, "--setenv", key, val)
200207
} else if val := os.Getenv(key); val != "" {
201208
args = append(args, "--setenv", key, val)
@@ -241,17 +248,6 @@ func (br *bubblewrapRunner) Run() error {
241248
return nil
242249
}
243250

244-
// envLookup looks up a key in a []string{"KEY=VALUE", ...} slice.
245-
func envLookup(env []string, key string) string {
246-
prefix := key + "="
247-
for _, e := range env {
248-
if strings.HasPrefix(e, prefix) {
249-
return e[len(prefix):]
250-
}
251-
}
252-
return ""
253-
}
254-
255251
func ensureSandboxParentDirs(args *[]string, seen map[string]struct{}, path string) {
256252
cleanPath := filepath.Clean(path)
257253
if cleanPath == "" || cleanPath == "." || !filepath.IsAbs(cleanPath) {

runner/bubblewrap_linux_test.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ import (
1212
"github.com/stretchr/testify/require"
1313
)
1414

15+
func bubblewrapSetenvValues(args []string, key string) []string {
16+
out := make([]string, 0, 1)
17+
for i := 0; i+2 < len(args); i++ {
18+
if args[i] == "--setenv" && args[i+1] == key {
19+
out = append(out, args[i+2])
20+
}
21+
}
22+
return out
23+
}
24+
1525
func TestEnsureSandboxParentDirs(t *testing.T) {
1626
var args []string
1727
seen := make(map[string]struct{})
@@ -102,8 +112,8 @@ func TestBubblewrapNoNetworkAddsUnshareNet(t *testing.T) {
102112
Ctx: context.Background(),
103113
BubblewrapParams: BubblewrapParams{
104114
BinaryPath: "/fake/bwrap",
105-
NoNetwork: true,
106115
},
116+
SandboxConfig: SandboxConfig{NoNetwork: true},
107117
FullTargetPath: "/bin/true",
108118
},
109119
}
@@ -131,7 +141,6 @@ func TestBubblewrapNoNetworkDisabledOmitsUnshareNet(t *testing.T) {
131141
Ctx: context.Background(),
132142
BubblewrapParams: BubblewrapParams{
133143
BinaryPath: "/fake/bwrap",
134-
NoNetwork: false,
135144
},
136145
FullTargetPath: "/bin/true",
137146
},
@@ -140,3 +149,66 @@ func TestBubblewrapNoNetworkDisabledOmitsUnshareNet(t *testing.T) {
140149
require.NoError(t, br.Run())
141150
assert.NotContains(t, gotArgs, "--unshare-net")
142151
}
152+
153+
func TestBubblewrapAllowlistEnvEmptyValueOverridesHost(t *testing.T) {
154+
origCommand := bubblewrapCommand
155+
t.Cleanup(func() {
156+
bubblewrapCommand = origCommand
157+
})
158+
159+
var gotArgs []string
160+
bubblewrapCommand = func(name string, args ...string) *exec.Cmd {
161+
gotArgs = append([]string{}, args...)
162+
return exec.Command("sh", "-c", "true")
163+
}
164+
165+
t.Setenv("LANG", "host-lang")
166+
167+
br := &bubblewrapRunner{
168+
params: RunnerParams{
169+
Consumer: &state.Consumer{OnMessage: func(string, string) {}},
170+
Ctx: context.Background(),
171+
BubblewrapParams: BubblewrapParams{
172+
BinaryPath: "/fake/bwrap",
173+
},
174+
Env: []string{"LANG="},
175+
FullTargetPath: "/bin/true",
176+
},
177+
}
178+
179+
require.NoError(t, br.Run())
180+
assert.Equal(t, []string{""}, bubblewrapSetenvValues(gotArgs, "LANG"))
181+
}
182+
183+
func TestBubblewrapAllowEnvEmptyValueOverridesHost(t *testing.T) {
184+
origCommand := bubblewrapCommand
185+
t.Cleanup(func() {
186+
bubblewrapCommand = origCommand
187+
})
188+
189+
var gotArgs []string
190+
bubblewrapCommand = func(name string, args ...string) *exec.Cmd {
191+
gotArgs = append([]string{}, args...)
192+
return exec.Command("sh", "-c", "true")
193+
}
194+
195+
t.Setenv("SMAUG_ALLOW_ENV_EMPTY", "host")
196+
197+
br := &bubblewrapRunner{
198+
params: RunnerParams{
199+
Consumer: &state.Consumer{OnMessage: func(string, string) {}},
200+
Ctx: context.Background(),
201+
BubblewrapParams: BubblewrapParams{
202+
BinaryPath: "/fake/bwrap",
203+
},
204+
Env: []string{"SMAUG_ALLOW_ENV_EMPTY="},
205+
SandboxConfig: SandboxConfig{
206+
AllowEnv: []string{"SMAUG_ALLOW_ENV_EMPTY"},
207+
},
208+
FullTargetPath: "/bin/true",
209+
},
210+
}
211+
212+
require.NoError(t, br.Run())
213+
assert.Equal(t, []string{""}, bubblewrapSetenvValues(gotArgs, "SMAUG_ALLOW_ENV_EMPTY"))
214+
}

runner/env_allowlist_linux.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
package runner
44

5+
import "strings"
6+
57
// BaseSandboxEnvVars are platform/session vars required by sandboxed games.
68
var BaseSandboxEnvVars = []string{
79
"DISPLAY",
@@ -26,17 +28,36 @@ func SandboxEnvAllowlist() []string {
2628
return allowlist
2729
}
2830

29-
func collectAllowedEnv(paramsEnv []string, hostEnv []string) []string {
31+
func collectAllowedEnv(paramsEnv []string, hostEnv []string, extraKeys []string) []string {
3032
allowlist := SandboxEnvAllowlist()
33+
allowlist = append(allowlist, extraKeys...)
3134
out := make([]string, 0, len(allowlist))
3235
for _, key := range allowlist {
33-
if val := envLookup(paramsEnv, key); val != "" {
36+
if val, found := envLookupWithPresence(paramsEnv, key); found {
3437
out = append(out, key+"="+val)
3538
continue
3639
}
37-
if val := envLookup(hostEnv, key); val != "" {
40+
if val, found := envLookupWithPresence(hostEnv, key); found {
3841
out = append(out, key+"="+val)
3942
}
4043
}
4144
return out
4245
}
46+
47+
// envLookup looks up a key in a []string{"KEY=VALUE", ...} slice.
48+
func envLookup(env []string, key string) string {
49+
val, _ := envLookupWithPresence(env, key)
50+
return val
51+
}
52+
53+
// envLookupWithPresence returns the value for KEY and whether KEY was present.
54+
// Presence is true even when KEY is explicitly set to an empty value ("KEY=").
55+
func envLookupWithPresence(env []string, key string) (string, bool) {
56+
prefix := key + "="
57+
for _, e := range env {
58+
if strings.HasPrefix(e, prefix) {
59+
return e[len(prefix):], true
60+
}
61+
}
62+
return "", false
63+
}

runner/env_allowlist_linux_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//go:build linux
2+
3+
package runner
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestCollectAllowedEnvParamsEmptyOverridesHostBaseKey(t *testing.T) {
12+
got := collectAllowedEnv(
13+
[]string{"LANG="},
14+
[]string{"LANG=host-lang"},
15+
nil,
16+
)
17+
18+
assert.Contains(t, got, "LANG=")
19+
assert.NotContains(t, got, "LANG=host-lang")
20+
}
21+
22+
func TestCollectAllowedEnvParamsEmptyOverridesHostExtraKey(t *testing.T) {
23+
got := collectAllowedEnv(
24+
[]string{"SMAUG_EXTRA_ENV="},
25+
[]string{"SMAUG_EXTRA_ENV=host"},
26+
[]string{"SMAUG_EXTRA_ENV"},
27+
)
28+
29+
assert.Contains(t, got, "SMAUG_EXTRA_ENV=")
30+
assert.NotContains(t, got, "SMAUG_EXTRA_ENV=host")
31+
}
32+
33+
func TestCollectAllowedEnvExtraKeyFallsBackToHost(t *testing.T) {
34+
got := collectAllowedEnv(
35+
nil,
36+
[]string{"SMAUG_EXTRA_ENV=host"},
37+
[]string{"SMAUG_EXTRA_ENV"},
38+
)
39+
40+
assert.Contains(t, got, "SMAUG_EXTRA_ENV=host")
41+
}

runner/firejail_linux.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (fr *firejailRunner) Run() error {
6868

6969
var args []string
7070
args = append(args, fmt.Sprintf("--profile=%s", sandboxProfilePath))
71-
if params.FirejailParams.NoNetwork {
71+
if params.SandboxConfig.NoNetwork {
7272
args = append(args, "--net=none")
7373
}
7474
args = append(args, "--")
@@ -77,7 +77,7 @@ func (fr *firejailRunner) Run() error {
7777

7878
cmd := firejailCommand(firejailPath, args...)
7979
cmd.Dir = params.Dir
80-
cmd.Env = collectAllowedEnv(params.Env, os.Environ())
80+
cmd.Env = collectAllowedEnv(params.Env, os.Environ(), params.SandboxConfig.AllowEnv)
8181
cmd.Stdout = params.Stdout
8282
cmd.Stderr = params.Stderr
8383

runner/firejail_linux_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ func newFirejailTestRunner(t *testing.T, noNetwork bool) *firejailRunner {
3737
TempDir: t.TempDir(),
3838
FirejailParams: FirejailParams{
3939
BinaryPath: "/fake/firejail",
40-
NoNetwork: noNetwork,
4140
},
41+
SandboxConfig: SandboxConfig{NoNetwork: noNetwork},
4242
},
4343
}
4444
}

runner/flatpakspawn_linux.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (fr *flatpakSpawnRunner) Run() error {
4747
var args []string
4848
args = append(args, "--sandbox")
4949

50-
if params.FlatpakSpawnParams.NoNetwork {
50+
if params.SandboxConfig.NoNetwork {
5151
args = append(args, "--no-network")
5252
}
5353

@@ -58,6 +58,14 @@ func (fr *flatpakSpawnRunner) Run() error {
5858
for _, e := range params.Env {
5959
args = append(args, "--env="+e)
6060
}
61+
for _, key := range params.SandboxConfig.AllowEnv {
62+
if _, found := envLookupWithPresence(params.Env, key); found {
63+
continue
64+
}
65+
if val := os.Getenv(key); val != "" {
66+
args = append(args, "--env="+key+"="+val)
67+
}
68+
}
6169

6270
// Working directory
6371
if params.Dir != "" {

0 commit comments

Comments
 (0)