Skip to content

Commit 12e340a

Browse files
committed
Fix Linux Bubblewrap setup
- Prefer an existing `bubblewrap` before installing anything. - Use Homebrew before `apt-get` so local and container installs stay consistent. - Keep `apt-get` as a final GitHub-hosted Ubuntu fallback for missing or unusable `bwrap` executables. - Symbolise API dependency bounds before runtime casts so the Homebrew fallback can load `bubblewrap` from API data. - Document the temporary `test-bot` default until Linux sandboxing is forced on in a later release.
1 parent 081559f commit 12e340a

6 files changed

Lines changed: 201 additions & 58 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -265,16 +265,9 @@ jobs:
265265
if: matrix.name == 'tests (online)'
266266
uses: Homebrew/actions/cache-homebrew-prefix@main
267267
with:
268-
install: bubblewrap curl subversion
268+
install: curl subversion
269269
workflow-key: tests-tests-online
270270

271-
- name: Install brew tests Linux dependencies
272-
if: matrix.name == 'tests (Linux)'
273-
uses: Homebrew/actions/cache-homebrew-prefix@main
274-
with:
275-
install: bubblewrap
276-
workflow-key: tests-tests-linux
277-
278271
- name: Install brew tests macOS dependencies
279272
if: runner.os != 'Linux'
280273
uses: Homebrew/actions/cache-homebrew-prefix@main
@@ -357,7 +350,6 @@ jobs:
357350
# Slimmed down version from the Homebrew Dockerfile
358351
apt-get update
359352
apt-get install -y --no-install-recommends \
360-
bubblewrap \
361353
bzip2 \
362354
ca-certificates \
363355
curl \

Library/Homebrew/api/formula/formula_struct_generator.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,11 @@ def symbolize_dependency_hash(hash)
223223
hash = hash.dup
224224

225225
if (uses_from_macos_bounds = hash["uses_from_macos_bounds"])
226-
uses_from_macos_bounds = T.cast(uses_from_macos_bounds, T::Array[T::Hash[Symbol, Symbol]])
227-
hash["uses_from_macos_bounds"] = uses_from_macos_bounds.map(&:deep_symbolize_keys)
226+
uses_from_macos_bounds =
227+
T.cast(uses_from_macos_bounds, T::Array[T::Hash[T.any(String, Symbol), T.any(String, Symbol)]])
228+
hash["uses_from_macos_bounds"] = uses_from_macos_bounds.map do |bound|
229+
bound.to_h { |key, value| [key.to_sym, value.to_sym] }
230+
end
228231
end
229232

230233
hash.transform_values do |deps|

Library/Homebrew/extend/os/linux/sandbox.rb

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ module Sandbox
2828
/usr/bin
2929
/bin
3030
].freeze, T::Array[String])
31+
HOMEBREW_BUBBLEWRAP_PATHS = T.let([
32+
"#{HOMEBREW_PREFIX}/bin",
33+
].freeze, T::Array[String])
3134
class SysctlSetting < T::Struct
3235
const :assignment, String
3336
const :description, T::Array[String]
@@ -60,7 +63,7 @@ class SysctlSetting < T::Struct
6063
].freeze, T::Array[SysctlSetting])
6164
# `TIOCSCTTY` from `<asm-generic/ioctls.h>`; Ruby does not expose it.
6265
TIOCSCTTY = 0x540E
63-
private_constant :BUBBLEWRAP, :BUBBLEWRAP_TEST_ARGS, :SYSTEM_BUBBLEWRAP_PATHS,
66+
private_constant :BUBBLEWRAP, :BUBBLEWRAP_TEST_ARGS, :SYSTEM_BUBBLEWRAP_PATHS, :HOMEBREW_BUBBLEWRAP_PATHS,
6467
:SysctlSetting, :SANDBOX_SYSCTL_SETTINGS, :TIOCSCTTY
6568

6669
sig { returns(::PATH) }
@@ -123,7 +126,7 @@ def system_bubblewrap_paths
123126

124127
sig { returns(::PATH) }
125128
def executable_candidate_paths
126-
PATH.new(system_bubblewrap_paths, super)
129+
PATH.new(HOMEBREW_BUBBLEWRAP_PATHS, system_bubblewrap_paths, super)
127130
end
128131

129132
sig { returns(::PATH) }
@@ -148,14 +151,27 @@ def ensure_sandbox_installed!(install_from_tests: false)
148151
return if ENV["HOMEBREW_INSTALLING_BUBBLEWRAP"]
149152
return if bubblewrap_executable
150153

151-
require "exceptions"
152-
require "formula"
153-
with_env(HOMEBREW_INSTALLING_BUBBLEWRAP: "1") do
154-
::Formula["bubblewrap"].ensure_installed!(reason: "Linux sandboxing")
154+
begin
155+
require "exceptions"
156+
require "formula"
157+
with_env(HOMEBREW_INSTALLING_BUBBLEWRAP: "1") do
158+
::Formula["bubblewrap"].ensure_installed!(reason: "Linux sandboxing")
159+
end
160+
reset_state!
161+
return if bubblewrap_executable
162+
rescue ::FormulaUnavailableError
163+
nil
155164
end
165+
166+
return unless GitHub::Actions.env_set?
167+
return unless ENV.fetch("HOMEBREW_GITHUB_HOSTED_RUNNER", nil)
168+
return unless which("apt-get")
169+
170+
ohai "Installing Bubblewrap..."
171+
command = ["apt-get", "install", "--yes", "bubblewrap"]
172+
command.unshift("sudo") unless Process.euid.zero?
173+
system(*command)
156174
reset_state!
157-
rescue ::FormulaUnavailableError
158-
nil
159175
end
160176

161177
sig { returns(T::Boolean) }
@@ -197,21 +213,9 @@ def configuration_command_messages
197213

198214
sig { void }
199215
def configure!
200-
unless (bubblewrap = bubblewrap_executable)
201-
if GitHub::Actions.env_set? &&
202-
ENV["RUNNER_ENVIRONMENT"] == "github-hosted" &&
203-
ENV.fetch("ImageOS", "").start_with?("ubuntu")
204-
ohai "Installing Bubblewrap..."
205-
system "sudo", "apt-get", "install", "--yes", "bubblewrap"
206-
reset_state!
207-
bubblewrap = bubblewrap_executable
208-
end
209-
210-
unless bubblewrap
211-
ensure_sandbox_installed!(install_from_tests: true)
212-
bubblewrap = bubblewrap_executable
213-
end
214-
unless bubblewrap
216+
unless bubblewrap_executable
217+
ensure_sandbox_installed!(install_from_tests: true)
218+
unless bubblewrap_executable
215219
reset_state!
216220
return
217221
end
@@ -259,10 +263,10 @@ def compute_state
259263
bubblewraps = bubblewrap_executables
260264
return :missing if bubblewraps.empty?
261265

262-
bubblewrap = bubblewraps.find { |candidate| executable_usable?(candidate) }
263-
return :setuid if bubblewrap.nil?
266+
bubblewraps = bubblewraps.select { |candidate| executable_usable?(candidate) }
267+
return :setuid if bubblewraps.empty?
264268

265-
return :available if bubblewrap_sandbox_available?(bubblewrap)
269+
return :available if bubblewraps.any? { |candidate| bubblewrap_sandbox_available?(candidate) }
266270

267271
:unavailable
268272
end

Library/Homebrew/test/sandbox_linux_spec.rb

Lines changed: 152 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ def executable_candidate_paths = test_executable_candidate_paths
3838
allow(File).to receive(:stat).with(setuid_bubblewrap).and_return(instance_double(File::Stat, setuid?: true))
3939
end
4040

41-
it "skips setuid bubblewrap candidates" do
41+
it "searches Homebrew Bubblewrap before system Bubblewrap and skips setuid candidates" do
42+
expect(klass.executable_candidate_paths.to_a).to start_with("#{HOMEBREW_PREFIX}/bin", "/usr/bin", "/bin")
4243
expect(sandbox_class.bubblewrap_executable).to eq(usable_bubblewrap)
4344
end
4445

@@ -62,6 +63,8 @@ def executable_candidate_paths = test_executable_candidate_paths
6263
end
6364
let(:bubblewrap_dir) { mktmpdir }
6465
let(:bubblewrap) { bubblewrap_dir/"bwrap" }
66+
let(:fallback_bubblewrap_dir) { mktmpdir }
67+
let(:fallback_bubblewrap) { fallback_bubblewrap_dir/"bwrap" }
6568

6669
before do
6770
FileUtils.touch bubblewrap
@@ -103,6 +106,43 @@ def executable_candidate_paths = test_executable_candidate_paths
103106
expect(sandbox_class.failure_reason).to be_nil
104107
end
105108

109+
it "probes later usable Bubblewrap candidates if earlier candidates fail" do
110+
FileUtils.touch fallback_bubblewrap
111+
FileUtils.chmod "+x", fallback_bubblewrap
112+
sandbox_class.test_executable_candidate_paths = PATH.new(bubblewrap_dir, fallback_bubblewrap_dir)
113+
114+
expect(sandbox_class).to receive(:system).with(
115+
bubblewrap.to_s,
116+
"--unshare-user",
117+
"--unshare-ipc",
118+
"--unshare-pid",
119+
"--unshare-uts",
120+
"--unshare-cgroup-try",
121+
"--ro-bind", "/", "/",
122+
"--proc", "/proc",
123+
"--dev", "/dev",
124+
"true",
125+
out: File::NULL,
126+
err: File::NULL
127+
).and_return(false)
128+
expect(sandbox_class).to receive(:system).with(
129+
fallback_bubblewrap.to_s,
130+
"--unshare-user",
131+
"--unshare-ipc",
132+
"--unshare-pid",
133+
"--unshare-uts",
134+
"--unshare-cgroup-try",
135+
"--ro-bind", "/", "/",
136+
"--proc", "/proc",
137+
"--dev", "/dev",
138+
"true",
139+
out: File::NULL,
140+
err: File::NULL
141+
).and_return(true)
142+
143+
expect(sandbox_class.available?).to be(true)
144+
end
145+
106146
it "reports setuid bubblewrap candidates" do
107147
allow(File).to receive(:stat).and_call_original
108148
allow(File).to receive(:stat).with(bubblewrap).and_return(instance_double(File::Stat, setuid?: true))
@@ -125,7 +165,7 @@ def executable_candidate_paths = test_executable_candidate_paths
125165
let(:sandbox_class) { Class.new(klass) }
126166

127167
around do |example|
128-
with_env(GITHUB_ACTIONS: nil, ImageOS: nil, RUNNER_ENVIRONMENT: nil) { example.run }
168+
with_env(GITHUB_ACTIONS: nil, HOMEBREW_GITHUB_HOSTED_RUNNER: nil) { example.run }
129169
end
130170

131171
def expect_sandbox_configuration_command(sandbox_class, assignment, result:)
@@ -164,39 +204,130 @@ def expect_sandbox_configuration_command(sandbox_class, assignment, result:)
164204
sandbox_class.configure!
165205
end
166206

167-
it "installs Bubblewrap with apt-get on default GitHub Actions Ubuntu runners" do
207+
it "installs Bubblewrap and configures Linux sandbox sysctls" do
168208
expect(sandbox_class).to receive(:bubblewrap_executable)
169209
.twice
170-
.and_return(nil, Pathname("/usr/bin/bwrap"))
171-
expect(sandbox_class).to receive(:ohai).with("Installing Bubblewrap...")
172-
expect(sandbox_class).to receive(:system)
173-
.with("sudo", "apt-get", "install", "--yes", "bubblewrap")
174-
.and_return(true)
175-
expect(sandbox_class).not_to receive(:ensure_sandbox_installed!)
210+
.and_return(nil, Pathname(HOMEBREW_PREFIX/"bin/bwrap"))
211+
expect(sandbox_class).to receive(:ensure_sandbox_installed!)
212+
.with(install_from_tests: true)
176213
expect(sandbox_class).to receive(:ohai).with("Configuring Bubblewrap...").ordered
177214
expect_sandbox_configuration_command(sandbox_class, "kernel.unprivileged_userns_clone=1", result: true)
178215
expect_sandbox_configuration_command(sandbox_class, "user.max_user_namespaces=28633", result: true)
179216
expect_sandbox_configuration_command(sandbox_class, "kernel.apparmor_restrict_unprivileged_userns=0",
180217
result: false)
181218

182-
with_env(GITHUB_ACTIONS: "true", ImageOS: "ubuntu24", RUNNER_ENVIRONMENT: "github-hosted") do
183-
sandbox_class.configure!
184-
end
219+
sandbox_class.configure!
185220
end
221+
end
186222

187-
it "installs Bubblewrap and configures Linux sandbox sysctls" do
223+
describe "::ensure_sandbox_installed!" do
224+
let(:sandbox_class) { Class.new(klass) }
225+
226+
around do |example|
227+
with_env(GITHUB_ACTIONS: nil, HOMEBREW_GITHUB_HOSTED_RUNNER: nil,
228+
HOMEBREW_INSTALLING_BUBBLEWRAP: nil, HOMEBREW_TESTS: nil) { example.run }
229+
end
230+
231+
before do
232+
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
233+
end
234+
235+
it "does nothing when Homebrew Bubblewrap is already available" do
236+
expect(sandbox_class).to receive(:bubblewrap_executable)
237+
.once
238+
.and_return(Pathname(HOMEBREW_PREFIX/"bin/bwrap"))
239+
expect(Formula).not_to receive(:[])
240+
expect(sandbox_class).not_to receive(:which)
241+
expect(sandbox_class).not_to receive(:system)
242+
243+
sandbox_class.ensure_sandbox_installed!
244+
end
245+
246+
it "does nothing when system Bubblewrap is already available" do
247+
expect(sandbox_class).to receive(:bubblewrap_executable)
248+
.once
249+
.and_return(Pathname("/usr/bin/bwrap"))
250+
expect(Formula).not_to receive(:[])
251+
expect(sandbox_class).not_to receive(:which)
252+
expect(sandbox_class).not_to receive(:system)
253+
254+
sandbox_class.ensure_sandbox_installed!
255+
end
256+
257+
it "installs Bubblewrap with Homebrew before trying apt-get on GitHub Actions" do
188258
expect(sandbox_class).to receive(:bubblewrap_executable)
189259
.twice
190260
.and_return(nil, Pathname(HOMEBREW_PREFIX/"bin/bwrap"))
191-
expect(sandbox_class).to receive(:ensure_sandbox_installed!)
192-
.with(install_from_tests: true)
193-
expect(sandbox_class).to receive(:ohai).with("Configuring Bubblewrap...").ordered
194-
expect_sandbox_configuration_command(sandbox_class, "kernel.unprivileged_userns_clone=1", result: true)
195-
expect_sandbox_configuration_command(sandbox_class, "user.max_user_namespaces=28633", result: true)
196-
expect_sandbox_configuration_command(sandbox_class, "kernel.apparmor_restrict_unprivileged_userns=0",
197-
result: false)
261+
expect(Formula).to receive(:[]).with("bubblewrap")
262+
.and_return(instance_double(Formula, ensure_installed!: nil))
263+
expect(sandbox_class).not_to receive(:which)
264+
expect(sandbox_class).not_to receive(:system)
198265

199-
sandbox_class.configure!
266+
with_env(GITHUB_ACTIONS: "true", HOMEBREW_GITHUB_HOSTED_RUNNER: "1") do
267+
sandbox_class.ensure_sandbox_installed!
268+
end
269+
end
270+
271+
it "falls back to sudo apt-get on GitHub Actions Ubuntu when Homebrew Bubblewrap is unavailable" do
272+
expect(sandbox_class).to receive(:bubblewrap_executable)
273+
.twice
274+
.and_return(nil)
275+
expect(Formula).to receive(:[]).with("bubblewrap")
276+
.and_return(instance_double(Formula, ensure_installed!: nil))
277+
expect(sandbox_class).to receive(:which).with("apt-get").and_return(Pathname("/usr/bin/apt-get"))
278+
expect(Process).to receive(:euid).and_return(1000)
279+
expect(sandbox_class).to receive(:ohai).with("Installing Bubblewrap...")
280+
expect(sandbox_class).to receive(:system)
281+
.with("sudo", "apt-get", "install", "--yes", "bubblewrap")
282+
.and_return(true)
283+
284+
with_env(GITHUB_ACTIONS: "true", HOMEBREW_GITHUB_HOSTED_RUNNER: "1") do
285+
sandbox_class.ensure_sandbox_installed!
286+
end
287+
end
288+
289+
it "falls back to apt-get as root on GitHub Actions Ubuntu when Homebrew Bubblewrap is unavailable" do
290+
expect(sandbox_class).to receive(:bubblewrap_executable)
291+
.twice
292+
.and_return(nil)
293+
expect(Formula).to receive(:[]).with("bubblewrap")
294+
.and_return(instance_double(Formula, ensure_installed!: nil))
295+
expect(sandbox_class).to receive(:which).with("apt-get").and_return(Pathname("/usr/bin/apt-get"))
296+
expect(Process).to receive(:euid).and_return(0)
297+
expect(sandbox_class).to receive(:ohai).with("Installing Bubblewrap...")
298+
expect(sandbox_class).to receive(:system)
299+
.with("apt-get", "install", "--yes", "bubblewrap")
300+
.and_return(true)
301+
302+
with_env(GITHUB_ACTIONS: "true", HOMEBREW_GITHUB_HOSTED_RUNNER: "1") do
303+
sandbox_class.ensure_sandbox_installed!
304+
end
305+
end
306+
307+
it "does not fall back to apt-get outside GitHub Actions Ubuntu" do
308+
expect(sandbox_class).to receive(:bubblewrap_executable)
309+
.twice
310+
.and_return(nil, nil)
311+
expect(Formula).to receive(:[]).with("bubblewrap")
312+
.and_return(instance_double(Formula, ensure_installed!: nil))
313+
expect(sandbox_class).not_to receive(:which)
314+
expect(sandbox_class).not_to receive(:system)
315+
316+
with_env(GITHUB_ACTIONS: "true") do
317+
sandbox_class.ensure_sandbox_installed!
318+
end
319+
end
320+
321+
it "does not fall back to apt-get outside GitHub Actions" do
322+
expect(sandbox_class).to receive(:bubblewrap_executable)
323+
.twice
324+
.and_return(nil, nil)
325+
expect(Formula).to receive(:[]).with("bubblewrap")
326+
.and_return(instance_double(Formula, ensure_installed!: nil))
327+
expect(sandbox_class).not_to receive(:which)
328+
expect(sandbox_class).not_to receive(:system)
329+
330+
sandbox_class.ensure_sandbox_installed!
200331
end
201332
end
202333

Library/Homebrew/test/test_bot_spec.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
1818
end
1919

20+
it "enables the Linux sandbox for GitHub Actions developers" do
21+
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_call_original
22+
expect(klass).to receive(:configure_sandbox!).and_return(true)
23+
24+
with_env(HOMEBREW_DEVELOPER: "1", HOMEBREW_SANDBOX_LINUX: nil) do
25+
klass.setup_github_actions_sandbox!
26+
end
27+
end
28+
2029
it "configures the Linux sandbox for GitHub Actions" do
2130
expect(klass).to receive(:configure_sandbox!).and_return(true)
2231

Library/Homebrew/test_bot.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def local?(args)
3838
sig { void }
3939
def setup_github_actions_sandbox!
4040
return unless GitHub::Actions.env_set?
41+
42+
# TODO: odeprecated: force Linux sandbox support on when using `test-bot`.
43+
ENV["HOMEBREW_SANDBOX_LINUX"] = "1" if ENV["HOMEBREW_DEVELOPER"].present? &&
44+
ENV["HOMEBREW_SANDBOX_LINUX"].blank?
4145
return unless Homebrew::EnvConfig.sandbox_linux?
4246

4347
return if configure_sandbox!

0 commit comments

Comments
 (0)