Skip to content

Commit 41a5a05

Browse files
committed
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. - Keep macOS on its existing `sandbox-exec` path while Linux uses shared executable lookup for `bwrap`. - 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 suppress Linux PTY thread warnings after sandboxed children exit. - Expose formula, Homebrew library and Linux runtime paths as read-only binds so sandboxed source 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. - Require a working rootless `bwrap` only on GitHub Actions and print probe, sysctl, runner and candidate details when it is unusable. - Install and probe both system and brewed `bubblewrap` in Linux CI so failures show which binary works.
1 parent 377eef6 commit 41a5a05

17 files changed

Lines changed: 1033 additions & 53 deletions

File tree

.github/workflows/tests.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,48 @@ 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+
sudo apt-get update
281+
sudo apt-get install -y --no-install-recommends bubblewrap
282+
# Allow unprivileged user namespace cloning; rootless `bwrap` needs this
283+
# to create its user namespace.
284+
sudo sysctl -w kernel.unprivileged_userns_clone=1
285+
# Ensure the runner can allocate user namespaces instead of hitting a
286+
# per-user namespace limit.
287+
sudo sysctl -w user.max_user_namespaces=28633
288+
# Ubuntu runners may additionally restrict unprivileged user namespaces
289+
# through AppArmor; older kernels may not expose this sysctl.
290+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
291+
brew install bubblewrap
292+
for bwrap in /usr/bin/bwrap "$(brew --prefix bubblewrap)/bin/bwrap"; do
293+
if [[ ! -x "${bwrap}" ]]
294+
then
295+
echo "${bwrap}: missing or not executable"
296+
continue
297+
fi
298+
echo "::group::Evaluate ${bwrap}"
299+
ls -l "${bwrap}"
300+
"${bwrap}" --version || true
301+
if "${bwrap}" \
302+
--unshare-user \
303+
--unshare-ipc \
304+
--unshare-pid \
305+
--unshare-uts \
306+
--unshare-cgroup-try \
307+
--ro-bind / / \
308+
--proc /proc \
309+
--dev /dev \
310+
true
311+
then
312+
echo "${bwrap}: rootless probe passed"
313+
else
314+
echo "${bwrap}: rootless probe failed"
315+
fi
316+
echo "::endgroup::"
317+
done
318+
277319
# brew tests doesn't like world writable directories
278320
- name: Cleanup permissions
279321
if: runner.os == 'Linux'
@@ -286,6 +328,7 @@ jobs:
286328
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
287329
# These cannot be queried at the macOS level on GitHub Actions.
288330
HOMEBREW_LANGUAGES: en-GB
331+
HOMEBREW_SANDBOX_LINUX: 1
289332
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
290333

291334
- name: Get RSpec JUnit XML filenames
@@ -335,6 +378,7 @@ jobs:
335378
- name: test-bot (macOS arm64)
336379
runs-on: macos-26
337380
env:
381+
HOMEBREW_SANDBOX_LINUX: 1
338382
HOMEBREW_TEST_BOT_ANALYTICS: 1
339383
steps:
340384
- name: Install Homebrew and Homebrew's dependencies
@@ -345,6 +389,7 @@ jobs:
345389
# Slimmed down version from the Homebrew Dockerfile
346390
apt-get update
347391
apt-get install -y --no-install-recommends \
392+
bubblewrap \
348393
bzip2 \
349394
ca-certificates \
350395
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: 5 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,18 @@ 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+
sandbox.allow_write_path_if_exists HOMEBREW_PREFIX/dir
96+
end
9597
sandbox.deny_all_network unless f.class.network_access_allowed?(:test)
9698
sandbox.run(*exec_args)
9799
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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require "utils/github/actions"
5+
46
module OS
57
module Linux
68
module DevCmd
@@ -20,6 +22,99 @@ def os_bundle_args(bundle_args)
2022
def os_files(files)
2123
non_macos_files(files)
2224
end
25+
26+
sig { void }
27+
def check_test_environment!
28+
super
29+
return unless GitHub::Actions.env_set?
30+
31+
require "sandbox"
32+
with_env(HOMEBREW_SANDBOX_LINUX: "1") do
33+
return if ::Sandbox.available?
34+
35+
message = +"GitHub Actions Linux tests require a working rootless Bubblewrap sandbox.\n"
36+
message << "GITHUB_ACTIONS=#{ENV.fetch("GITHUB_ACTIONS", nil).inspect}\n"
37+
message << "RUNNER_OS=#{ENV.fetch("RUNNER_OS", nil).inspect}\n"
38+
message << "ImageOS=#{ENV.fetch("ImageOS", nil).inspect}\n"
39+
message << "ImageVersion=#{ENV.fetch("ImageVersion", nil).inspect}\n"
40+
message << "HOMEBREW_SANDBOX_LINUX=#{ENV.fetch("HOMEBREW_SANDBOX_LINUX", nil).inspect}\n"
41+
message << "PATH=#{ENV.fetch("PATH", nil).inspect}\n"
42+
message << "HOMEBREW_PATH=#{ENV.fetch("HOMEBREW_PATH", nil).inspect}\n"
43+
message << "ORIGINAL_PATHS=#{ORIGINAL_PATHS.join(File::PATH_SEPARATOR)}\n"
44+
message << "HOMEBREW_ORIGINAL_BREW_FILE=#{HOMEBREW_ORIGINAL_BREW_FILE}\n"
45+
46+
uname = system_command("uname", args: ["-a"], print_stderr: false, must_succeed: false)
47+
message << "uname exit_status=#{uname.exit_status.inspect}\n"
48+
message << "uname stdout=#{uname.stdout.presence || "(empty)"}\n"
49+
message << "uname stderr=#{uname.stderr.presence || "(empty)"}\n"
50+
51+
%w[
52+
kernel.unprivileged_userns_clone
53+
user.max_user_namespaces
54+
kernel.apparmor_restrict_unprivileged_userns
55+
].each do |key|
56+
result = system_command("sysctl", args: ["-n", key], print_stderr: false, must_succeed: false)
57+
message << "sysctl #{key} exit_status=#{result.exit_status.inspect}\n"
58+
message << "sysctl #{key} stdout=#{result.stdout.presence || "(empty)"}\n"
59+
message << "sysctl #{key} stderr=#{result.stderr.presence || "(empty)"}\n"
60+
end
61+
62+
candidates = OS::Linux::Sandbox.bubblewrap_candidate_paths.filter_map do |path|
63+
candidate = begin
64+
::Pathname.new(File.expand_path("bwrap", path))
65+
rescue ArgumentError => e
66+
next "#{path}/bwrap error=#{e.class}: #{e.message}"
67+
end
68+
next unless candidate.exist?
69+
70+
stat = File.stat(candidate)
71+
realpath = candidate.realpath
72+
"#{candidate} realpath=#{realpath} file=#{candidate.file?} executable=#{candidate.executable?} " \
73+
"mode=#{stat.mode.to_s(8)} uid=#{stat.uid} gid=#{stat.gid} setuid=#{stat.setuid?}"
74+
rescue SystemCallError => e
75+
"#{candidate} error=#{e.class}: #{e.message}"
76+
end
77+
message << "bwrap candidates=#{candidates.presence&.join(", ") || "(none)"}\n"
78+
79+
if (bubblewrap = OS::Linux::Sandbox.bubblewrap_executable)
80+
message << "selected bwrap=#{bubblewrap}\n"
81+
message << "selected bwrap setuid=#{File.stat(bubblewrap).setuid?}\n"
82+
version = system_command(bubblewrap, args: ["--version"], print_stderr: false, must_succeed: false)
83+
message << "bwrap version exit_status=#{version.exit_status.inspect}\n"
84+
message << "bwrap version stdout=#{version.stdout.presence || "(empty)"}\n"
85+
message << "bwrap version stderr=#{version.stderr.presence || "(empty)"}\n"
86+
probe_args = [
87+
"--unshare-user",
88+
"--unshare-ipc",
89+
"--unshare-pid",
90+
"--unshare-uts",
91+
"--unshare-cgroup-try",
92+
"--ro-bind", "/", "/",
93+
"--proc", "/proc",
94+
"--dev", "/dev",
95+
"true"
96+
]
97+
message << "bwrap probe command=#{[bubblewrap, *probe_args].join(" ")}\n"
98+
result = system_command(
99+
bubblewrap,
100+
args: probe_args,
101+
print_stderr: false,
102+
must_succeed: false,
103+
)
104+
message << "bwrap probe exit_status=#{result.exit_status.inspect}\n"
105+
message << "bwrap probe stdout=#{result.stdout.presence || "(empty)"}\n"
106+
message << "bwrap probe stderr=#{result.stderr.presence || "(empty)"}"
107+
unless result.success?
108+
opoo "Bubblewrap is installed but rootless user namespaces are unavailable; check the Linux " \
109+
"namespace sysctls in GitHub Actions."
110+
end
111+
else
112+
message << "selected bwrap=(none)"
113+
end
114+
115+
raise UsageError, message
116+
end
117+
end
23118
end
24119
end
25120
end

0 commit comments

Comments
 (0)