Skip to content

Commit ca8620d

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 ca8620d

7 files changed

Lines changed: 336 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ jobs:
290290
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
291291
# These cannot be queried at the macOS level on GitHub Actions.
292292
HOMEBREW_LANGUAGES: en-GB
293+
HOMEBREW_SANDBOX_LINUX: ${{ matrix.name == 'tests (Linux)' && '1' || '' }}
293294
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
294295

295296
- name: Get RSpec JUnit XML filenames
@@ -339,6 +340,7 @@ jobs:
339340
- name: test-bot (macOS arm64)
340341
runs-on: macos-26
341342
env:
343+
HOMEBREW_SANDBOX_LINUX: ${{ startsWith(matrix.name, 'test-bot (Linux') && '1' || '' }}
342344
HOMEBREW_TEST_BOT_ANALYTICS: 1
343345
steps:
344346
- name: Install Homebrew and Homebrew's dependencies
@@ -349,6 +351,7 @@ jobs:
349351
# Slimmed down version from the Homebrew Dockerfile
350352
apt-get update
351353
apt-get install -y --no-install-recommends \
354+
bubblewrap \
352355
bzip2 \
353356
ca-certificates \
354357
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/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: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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 ENV["HOMEBREW_INSTALLING_BUBBLEWRAP"]
62+
return if bubblewrap_executable
63+
return unless CoreTap.instance.installed?
64+
65+
require "formula"
66+
with_env(HOMEBREW_INSTALLING_BUBBLEWRAP: "1") do
67+
::Formula["bubblewrap"].ensure_installed!(reason: "Linux sandboxing")
68+
end
69+
rescue FormulaUnavailableError
70+
nil
71+
end
72+
73+
sig { returns(T::Boolean) }
74+
def available?
75+
return false unless Homebrew::EnvConfig.sandbox_linux?
76+
77+
ensure_bubblewrap_installed!
78+
79+
return false unless (bubblewrap = bubblewrap_executable)
80+
return false if bubblewrap.stat.setuid?
81+
82+
system(
83+
bubblewrap.to_s,
84+
"--unshare-user",
85+
"--unshare-ipc",
86+
"--unshare-pid",
87+
"--unshare-uts",
88+
"--unshare-cgroup-try",
89+
"--ro-bind", "/", "/",
90+
"--proc", "/proc",
91+
"--dev", "/dev",
92+
"true",
93+
out: File::NULL,
94+
err: File::NULL
95+
) == true
96+
end
97+
98+
sig { returns(Integer) }
99+
def terminal_ioctl_request
100+
TIOCSCTTY
101+
end
102+
end
103+
104+
private
105+
106+
sig { params(args: T::Array[T.any(String, ::Pathname)], tmpdir: String).returns(T::Array[T.any(String, ::Pathname)]) }
107+
def sandbox_command(args, tmpdir)
108+
bubblewrap = T.must(which(BUBBLEWRAP, ORIGINAL_PATHS) || which(BUBBLEWRAP))
109+
[bubblewrap, *bubblewrap_args(tmpdir), "--", *args]
110+
end
111+
112+
sig { params(_tmpdir: String).returns(T::Array[String]) }
113+
def bubblewrap_args(_tmpdir)
114+
args = T.let([
115+
"--unshare-user",
116+
"--unshare-ipc",
117+
"--unshare-pid",
118+
"--unshare-uts",
119+
"--unshare-cgroup-try",
120+
"--die-with-parent",
121+
"--new-session",
122+
"--dev", "/dev",
123+
"--proc", "/proc",
124+
"--dir", "/var",
125+
"--chdir", "/"
126+
], T::Array[String])
127+
args << "--unshare-net" if deny_all_network?
128+
129+
read_only_paths.each do |path|
130+
args += ["--ro-bind", path, path]
131+
end
132+
133+
writable_paths.each do |path, type|
134+
prepare_writable_path(path, type)
135+
args += ["--bind", path, path]
136+
end
137+
138+
denied_write_paths.each do |path|
139+
next unless File.exist?(path)
140+
141+
args += ["--ro-bind", path, path]
142+
end
143+
144+
args
145+
end
146+
147+
sig { returns(T::Boolean) }
148+
def deny_all_network?
149+
profile.rules.any? do |rule|
150+
!rule.allow && rule.operation == "network*" && rule.filter.nil?
151+
end
152+
end
153+
154+
sig { returns(T::Array[String]) }
155+
def read_only_paths
156+
(READ_ONLY_PATHS + [HOMEBREW_PREFIX.to_s, HOMEBREW_REPOSITORY.to_s])
157+
.select { |path| File.exist?(path) }
158+
.uniq
159+
end
160+
161+
sig { returns(T::Hash[String, Symbol]) }
162+
def writable_paths
163+
profile.rules.each_with_object({}) do |rule, paths|
164+
next if !rule.allow || !rule.operation.start_with?("file-write")
165+
next unless (filter = rule.filter)
166+
167+
case filter.type
168+
when :literal, :subpath
169+
paths[filter.path] ||= filter.type
170+
when :regex
171+
raise ArgumentError, "Linux sandbox does not support regex path filters: #{filter.path}"
172+
else
173+
raise ArgumentError, "Invalid path filter type: #{filter.type}"
174+
end
175+
end
176+
end
177+
178+
sig { returns(T::Array[String]) }
179+
def denied_write_paths
180+
profile.rules.filter_map do |rule|
181+
next if rule.allow || !rule.operation.start_with?("file-write")
182+
183+
filter = rule.filter
184+
filter.path if filter && [:literal, :subpath].include?(filter.type)
185+
end.uniq
186+
end
187+
188+
sig { params(path: String, type: Symbol).void }
189+
def prepare_writable_path(path, type)
190+
pathname = ::Pathname.new(path)
191+
return if pathname.exist?
192+
193+
if type == :literal
194+
FileUtils.mkdir_p(pathname.dirname)
195+
FileUtils.touch(pathname)
196+
else
197+
FileUtils.mkdir_p(pathname)
198+
end
199+
end
200+
end
201+
end
202+
end
203+
204+
Sandbox.prepend(OS::Linux::Sandbox)
205+
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.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# typed: false
2+
# frozen_string_literal: true
3+
4+
require "sandbox"
5+
6+
RSpec.describe Sandbox, :needs_linux do
7+
subject(:sandbox) { described_class.new }
8+
9+
around do |example|
10+
with_env(HOMEBREW_SANDBOX_LINUX: "1") { example.run }
11+
end
12+
13+
describe "::available?" do
14+
let(:bubblewrap) { mktmpdir/"bwrap" }
15+
16+
before do
17+
FileUtils.touch bubblewrap
18+
FileUtils.chmod "+x", bubblewrap
19+
allow(described_class).to receive(:bubblewrap_executable).and_return(bubblewrap)
20+
allow(described_class).to receive(:ensure_bubblewrap_installed!)
21+
end
22+
23+
it "returns false unless Linux sandboxing is enabled" do
24+
with_env(HOMEBREW_SANDBOX_LINUX: nil) do
25+
expect(described_class).not_to receive(:system)
26+
27+
expect(described_class.available?).to be(false)
28+
end
29+
end
30+
31+
it "returns false when bubblewrap is unavailable" do
32+
allow(described_class).to receive(:bubblewrap_executable).and_return(nil)
33+
34+
expect(described_class.available?).to be(false)
35+
end
36+
37+
it "probes unprivileged namespace support" do
38+
expect(described_class).to receive(:system).with(
39+
bubblewrap.to_s,
40+
"--unshare-user",
41+
"--unshare-ipc",
42+
"--unshare-pid",
43+
"--unshare-uts",
44+
"--unshare-cgroup-try",
45+
"--ro-bind", "/", "/",
46+
"--proc", "/proc",
47+
"--dev", "/dev",
48+
"true",
49+
out: File::NULL,
50+
err: File::NULL
51+
).and_return(true)
52+
53+
expect(described_class.available?).to be(true)
54+
end
55+
end
56+
57+
describe "#bubblewrap_args" do
58+
let(:dir) { mktmpdir }
59+
let(:denied_dir) { mktmpdir }
60+
let(:args) { sandbox.send(:bubblewrap_args, mktmpdir.to_s) }
61+
62+
it "maps allowed and denied writes to bind mounts" do
63+
sandbox.allow_write_path dir
64+
sandbox.deny_write_path denied_dir
65+
sandbox.deny_all_network
66+
67+
expect(args).to include("--unshare-user", "--unshare-ipc", "--unshare-pid", "--unshare-net", "--new-session")
68+
expect(args.each_cons(3)).to include(["--bind", dir.to_s, dir.to_s])
69+
expect(args.each_cons(3)).to include(["--ro-bind", denied_dir.to_s, denied_dir.to_s])
70+
end
71+
72+
it "uses Linux temp paths instead of macOS temp paths" do
73+
sandbox.allow_write_temp_and_cache
74+
75+
expect(args).to include("/tmp", "/var/tmp", HOMEBREW_TEMP.to_s, HOMEBREW_CACHE.to_s)
76+
expect(args).not_to include("/private/tmp", "/private/var/tmp")
77+
end
78+
79+
it "does not add Xcode write paths" do
80+
sandbox.allow_write_xcode
81+
82+
expect(sandbox.send(:writable_paths)).to be_empty
83+
end
84+
85+
it "rejects regex path filters" do
86+
sandbox.allow_write path: "^/tmp/homebrew-[^/]+$", type: :regex
87+
88+
expect { args }.to raise_error(ArgumentError, /Linux sandbox does not support regex path filters/)
89+
end
90+
end
91+
92+
describe "#run" do
93+
before do
94+
skip "Sandbox not implemented." unless described_class.available?
95+
end
96+
97+
it "allows writing to an allowed path" do
98+
file = mktmpdir/"foo"
99+
sandbox.allow_write path: file
100+
sandbox.run "touch", file
101+
102+
expect(file).to exist
103+
end
104+
105+
it "fails when writing to a path that has not been allowed" do
106+
file = mktmpdir/"foo"
107+
108+
expect do
109+
sandbox.run "touch", file
110+
end.to raise_error(ErrorDuringExecution)
111+
112+
expect(file).not_to exist
113+
end
114+
115+
it "returns the command exit status" do
116+
expect { sandbox.run "false" }.to raise_error(ErrorDuringExecution)
117+
end
118+
end
119+
end

0 commit comments

Comments
 (0)