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` on GitHub Actions and install the
  system `bubblewrap` package before Linux tests run.
This commit is contained in:
Mike McQuaid
2026-05-16 16:27:04 +01:00
parent b5fdb50483
commit 936f0b2a02
18 changed files with 889 additions and 54 deletions
+18
View File
@@ -274,6 +274,21 @@ jobs:
workflow-key: tests-tests-macos
uninstall: true
- name: Install brew tests Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends bubblewrap
# Allow unprivileged user namespace cloning; rootless `bwrap` needs this
# to create its user namespace.
sudo sysctl -w kernel.unprivileged_userns_clone=1
# Ensure the runner can allocate user namespaces instead of hitting a
# per-user namespace limit.
sudo sysctl -w user.max_user_namespaces=28633
# Ubuntu runners may additionally restrict unprivileged user namespaces
# through AppArmor; older kernels may not expose this sysctl.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
# brew tests doesn't like world writable directories
- name: Cleanup permissions
if: runner.os == 'Linux'
@@ -286,6 +301,7 @@ jobs:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# These cannot be queried at the macOS level on GitHub Actions.
HOMEBREW_LANGUAGES: en-GB
HOMEBREW_SANDBOX_LINUX: 1
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Get RSpec JUnit XML filenames
@@ -335,6 +351,7 @@ jobs:
- name: test-bot (macOS arm64)
runs-on: macos-26
env:
HOMEBREW_SANDBOX_LINUX: 1
HOMEBREW_TEST_BOT_ANALYTICS: 1
steps:
- name: Install Homebrew and Homebrew's dependencies
@@ -345,6 +362,7 @@ jobs:
# Slimmed down version from the Homebrew Dockerfile
apt-get update
apt-get install -y --no-install-recommends \
bubblewrap \
bzip2 \
ca-certificates \
curl \
+1
View File
@@ -29,6 +29,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& retry apt-get update --error-on=any \
&& apt-get install -y --no-install-recommends \
acl \
bubblewrap \
bzip2 \
ca-certificates \
curl \
+5 -1
View File
@@ -260,6 +260,11 @@ begin
# We need to allow formulae from paths here due to how we pass them through.
ENV["HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS"] = "1"
formula_path = ARGV.first
# `build.rb` is handed a concrete formula file; keep reparsing from falling
# back to the API inside the Linux sandbox.
ENV["HOMEBREW_NO_INSTALL_FROM_API"] = "1" if formula_path&.end_with?(".rb")
args = Homebrew::Cmd::InstallCmd.new.args
Context.current = args.context
@@ -268,7 +273,6 @@ begin
trap("INT", old_trap)
formula_path = ARGV.first
if formula_path&.end_with?(".json")
raise "build.rb received an API JSON file as the formula path: #{formula_path}. " \
"This usually means the formula source was not downloaded from the API. " \
+5 -3
View File
@@ -37,6 +37,7 @@ module Homebrew
require "formula_free_port"
require "utils/fork"
optional_prefix_var_dirs = %w[var/cache var/log var/run]
args.named.to_resolved_formulae.each do |f|
# Cannot test uninstalled formulae
unless f.latest_version_installed?
@@ -81,6 +82,7 @@ module Homebrew
exec_args << "--HEAD" if f.head?
Sandbox.ensure_sandbox_installed!
if Sandbox.available?
sandbox = Sandbox.new
f.logs.mkpath
@@ -88,10 +90,10 @@ module Homebrew
sandbox.allow_write_temp_and_cache
sandbox.allow_write_log(f)
sandbox.allow_write_xcode
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/cache")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
optional_prefix_var_dirs.each do |dir|
sandbox.allow_write_path_if_exists HOMEBREW_PREFIX/dir
end
sandbox.deny_all_network unless f.class.network_access_allowed?(:test)
sandbox.run(*exec_args)
else
+6
View File
@@ -66,6 +66,8 @@ module Homebrew
# `return false`.
require "extend/os/dev-cmd/tests"
check_test_environment!
parallel = !args.no_parallel?
only = args.only
@@ -210,6 +212,9 @@ module Homebrew
bundle_args << "--tag" << "~needs_linux" << "--tag" << "~needs_systemd"
end
sig { void }
def check_test_environment!; end
sig { params(files: T::Array[String]).returns(T::Array[String]) }
def os_files(files)
# for generic tests, remove macOS or Linux specific files
@@ -256,6 +261,7 @@ module Homebrew
HOMEBREW_GITHUB_API_TOKEN
HOMEBREW_CACHE
HOMEBREW_LOGS
HOMEBREW_SANDBOX_LINUX
HOMEBREW_TEMP
]
allowed_test_env << "HOMEBREW_USE_RUBY_FROM_PATH" if Homebrew::EnvConfig.developer?
+4
View File
@@ -486,6 +486,10 @@ module Homebrew
description: "If set, use Pry for the `brew irb` command.",
boolean: true,
},
HOMEBREW_SANDBOX_LINUX: {
description: "If set, use the `bwrap`(1) sandbox for formula installation and testing on Linux.",
boolean: true,
},
HOMEBREW_SBOM: {
# odeprecated: edit in 5.2.0
description: "If set, Homebrew will write SBOM files and run SBOM-related installation logic. " \
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true
require "utils/github/actions"
module OS
module Linux
module DevCmd
@@ -20,6 +22,19 @@ module OS
def os_files(files)
non_macos_files(files)
end
sig { void }
def check_test_environment!
super
return unless GitHub::Actions.env_set?
require "sandbox"
with_env(HOMEBREW_SANDBOX_LINUX: "1") do
return if ::Sandbox.available?
raise UsageError, "GitHub Actions Linux tests require a working rootless Bubblewrap sandbox."
end
end
end
end
end
+306
View File
@@ -0,0 +1,306 @@
# typed: strict
# frozen_string_literal: true
require "fileutils"
require "env_config"
module OS
module Linux
module Sandbox
extend T::Helpers
requires_ancestor { ::Sandbox }
BUBBLEWRAP = "bwrap"
SYSTEM_BUBBLEWRAP_PATHS = T.let(%w[
/usr/bin
/bin
].freeze, T::Array[String])
# `TIOCSCTTY` from `<asm-generic/ioctls.h>`; Ruby does not expose it.
TIOCSCTTY = 0x540E
READ_ONLY_PATHS = T.let(%w[
/bin
/etc
/lib
/lib64
/opt
/run
/sbin
/sys
/usr
].freeze, T::Array[String])
private_constant :BUBBLEWRAP, :SYSTEM_BUBBLEWRAP_PATHS, :TIOCSCTTY, :READ_ONLY_PATHS
sig { returns(::PATH) }
def self.bubblewrap_candidate_paths
::Sandbox.executable_candidate_paths
end
sig { returns(T.nilable(::Pathname)) }
def self.bubblewrap_executable
::Sandbox.executable
end
sig { returns(::Pathname) }
def self.bubblewrap_executable!
bubblewrap_executable || raise("Bubblewrap is required to use the Linux sandbox.")
end
sig { void }
def allow_write_temp_and_cache
allow_write_path "/tmp"
allow_write_path "/var/tmp"
allow_write_path HOMEBREW_TEMP
allow_write_path HOMEBREW_CACHE
end
sig { void }
def allow_cvs
cvspass = ::Pathname.new("#{Dir.home(ENV.fetch("USER"))}/.cvspass")
allow_write path: cvspass, type: :literal if cvspass.exist?
end
sig { void }
def allow_fossil
[".fossil", ".fossil-journal"].each do |file|
fossil_file = ::Pathname.new("#{Dir.home(ENV.fetch("USER"))}/#{file}")
allow_write path: fossil_file, type: :literal if fossil_file.exist?
end
end
module ClassMethods
extend T::Helpers
requires_ancestor { T.class_of(::Sandbox) }
sig { returns(String) }
def executable_name
BUBBLEWRAP
end
sig { params(candidate: ::Pathname).returns(T::Boolean) }
def executable_usable?(candidate)
!File.stat(candidate).setuid?
end
sig { returns(T::Array[String]) }
def system_bubblewrap_paths
SYSTEM_BUBBLEWRAP_PATHS
end
sig { returns(::PATH) }
def executable_candidate_paths
PATH.new(system_bubblewrap_paths, super)
end
sig { returns(::PATH) }
def bubblewrap_candidate_paths
executable_candidate_paths
end
sig { returns(T.nilable(::Pathname)) }
def bubblewrap_executable
executable
end
sig { returns(::Pathname) }
def bubblewrap_executable!
bubblewrap_executable || raise("Bubblewrap is required to use the Linux sandbox.")
end
sig { void }
def ensure_sandbox_installed!
return unless Homebrew::EnvConfig.sandbox_linux?
# Never trigger a real install during `brew tests`.
return if ENV["HOMEBREW_TESTS"]
return if ENV["HOMEBREW_INSTALLING_BUBBLEWRAP"]
return if bubblewrap_executable
require "tap"
return unless ::CoreTap.instance.installed?
require "exceptions"
require "formula"
with_env(HOMEBREW_INSTALLING_BUBBLEWRAP: "1") do
::Formula["bubblewrap"].ensure_installed!(reason: "Linux sandboxing")
end
rescue ::FormulaUnavailableError
nil
end
sig { returns(T::Boolean) }
def available?
return false unless Homebrew::EnvConfig.sandbox_linux?
return false unless (bubblewrap = executable)
system(
bubblewrap.to_s,
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true",
out: File::NULL,
err: File::NULL
) == true
end
# `ioctl` request used to attach the sandboxed child to a controlling TTY.
sig { returns(Integer) }
def terminal_ioctl_request
TIOCSCTTY
end
end
sig { params(args: T.any(String, ::Pathname)).void }
def run(*args)
@prepared_writable_paths = T.let([], T.nilable(T::Array[::Pathname]))
old_report_on_exception = T.let(Thread.report_on_exception, T.nilable(T::Boolean))
Thread.report_on_exception = false
super
ensure
Thread.report_on_exception = old_report_on_exception unless old_report_on_exception.nil?
@prepared_writable_paths&.reverse_each do |path|
path.rmdir if path.directory?
rescue Errno::ENOENT, Errno::ENOTEMPTY
nil
end
@prepared_writable_paths = nil
end
private
sig { params(args: T::Array[T.any(String, ::Pathname)], tmpdir: String).returns(T::Array[T.any(String, ::Pathname)]) }
def sandbox_command(args, tmpdir)
[::Sandbox.executable!, *bubblewrap_args(tmpdir), "--", *args]
end
sig { params(tmpdir: String).returns(T::Array[String]) }
def bubblewrap_args(tmpdir)
args = T.let([
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--die-with-parent",
"--new-session",
"--dev", "/dev",
"--proc", "/proc",
"--dir", "/var"
], T::Array[String])
args << "--unshare-net" if deny_all_network?
::Pathname.new(tmpdir).ascend.to_a.reverse_each do |path|
next if path.root?
args += ["--dir", path.to_s]
end
read_only_mounts = read_only_paths
read_only_parent_paths = read_only_mounts.flat_map do |path|
::Pathname.new(path).ascend.to_a.reverse.filter_map do |parent|
parent.to_s if !parent.root? && parent.to_s != path
end
end.uniq
read_only_parent_paths.each do |path|
args += ["--dir", path]
end
read_only_mounts.each do |path|
args += ["--ro-bind", path, path]
end
writable_paths.each do |path, type|
prepare_writable_path(path, type)
args += ["--bind", path, path]
end
denied_write_paths.each do |path|
next unless File.exist?(path)
args += ["--ro-bind", path, path]
end
args += ["--bind", tmpdir, tmpdir, "--chdir", tmpdir]
args
end
sig { returns(T::Boolean) }
def deny_all_network?
profile.rules.any? do |rule|
!rule.allow && rule.operation == "network*" && rule.filter.nil?
end
end
sig { returns(T::Array[String]) }
def read_only_paths
(READ_ONLY_PATHS + [HOMEBREW_PREFIX.to_s, HOMEBREW_REPOSITORY.to_s, HOMEBREW_LIBRARY_PATH.to_s] +
profile.rules.filter_map do |rule|
next if !rule.allow || !rule.operation.start_with?("file-read")
next unless (filter = rule.filter)
case filter.type
when :literal, :subpath
filter.path
when :regex
raise ArgumentError, "Linux sandbox does not support regex path filters: #{filter.path}"
else
raise ArgumentError, "Invalid path filter type: #{filter.type}"
end
end)
.select { |path| File.exist?(path) }
.uniq
end
sig { returns(T::Hash[String, Symbol]) }
def writable_paths
profile.rules.each_with_object({}) do |rule, paths|
next if !rule.allow || !rule.operation.start_with?("file-write")
next unless (filter = rule.filter)
case filter.type
when :literal, :subpath
paths[filter.path] ||= filter.type
when :regex
raise ArgumentError, "Linux sandbox does not support regex path filters: #{filter.path}"
else
raise ArgumentError, "Invalid path filter type: #{filter.type}"
end
end
end
sig { returns(T::Array[String]) }
def denied_write_paths
profile.rules.filter_map do |rule|
next if rule.allow || !rule.operation.start_with?("file-write")
filter = rule.filter
filter.path if filter && [:literal, :subpath].include?(filter.type)
end.uniq
end
sig { params(path: String, type: Symbol).void }
def prepare_writable_path(path, type)
pathname = ::Pathname.new(path)
return if pathname.exist?
if type == :literal
FileUtils.mkdir_p(pathname.dirname)
FileUtils.touch(pathname)
else
FileUtils.mkdir_p(pathname)
@prepared_writable_paths&.<< pathname
end
end
end
end
end
Sandbox.prepend(OS::Linux::Sandbox)
Sandbox.singleton_class.prepend(OS::Linux::Sandbox::ClassMethods)
+1
View File
@@ -2,3 +2,4 @@
# frozen_string_literal: true
require "extend/os/mac/sandbox" if OS.mac?
require "extend/os/linux/sandbox" if OS.linux?
+5 -1
View File
@@ -1107,16 +1107,19 @@ on_request: installed_on_request?, options:)
# 1. formulae can modify ENV, so we must ensure that each
# installation has a pristine ENV when it starts, forking now is
# the easiest way to do this
formula_path = formula.specified_path
args = [
"nice",
*HOMEBREW_RUBY_EXEC_ARGS,
"--",
HOMEBREW_LIBRARY_PATH/"build.rb",
formula.specified_path,
formula_path,
].concat(build_argv)
Sandbox.ensure_sandbox_installed!
if Sandbox.available?
sandbox = Sandbox.new
sandbox.allow_read_if_exists path: formula_path
formula.logs.mkpath
sandbox.record_log(formula.logs/"build.sandbox.log")
sandbox.allow_write_path(Dir.home) if interactive?
@@ -1357,6 +1360,7 @@ on_request: installed_on_request?, options:)
args << post_install_formula_path
Sandbox.ensure_sandbox_installed!
if Sandbox.available?
sandbox = Sandbox.new
formula.logs.mkpath
+65
View File
@@ -74,6 +74,50 @@ class Sandbox
false
end
sig { void }
def self.ensure_sandbox_installed!; end
sig { returns(String) }
def self.executable_name
raise NotImplementedError, "Sandbox is not implemented for this OS."
end
sig { returns(::PATH) }
def self.executable_candidate_paths
executable_path = Pathname.new(executable_name)
return PATH.new(executable_path.dirname) if executable_path.absolute?
PATH.new(ORIGINAL_PATHS, ENV.fetch("PATH"), HOMEBREW_ORIGINAL_BREW_FILE.dirname)
end
sig { returns(T.nilable(Pathname)) }
def self.executable
executable_candidate_paths.each do |path|
begin
candidate = Pathname.new(File.expand_path(executable_name, path))
rescue ArgumentError
next
end
next if !candidate.file? || !candidate.executable?
next unless executable_usable?(candidate)
return candidate
end
nil
end
sig { returns(Pathname) }
def self.executable!
executable || raise("#{executable_name} is required to use the sandbox.")
end
sig { params(_candidate: Pathname).returns(T::Boolean) }
def self.executable_usable?(_candidate)
true
end
sig { returns(Integer) }
def self.terminal_ioctl_request
raise NotImplementedError, "Sandbox is not implemented for this OS."
@@ -101,6 +145,19 @@ class Sandbox
@profile.add_rule(rule)
end
sig { params(path: T.any(String, Pathname), type: Symbol).void }
def allow_read(path:, type: :literal)
add_rule allow: true, operation: "file-read*", filter: path_filter(path, type)
end
sig { params(path: T.nilable(T.any(String, Pathname)), type: Symbol).void }
def allow_read_if_exists(path:, type: :literal)
return unless path
return unless File.exist?(path)
allow_read path:, type:
end
sig { params(path: T.any(String, Pathname), type: Symbol).void }
def allow_write(path:, type: :literal)
add_rule allow: true, operation: "file-write*", filter: path_filter(path, type)
@@ -118,6 +175,14 @@ class Sandbox
allow_write path:, type: :subpath
end
sig { params(path: T.nilable(T.any(String, Pathname))).void }
def allow_write_path_if_exists(path)
return unless path
return unless File.exist?(path)
allow_write_path path
end
sig { params(path: T.any(String, Pathname)).void }
def deny_write_path(path)
deny_write path:, type: :subpath
+3
View File
@@ -286,6 +286,9 @@ module Homebrew::EnvConfig
sig { returns(T::Boolean) }
def pry?; end
sig { returns(T::Boolean) }
def sandbox_linux?; end
sig { returns(T::Boolean) }
def sbom?; end
+39 -1
View File
@@ -1,4 +1,4 @@
# typed: strict
# typed: false
# frozen_string_literal: true
require "cmd/shared_examples/args_parse"
@@ -6,4 +6,42 @@ require "dev-cmd/tests"
RSpec.describe Homebrew::DevCmd::Tests do
it_behaves_like "parseable arguments"
describe "#check_test_environment!", :needs_linux do
subject(:tests) { described_class.new([]) }
before do
require "extend/os/linux/dev-cmd/tests"
require "sandbox"
end
it "does not require the Linux sandbox outside GitHub Actions" do
allow(Sandbox).to receive(:available?).and_return(false)
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
with_env(CI: "1", GITHUB_ACTIONS: nil) do
expect { tests.send(:check_test_environment!) }.not_to raise_error
end
end
it "raises when the Linux sandbox is unavailable in GitHub Actions" do
allow(Sandbox).to receive(:available?).and_return(false)
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
with_env(GITHUB_ACTIONS: "true") do
expect { tests.send(:check_test_environment!) }
.to raise_error(UsageError,
"Invalid usage: GitHub Actions Linux tests require a working rootless Bubblewrap sandbox.")
end
end
it "probes sandbox availability with Linux sandboxing enabled" do
allow(Sandbox).to receive(:available?) { ENV["HOMEBREW_SANDBOX_LINUX"] == "1" }
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
with_env(GITHUB_ACTIONS: "true", HOMEBREW_SANDBOX_LINUX: nil) do
expect { tests.send(:check_test_environment!) }.not_to raise_error
end
end
end
end
@@ -22,8 +22,12 @@ RSpec.describe FormulaInstaller do
installer = described_class.new(formula, **options)
installer.fetch
installer.install
# These fixture installs must stay local so Linux sandbox builds do not
# need API cache or source paths mounted.
with_env(HOMEBREW_NO_INSTALL_FROM_API: "1") do
installer.fetch
installer.install
end
keg = Keg.new(formula.prefix)
@@ -791,8 +795,12 @@ RSpec.describe FormulaInstaller do
it "shows audit problems if HOMEBREW_DEVELOPER is set" do
ENV["HOMEBREW_DEVELOPER"] = "1"
formula_installer.fetch
formula_installer.install
# Keep this fixture install local so Linux sandbox builds do not need API
# cache or source paths mounted.
with_env(HOMEBREW_NO_INSTALL_FROM_API: "1") do
formula_installer.fetch
formula_installer.install
end
expect(formula_installer).to receive(:audit_installed).and_call_original
formula_installer.caveats
end
@@ -912,5 +920,29 @@ RSpec.describe FormulaInstaller do
installer.build
end.to raise_error(CannotInstallFormulaError, /source code not found/)
end
it "exposes local formula paths to the sandbox" do
formula_path = mktmpdir/"homebrew-local-formula.rb"
FileUtils.touch formula_path
formula = formula("homebrew-local-formula", path: formula_path) do
url "foo"
version "1.0"
end
installer = described_class.new(formula)
sandbox = instance_double(Sandbox)
allow(installer).to receive(:build_argv).and_return([])
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true, new: sandbox)
allow(sandbox).to receive_messages(record_log: nil, allow_read_if_exists: nil, allow_write_temp_and_cache: nil,
allow_write_log: nil, allow_cvs: nil, allow_fossil: nil,
allow_write_xcode: nil, allow_write_cellar: nil, run: nil)
allow(formula).to receive_messages(logs: mktmpdir, update_head_version: nil, prefix: mktmpdir,
network_access_allowed?: true)
allow(Keg).to receive(:new).and_return(instance_double(Keg, empty_installation?: false))
expect(sandbox).to receive(:allow_read_if_exists).with(path: formula_path)
installer.build
end
end
end
+189
View File
@@ -0,0 +1,189 @@
# typed: false
# frozen_string_literal: true
require "sandbox"
require "extend/os/linux/sandbox" if OS.linux?
RSpec.describe Sandbox, :needs_linux do
subject(:sandbox) { described_class.new }
around do |example|
with_env(HOMEBREW_SANDBOX_LINUX: "1") { example.run }
end
describe "::bubblewrap_executable" do
let(:sandbox_class) do
Class.new(described_class) do
class << self
attr_accessor :test_executable_candidate_paths
def executable_candidate_paths = test_executable_candidate_paths
end
end
end
let(:setuid_dir) { mktmpdir }
let(:usable_dir) { mktmpdir }
let(:setuid_bubblewrap) { setuid_dir/"bwrap" }
let(:usable_bubblewrap) { usable_dir/"bwrap" }
before do
FileUtils.touch setuid_bubblewrap
FileUtils.chmod "+x", setuid_bubblewrap
FileUtils.touch usable_bubblewrap
FileUtils.chmod "+x", usable_bubblewrap
sandbox_class.test_executable_candidate_paths = PATH.new(setuid_dir, usable_dir)
allow(File).to receive(:stat).and_call_original
allow(File).to receive(:stat).with(setuid_bubblewrap).and_return(instance_double(File::Stat, setuid?: true))
end
it "skips setuid bubblewrap candidates" do
expect(sandbox_class.bubblewrap_executable).to eq(usable_bubblewrap)
end
it "raises when no suitable bubblewrap candidate exists" do
sandbox_class.test_executable_candidate_paths = PATH.new(mktmpdir)
expect { sandbox_class.bubblewrap_executable! }
.to raise_error(RuntimeError, "Bubblewrap is required to use the Linux sandbox.")
end
end
describe "::available?" do
let(:sandbox_class) do
Class.new(described_class) do
class << self
attr_accessor :test_executable
def executable = test_executable
end
end
end
let(:bubblewrap_dir) { mktmpdir }
let(:bubblewrap) { bubblewrap_dir/"bwrap" }
before do
FileUtils.touch bubblewrap
FileUtils.chmod "+x", bubblewrap
sandbox_class.test_executable = bubblewrap
end
it "returns false unless Linux sandboxing is enabled" do
with_env(HOMEBREW_SANDBOX_LINUX: nil) do
expect(sandbox_class.available?).to be(false)
end
end
it "returns false when bubblewrap is unavailable" do
sandbox_class.test_executable = nil
expect(sandbox_class.available?).to be(false)
end
it "probes unprivileged namespace support" do
expect(sandbox_class).to receive(:system).with(
bubblewrap.to_s,
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true",
out: File::NULL,
err: File::NULL
).and_return(true)
expect(sandbox_class.available?).to be(true)
end
end
describe "#bubblewrap_args" do
let(:dir) { mktmpdir }
let(:denied_dir) { mktmpdir }
let(:tmpdir) { mktmpdir }
let(:args) { sandbox.send(:bubblewrap_args, tmpdir.to_s) }
it "maps allowed and denied writes to bind mounts" do
sandbox.allow_write_path dir
sandbox.deny_write_path denied_dir
sandbox.deny_all_network
expect(args).to include("--unshare-user", "--unshare-ipc", "--unshare-pid", "--unshare-net", "--new-session")
expect(args.each_cons(3)).to include(["--bind", dir.to_s, dir.to_s])
expect(args.each_cons(3)).to include(["--ro-bind", denied_dir.to_s, denied_dir.to_s])
end
it "runs from the sandbox tmpdir" do
expect(args.each_cons(3)).to include(["--bind", tmpdir.to_s, tmpdir.to_s])
expect(args.each_cons(2)).to include(["--chdir", tmpdir.to_s])
end
it "exposes the Homebrew library path" do
expect(args.index(HOMEBREW_LIBRARY_PATH.dirname.to_s)).to be < args.index(HOMEBREW_LIBRARY_PATH.to_s)
expect(args.each_cons(3)).to include(["--ro-bind", HOMEBREW_LIBRARY_PATH.to_s, HOMEBREW_LIBRARY_PATH.to_s])
end
it "exposes Linux runtime paths" do
%w[/run /sys].select { |path| File.exist?(path) }.each do |path|
expect(args.each_cons(3)).to include(["--ro-bind", path, path])
end
end
it "maps allowed reads to read-only bind mounts" do
file = mktmpdir/"foo.rb"
FileUtils.touch file
sandbox.allow_read path: file
expect(args.each_cons(3)).to include(["--ro-bind", file.to_s, file.to_s])
end
it "uses Linux temp paths instead of macOS temp paths" do
sandbox.allow_write_temp_and_cache
expect(args).to include("/tmp", "/var/tmp", HOMEBREW_TEMP.to_s, HOMEBREW_CACHE.to_s)
expect(args).not_to include("/private/tmp", "/private/var/tmp")
end
it "does not add Xcode write paths" do
sandbox.allow_write_xcode
expect(sandbox.send(:writable_paths)).to be_empty
end
it "rejects regex path filters" do
sandbox.allow_write path: "^/tmp/homebrew-[^/]+$", type: :regex
expect { args }.to raise_error(ArgumentError, /Linux sandbox does not support regex path filters/)
end
end
describe "#run" do
before do
skip "Sandbox not implemented." if !ENV["CI"] && !described_class.available?
end
it "allows writing to an allowed path" do
file = mktmpdir/"foo"
sandbox.allow_write path: file
sandbox.run "touch", file
expect(file).to exist
end
it "fails when writing to a path that has not been allowed" do
file = mktmpdir/"foo"
expect do
sandbox.run "touch", file
end.to raise_error(ErrorDuringExecution)
expect(file).not_to exist
end
it "returns the command exit status" do
expect { sandbox.run "false" }.to raise_error(ErrorDuringExecution)
end
end
end
@@ -0,0 +1,186 @@
# typed: false
# frozen_string_literal: true
require "sandbox"
RSpec.describe Sandbox do
subject(:sandbox) { described_class.new }
describe "::executable" do
let(:sandbox_class) do
Class.new(described_class) do
class << self
attr_accessor :test_executable_name, :unsuitable_executables
def executable_name = test_executable_name
def executable_usable?(candidate)
unsuitable_executables.exclude?(candidate)
end
end
end
end
let(:first_dir) { mktmpdir }
let(:second_dir) { mktmpdir }
let(:homebrew_bin) { mktmpdir }
let(:executable_name) { "sandbox-tool" }
let(:first_executable) { first_dir/executable_name }
let(:second_executable) { second_dir/executable_name }
let(:homebrew_executable) { homebrew_bin/executable_name }
before do
sandbox_class.test_executable_name = executable_name
sandbox_class.unsuitable_executables = []
stub_const("HOMEBREW_ORIGINAL_BREW_FILE", homebrew_bin/"brew")
end
it "uses the first suitable executable candidate" do
FileUtils.touch first_executable
FileUtils.chmod "+x", first_executable
FileUtils.touch second_executable
FileUtils.chmod "+x", second_executable
stub_const("ORIGINAL_PATHS", [first_dir])
with_env(PATH: second_dir.to_s) do
expect(sandbox_class.executable).to eq(first_executable)
end
end
it "skips unsuitable executable candidates" do
FileUtils.touch first_executable
FileUtils.chmod "+x", first_executable
FileUtils.touch second_executable
FileUtils.chmod "+x", second_executable
stub_const("ORIGINAL_PATHS", [first_dir])
sandbox_class.unsuitable_executables = [first_executable]
with_env(PATH: second_dir.to_s) do
expect(sandbox_class.executable).to eq(second_executable)
end
end
it "falls back to the original Homebrew bin directory" do
FileUtils.touch homebrew_executable
FileUtils.chmod "+x", homebrew_executable
stub_const("ORIGINAL_PATHS", [])
with_env(PATH: mktmpdir.to_s) do
expect(sandbox_class.executable).to eq(homebrew_executable)
end
end
it "checks absolute executable paths directly" do
FileUtils.touch first_executable
FileUtils.chmod "+x", first_executable
sandbox_class.test_executable_name = first_executable.to_s
stub_const("ORIGINAL_PATHS", [])
with_env(PATH: mktmpdir.to_s) do
expect(sandbox_class.executable).to eq(first_executable)
end
end
it "raises when no executable candidate exists" do
stub_const("ORIGINAL_PATHS", [])
with_env(PATH: mktmpdir.to_s) do
expect { sandbox_class.executable! }
.to raise_error(RuntimeError, "#{executable_name} is required to use the sandbox.")
end
end
end
describe "#path_filter" do
["'", '"', "(", ")", "\n", "\\"].each do |char|
it "fails if the path contains #{char}" do
expect do
sandbox.path_filter("foo#{char}bar", :subpath)
end.to raise_error(ArgumentError)
end
end
end
describe "#allow_read_if_exists" do
it "allows reads for existing paths" do
file = mktmpdir/"foo.rb"
FileUtils.touch file
sandbox.allow_read_if_exists path: file
rule = sandbox.send(:profile).rules.fetch(-1)
expect(rule).to have_attributes(allow: true, operation: "file-read*")
expect(rule.filter).to have_attributes(path: file.realpath.to_s, type: :literal)
end
it "skips missing paths" do
sandbox.allow_read_if_exists path: mktmpdir/"missing.rb"
expect(sandbox.send(:profile).rules).to be_empty
end
it "skips nil paths" do
sandbox.allow_read_if_exists path: nil
expect(sandbox.send(:profile).rules).to be_empty
end
end
describe "#allow_write_path_if_exists" do
it "allows writes for existing paths" do
dir = mktmpdir/"foo"
dir.mkpath
sandbox.allow_write_path_if_exists dir
rule = sandbox.send(:profile).rules.fetch(0)
expect(rule).to have_attributes(allow: true, operation: "file-write*")
expect(rule.filter).to have_attributes(path: dir.realpath.to_s, type: :subpath)
end
it "skips missing paths" do
sandbox.allow_write_path_if_exists mktmpdir/"missing"
expect(sandbox.send(:profile).rules).to be_empty
end
it "skips nil paths" do
sandbox.allow_write_path_if_exists nil
expect(sandbox.send(:profile).rules).to be_empty
end
end
describe "#allow_write_cellar" do
it "fails when the formula has a name including )" do
f = formula do
url "https://brew.sh/foo-1.0.tar.gz"
version "1.0"
def initialize(*, **)
super
@name = "foo)bar"
end
end
expect do
sandbox.allow_write_cellar f
end.to raise_error(ArgumentError)
end
it "fails when the formula has a name including \"" do
f = formula do
url "https://brew.sh/foo-1.0.tar.gz"
version "1.0"
def initialize(*, **)
super
@name = "foo\"bar"
end
end
expect do
sandbox.allow_write_cellar f
end.to raise_error(ArgumentError)
end
end
end
-44
View File
@@ -22,50 +22,6 @@ RSpec.describe Sandbox, :needs_macos do
expect(file).to exist
end
describe "#path_filter" do
["'", '"', "(", ")", "\n", "\\"].each do |char|
it "fails if the path contains #{char}" do
expect do
sandbox.path_filter("foo#{char}bar", :subpath)
end.to raise_error(ArgumentError)
end
end
end
describe "#allow_write_cellar" do
it "fails when the formula has a name including )" do
f = formula do
url "https://brew.sh/foo-1.0.tar.gz"
version "1.0"
def initialize(*, **)
super
@name = "foo)bar"
end
end
expect do
sandbox.allow_write_cellar f
end.to raise_error(ArgumentError)
end
it "fails when the formula has a name including \"" do
f = formula do
url "https://brew.sh/foo-1.0.tar.gz"
version "1.0"
def initialize(*, **)
super
@name = "foo\"bar"
end
end
expect do
sandbox.allow_write_cellar f
end.to raise_error(ArgumentError)
end
end
describe "#run" do
it "fails when writing to file not specified with ##allow_write" do
expect do
@@ -222,6 +222,9 @@ RSpec.shared_context "integration test" do # rubocop:disable RSpec/ContextWordin
end
def install_test_formula(name, content = nil, build_bottle: false)
# Synthetic test formulae must stay local so Linux sandbox builds do not
# need API cache or source paths mounted.
ENV["HOMEBREW_NO_INSTALL_FROM_API"] = "1"
setup_test_formula(name, content)
fi = FormulaInstaller.new(Formula[name], build_bottle:, installed_on_request: true)
fi.prelude_fetch
@@ -233,6 +236,8 @@ RSpec.shared_context "integration test" do # rubocop:disable RSpec/ContextWordin
def uninstall_test_formula(name)
rack = HOMEBREW_CELLAR/name
return unless rack.directory?
kegs = rack.children.map { |prefix| Keg.new(prefix) }
Homebrew::Uninstall.uninstall_kegs({ rack => kegs }, force: true, ignore_dependencies: true)
end