Skip to content

Commit dfdf12d

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. - Auto-install `bubblewrap` from `homebrew/core` when the sandbox is enabled and no system or brewed binary is found. - Prefer a system `bwrap` (from `ORIGINAL_PATHS`) over a brewed one so distribution-provided binaries are used when available. - Install `bubblewrap` in Linux CI and Docker images so test-bot exercises the sandbox path.
1 parent e7f90cb commit dfdf12d

7 files changed

Lines changed: 348 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,14 @@ jobs:
278278
workflow-key: tests-tests-macos
279279
uninstall: true
280280

281+
- name: Install brew tests Linux sandbox dependency
282+
if: matrix.name == 'tests (Linux)'
283+
uses: Homebrew/actions/cache-homebrew-prefix@main
284+
with:
285+
install: bubblewrap
286+
workflow-key: tests-tests-linux-sandbox
287+
uninstall: true
288+
281289
# brew tests doesn't like world writable directories
282290
- name: Cleanup permissions
283291
if: runner.os == 'Linux'
@@ -290,6 +298,7 @@ jobs:
290298
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
291299
# These cannot be queried at the macOS level on GitHub Actions.
292300
HOMEBREW_LANGUAGES: en-GB
301+
HOMEBREW_SANDBOX_LINUX: ${{ matrix.name == 'tests (Linux)' && '1' || '' }}
293302
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
294303

295304
- name: Get RSpec JUnit XML filenames
@@ -339,6 +348,7 @@ jobs:
339348
- name: test-bot (macOS arm64)
340349
runs-on: macos-26
341350
env:
351+
HOMEBREW_SANDBOX_LINUX: ${{ startsWith(matrix.name, 'test-bot (Linux') && '1' || '' }}
342352
HOMEBREW_TEST_BOT_ANALYTICS: 1
343353
steps:
344354
- name: Install Homebrew and Homebrew's dependencies
@@ -349,6 +359,7 @@ jobs:
349359
# Slimmed down version from the Homebrew Dockerfile
350360
apt-get update
351361
apt-get install -y --no-install-recommends \
362+
bubblewrap \
352363
bzip2 \
353364
ca-certificates \
354365
curl \
@@ -387,6 +398,14 @@ jobs:
387398
workflow-key: test-bot
388399
uninstall: true
389400

401+
- name: Install Linux sandbox dependency
402+
if: runner.os == 'Linux'
403+
uses: Homebrew/actions/cache-homebrew-prefix@main
404+
with:
405+
install: bubblewrap
406+
workflow-key: test-bot-linux-sandbox
407+
uninstall: true
408+
390409
- name: Setup environment variables
391410
run: |
392411
# Set environment variables to bypass `brew doctor` failures on Tier >=2 configurations

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/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 a Linux sandbox for formula installation and testing.",
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. " \
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "fileutils"
5+
require "env_config"
6+
7+
module OS
8+
module Linux
9+
module Sandbox
10+
extend T::Helpers
11+
12+
requires_ancestor { ::Sandbox }
13+
14+
BUBBLEWRAP = "bwrap"
15+
TIOCSCTTY = 0x540E
16+
READ_ONLY_PATHS = T.let(%w[
17+
/bin
18+
/etc
19+
/lib
20+
/lib64
21+
/opt
22+
/sbin
23+
/usr
24+
].freeze, T::Array[String])
25+
private_constant :BUBBLEWRAP, :TIOCSCTTY, :READ_ONLY_PATHS
26+
27+
sig { void }
28+
def allow_write_temp_and_cache
29+
allow_write_path "/tmp"
30+
allow_write_path "/var/tmp"
31+
allow_write_path HOMEBREW_TEMP
32+
allow_write_path HOMEBREW_CACHE
33+
end
34+
35+
sig { void }
36+
def allow_cvs
37+
cvspass = ::Pathname.new("#{Dir.home(ENV.fetch("USER"))}/.cvspass")
38+
allow_write path: cvspass, type: :literal if cvspass.exist?
39+
end
40+
41+
sig { void }
42+
def allow_fossil
43+
[".fossil", ".fossil-journal"].each do |file|
44+
fossil_file = ::Pathname.new("#{Dir.home(ENV.fetch("USER"))}/#{file}")
45+
allow_write path: fossil_file, type: :literal if fossil_file.exist?
46+
end
47+
end
48+
49+
module ClassMethods
50+
extend T::Helpers
51+
52+
requires_ancestor { T.class_of(::Sandbox) }
53+
54+
sig { returns(T.nilable(::Pathname)) }
55+
def bubblewrap_executable
56+
which(BUBBLEWRAP, ORIGINAL_PATHS) || which(BUBBLEWRAP)
57+
end
58+
59+
sig { void }
60+
def ensure_bubblewrap_installed!
61+
return if bubblewrap_executable
62+
return unless CoreTap.instance.installed?
63+
64+
require "formula"
65+
::Formula["bubblewrap"].ensure_installed!(reason: "Linux sandboxing")
66+
rescue FormulaUnavailableError
67+
nil
68+
end
69+
70+
sig { returns(T::Boolean) }
71+
def available?
72+
return false unless Homebrew::EnvConfig.sandbox_linux?
73+
74+
ensure_bubblewrap_installed!
75+
76+
return false unless (bubblewrap = bubblewrap_executable)
77+
return false if bubblewrap.stat.setuid?
78+
79+
system(
80+
bubblewrap.to_s,
81+
"--unshare-user",
82+
"--unshare-ipc",
83+
"--unshare-pid",
84+
"--unshare-uts",
85+
"--unshare-cgroup-try",
86+
"--ro-bind", "/", "/",
87+
"--proc", "/proc",
88+
"--dev", "/dev",
89+
"true",
90+
out: File::NULL,
91+
err: File::NULL
92+
) == true
93+
end
94+
95+
sig { returns(Integer) }
96+
def terminal_ioctl_request
97+
TIOCSCTTY
98+
end
99+
end
100+
101+
private
102+
103+
sig { params(args: T::Array[T.any(String, ::Pathname)], tmpdir: String).returns(T::Array[T.any(String, ::Pathname)]) }
104+
def sandbox_command(args, tmpdir)
105+
bubblewrap = T.must(which(BUBBLEWRAP, ORIGINAL_PATHS) || which(BUBBLEWRAP))
106+
[bubblewrap, *bubblewrap_args(tmpdir), "--", *args]
107+
end
108+
109+
sig { params(_tmpdir: String).returns(T::Array[String]) }
110+
def bubblewrap_args(_tmpdir)
111+
args = T.let([
112+
"--unshare-user",
113+
"--unshare-ipc",
114+
"--unshare-pid",
115+
"--unshare-uts",
116+
"--unshare-cgroup-try",
117+
"--die-with-parent",
118+
"--new-session",
119+
"--dev", "/dev",
120+
"--proc", "/proc",
121+
"--dir", "/var",
122+
"--chdir", "/"
123+
], T::Array[String])
124+
args << "--unshare-net" if deny_all_network?
125+
126+
read_only_paths.each do |path|
127+
args += ["--ro-bind", path, path]
128+
end
129+
130+
writable_paths.each do |path, type|
131+
prepare_writable_path(path, type)
132+
args += ["--bind", path, path]
133+
end
134+
135+
denied_write_paths.each do |path|
136+
next unless File.exist?(path)
137+
138+
args += ["--ro-bind", path, path]
139+
end
140+
141+
args
142+
end
143+
144+
sig { returns(T::Boolean) }
145+
def deny_all_network?
146+
profile.rules.any? do |rule|
147+
!rule.allow && rule.operation == "network*" && rule.filter.nil?
148+
end
149+
end
150+
151+
sig { returns(T::Array[String]) }
152+
def read_only_paths
153+
(READ_ONLY_PATHS + [HOMEBREW_PREFIX.to_s, HOMEBREW_REPOSITORY.to_s])
154+
.select { |path| File.exist?(path) }
155+
.uniq
156+
end
157+
158+
sig { returns(T::Hash[String, Symbol]) }
159+
def writable_paths
160+
profile.rules.each_with_object({}) do |rule, paths|
161+
next if !rule.allow || !rule.operation.start_with?("file-write")
162+
next unless (filter = rule.filter)
163+
164+
case filter.type
165+
when :literal, :subpath
166+
paths[filter.path] ||= filter.type
167+
when :regex
168+
raise ArgumentError, "Linux sandbox does not support regex path filters: #{filter.path}"
169+
else
170+
raise ArgumentError, "Invalid path filter type: #{filter.type}"
171+
end
172+
end
173+
end
174+
175+
sig { returns(T::Array[String]) }
176+
def denied_write_paths
177+
profile.rules.filter_map do |rule|
178+
next if rule.allow || !rule.operation.start_with?("file-write")
179+
180+
filter = rule.filter
181+
filter.path if filter && [:literal, :subpath].include?(filter.type)
182+
end.uniq
183+
end
184+
185+
sig { params(path: String, type: Symbol).void }
186+
def prepare_writable_path(path, type)
187+
pathname = ::Pathname.new(path)
188+
return if pathname.exist?
189+
190+
if type == :literal
191+
FileUtils.mkdir_p(pathname.dirname)
192+
FileUtils.touch(pathname)
193+
else
194+
FileUtils.mkdir_p(pathname)
195+
end
196+
end
197+
end
198+
end
199+
end
200+
201+
Sandbox.prepend(OS::Linux::Sandbox)
202+
Sandbox.singleton_class.prepend(OS::Linux::Sandbox::ClassMethods)

Library/Homebrew/extend/os/sandbox.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
# frozen_string_literal: true
33

44
require "extend/os/mac/sandbox" if OS.mac?
5+
require "extend/os/linux/sandbox" if OS.linux?

Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)