Skip to content

Commit 00ee4c6

Browse files
MikeMcQuaidp-linnane
authored andcommitted
Add Linux Bubblewrap sandbox
- Use `bwrap` to translate shared sandbox rules into rootless namespace execution. - Gate the backend behind `HOMEBREW_SANDBOX_LINUX` while the Linux policy is still experimental. - Auto-install `bubblewrap` from `homebrew/core` when the sandbox is enabled and no system or usable brewed binary is found. - Prefer a usable system `bwrap` from `ORIGINAL_PATHS` over a brewed one so distribution-provided binaries are used when available. - Find brewed `bwrap` from `HOMEBREW_ORIGINAL_BREW_FILE` so integration subprocesses still use the Linux sandbox. - Preserve `Sandbox#run`'s tmpdir cwd inside the `bwrap` namespace and ignore PTY EOF after sandboxed children exit. - Expose formula and Homebrew library paths as read-only binds so sandboxed local formula builds can run with vendored Ruby. - Keep synthetic test formula installs off the API so sandbox CI does not require network during local formula setup. - Avoid creating optional prefix `var` directories just to configure a test sandbox and remove empty Linux bind placeholders after runs. - Install `bubblewrap` and document namespace sysctls in Linux CI so test-bot exercises the sandbox path.
1 parent 377eef6 commit 00ee4c6

15 files changed

Lines changed: 643 additions & 5 deletions

File tree

.github/workflows/tests.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,20 @@ jobs:
274274
workflow-key: tests-tests-macos
275275
uninstall: true
276276

277+
- name: Install brew tests Linux dependencies
278+
if: runner.os == 'Linux'
279+
run: |
280+
# Allow unprivileged user namespace cloning; rootless `bwrap` needs this
281+
# to create its user namespace.
282+
sudo sysctl -w kernel.unprivileged_userns_clone=1
283+
# Ensure the runner can allocate user namespaces instead of hitting a
284+
# per-user namespace limit.
285+
sudo sysctl -w user.max_user_namespaces=28633
286+
# Ubuntu runners may additionally restrict unprivileged user namespaces
287+
# through AppArmor; older kernels may not expose this sysctl.
288+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
289+
brew install bubblewrap
290+
277291
# brew tests doesn't like world writable directories
278292
- name: Cleanup permissions
279293
if: runner.os == 'Linux'
@@ -286,6 +300,7 @@ jobs:
286300
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
287301
# These cannot be queried at the macOS level on GitHub Actions.
288302
HOMEBREW_LANGUAGES: en-GB
303+
HOMEBREW_SANDBOX_LINUX: 1
289304
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
290305

291306
- name: Get RSpec JUnit XML filenames
@@ -335,6 +350,7 @@ jobs:
335350
- name: test-bot (macOS arm64)
336351
runs-on: macos-26
337352
env:
353+
HOMEBREW_SANDBOX_LINUX: 1
338354
HOMEBREW_TEST_BOT_ANALYTICS: 1
339355
steps:
340356
- name: Install Homebrew and Homebrew's dependencies
@@ -345,6 +361,7 @@ jobs:
345361
# Slimmed down version from the Homebrew Dockerfile
346362
apt-get update
347363
apt-get install -y --no-install-recommends \
364+
bubblewrap \
348365
bzip2 \
349366
ca-certificates \
350367
curl \

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
2929
&& retry apt-get update --error-on=any \
3030
&& apt-get install -y --no-install-recommends \
3131
acl \
32+
bubblewrap \
3233
bzip2 \
3334
ca-certificates \
3435
curl \

Library/Homebrew/dev-cmd/test.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def run
3737
require "formula_free_port"
3838
require "utils/fork"
3939

40+
optional_prefix_var_dirs = %w[var/cache var/log var/run]
4041
args.named.to_resolved_formulae.each do |f|
4142
# Cannot test uninstalled formulae
4243
unless f.latest_version_installed?
@@ -81,17 +82,19 @@ def run
8182

8283
exec_args << "--HEAD" if f.head?
8384

85+
Sandbox.ensure_sandbox_installed!
8486
if Sandbox.available?
8587
sandbox = Sandbox.new
8688
f.logs.mkpath
8789
sandbox.record_log(f.logs/"test.sandbox.log")
8890
sandbox.allow_write_temp_and_cache
8991
sandbox.allow_write_log(f)
9092
sandbox.allow_write_xcode
91-
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/cache")
9293
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
93-
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
94-
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
94+
optional_prefix_var_dirs.each do |dir|
95+
path = HOMEBREW_PREFIX/dir
96+
sandbox.allow_write_path(path) if path.exist?
97+
end
9598
sandbox.deny_all_network unless f.class.network_access_allowed?(:test)
9699
sandbox.run(*exec_args)
97100
else

Library/Homebrew/dev-cmd/tests.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ def run
6666
# `return false`.
6767
require "extend/os/dev-cmd/tests"
6868

69+
check_test_environment!
70+
6971
parallel = !args.no_parallel?
7072

7173
only = args.only
@@ -210,6 +212,9 @@ def non_linux_bundle_args(bundle_args)
210212
bundle_args << "--tag" << "~needs_linux" << "--tag" << "~needs_systemd"
211213
end
212214

215+
sig { void }
216+
def check_test_environment!; end
217+
213218
sig { params(files: T::Array[String]).returns(T::Array[String]) }
214219
def os_files(files)
215220
# for generic tests, remove macOS or Linux specific files
@@ -256,6 +261,7 @@ def setup_environment!
256261
HOMEBREW_GITHUB_API_TOKEN
257262
HOMEBREW_CACHE
258263
HOMEBREW_LOGS
264+
HOMEBREW_SANDBOX_LINUX
259265
HOMEBREW_TEMP
260266
]
261267
allowed_test_env << "HOMEBREW_USE_RUBY_FROM_PATH" if Homebrew::EnvConfig.developer?

Library/Homebrew/env_config.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,10 @@ module EnvConfig
487487
description: "If set, use Pry for the `brew irb` command.",
488488
boolean: true,
489489
},
490+
HOMEBREW_SANDBOX_LINUX: {
491+
description: "If set, use the `bwrap`(1) sandbox for formula installation and testing on Linux.",
492+
boolean: true,
493+
},
490494
HOMEBREW_SBOM: {
491495
# odeprecated: edit in 5.2.0
492496
description: "If set, Homebrew will write SBOM files and run SBOM-related installation logic. " \

Library/Homebrew/extend/os/linux/dev-cmd/tests.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,66 @@ def os_bundle_args(bundle_args)
2020
def os_files(files)
2121
non_macos_files(files)
2222
end
23+
24+
sig { void }
25+
def check_test_environment!
26+
super
27+
return unless ENV["CI"]
28+
29+
require "sandbox"
30+
with_env(HOMEBREW_SANDBOX_LINUX: "1") do
31+
return if ::Sandbox.available?
32+
33+
message = +"Linux tests require a working rootless Bubblewrap sandbox.\n"
34+
message << "HOMEBREW_SANDBOX_LINUX=#{ENV.fetch("HOMEBREW_SANDBOX_LINUX", nil).inspect}\n"
35+
message << "PATH=#{ENV.fetch("PATH", nil).inspect}\n"
36+
message << "HOMEBREW_PATH=#{ENV.fetch("HOMEBREW_PATH", nil).inspect}\n"
37+
message << "ORIGINAL_PATHS=#{ORIGINAL_PATHS.join(File::PATH_SEPARATOR)}\n"
38+
39+
candidates = OS::Linux::Sandbox.bubblewrap_candidate_paths.filter_map do |path|
40+
candidate = begin
41+
::Pathname.new(File.expand_path("bwrap", path))
42+
rescue ArgumentError => e
43+
next "#{path}/bwrap error=#{e.class}: #{e.message}"
44+
end
45+
next unless candidate.exist?
46+
47+
stat = File.stat(candidate)
48+
"#{candidate} file=#{candidate.file?} executable=#{candidate.executable?} setuid=#{stat.setuid?}"
49+
rescue SystemCallError => e
50+
"#{candidate} error=#{e.class}: #{e.message}"
51+
end
52+
message << "bwrap candidates=#{candidates.presence&.join(", ") || "(none)"}\n"
53+
54+
if (bubblewrap = OS::Linux::Sandbox.bubblewrap_executable)
55+
message << "selected bwrap=#{bubblewrap}\n"
56+
message << "selected bwrap setuid=#{File.stat(bubblewrap).setuid?}\n"
57+
result = system_command(
58+
bubblewrap,
59+
args: [
60+
"--unshare-user",
61+
"--unshare-ipc",
62+
"--unshare-pid",
63+
"--unshare-uts",
64+
"--unshare-cgroup-try",
65+
"--ro-bind", "/", "/",
66+
"--proc", "/proc",
67+
"--dev", "/dev",
68+
"true"
69+
],
70+
print_stderr: false,
71+
must_succeed: false,
72+
)
73+
message << "bwrap probe exit_status=#{result.exit_status.inspect}\n"
74+
message << "bwrap probe stdout=#{result.stdout.presence || "(empty)"}\n"
75+
message << "bwrap probe stderr=#{result.stderr.presence || "(empty)"}"
76+
else
77+
message << "selected bwrap=(none)"
78+
end
79+
80+
raise UsageError, message
81+
end
82+
end
2383
end
2484
end
2585
end

0 commit comments

Comments
 (0)