Remove Bubblewrap, use Landlock for Linux sandbox

- Landlock needs no separate executable, installation or `sysctl`
  configuration, so use it as the only Linux sandbox implementation
  rather than an opt-in behind `$HOMEBREW_SANDBOX_LINUX_LANDLOCK`.
- Delete `Sandbox::Bubblewrap`, the `brew setup-sandbox` command and
  the implicit `bubblewrap` dependency, none of which Landlock needs.
- Remove the Bubblewrap-era `Sandbox` API (`ensure_sandbox_installed!`,
  `configure!`, `configuration_commands`, `sandbox_install_command`)
  and its call sites now that no backend needs installing or
  configuring.
- Simplify `brew doctor`'s `check_linux_sandbox` to report the
  Landlock failure reason with the `$HOMEBREW_NO_SANDBOX_LINUX`
  workaround.
This commit is contained in:
Mike McQuaid
2026-08-04 11:55:32 +01:00
parent f4a703c726
commit 377e4f740b
40 changed files with 92 additions and 1796 deletions
-2
View File
@@ -298,7 +298,6 @@ jobs:
run: brew tests ${{ matrix.test-flags }}
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_SANDBOX_LINUX_LANDLOCK: 1
# These cannot be queried at the macOS level on GitHub Actions.
HOMEBREW_LANGUAGES: en-GB
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -359,7 +358,6 @@ jobs:
- name: test-bot (macOS arm64)
runs-on: macos-26
env:
HOMEBREW_SANDBOX_LINUX_LANDLOCK: 1
HOMEBREW_TEST_BOT_ANALYTICS: 1
steps:
- name: Install Homebrew and Homebrew's dependencies
-1
View File
@@ -29,7 +29,6 @@ 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 \
-7
View File
@@ -241,13 +241,6 @@ then
export HOMEBREW_COLOR="1"
fi
# This is set by Homebrew's self-hosted runner environment.
# shellcheck disable=SC2154
if [[ -n "${HOMEBREW_LINUX}" && -n "${GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED}" ]]
then
export HOMEBREW_SANDBOX_LINUX_LANDLOCK="1"
fi
setup-locale
#####
@@ -203,7 +203,6 @@ module Cask
sig { returns(T.nilable(Sandbox)) }
def cask_sandbox
Sandbox.ensure_sandbox_installed!
return unless Sandbox.available?
Sandbox.new.tap do |sandbox|
-20
View File
@@ -1,20 +0,0 @@
# typed: strict
# frozen_string_literal: true
require "abstract_command"
require "shell_command"
module Homebrew
module Cmd
class SetupSandbox < AbstractCommand
include ShellCommand
cmd_args do
description <<~EOS
Run any necessary commands to setup the Homebrew sandbox.
Must be run with `sudo`. Currently a no-op on non-Linux.
EOS
end
end
end
end
-67
View File
@@ -1,67 +0,0 @@
# Documentation defined in Library/Homebrew/cmd/setup-sandbox.rb
# This Bubblewrap installation mirrors the package manager approaches in
# https://github.com/Homebrew/install and the Homebrew formula fallback in
# `ensure_sandbox_installed!` in Library/Homebrew/extend/os/linux/sandbox.rb.
# `sudo` strips `GITHUB_ACTIONS`, so also detect the runner via `/proc/1/cgroup`
# like `check-run-command-as-root` in Library/Homebrew/brew.sh does.
homebrew-on-github-actions() {
[[ -n "${GITHUB_ACTIONS}" ]] && return 0
grep -q "actions_job" /proc/1/cgroup &>/dev/null
}
homebrew-setup-sandbox() {
# The sandbox sysctls and Bubblewrap are Linux-only.
[[ -z "${HOMEBREW_LINUX}" ]] && return 0
if homebrew-on-github-actions && ! command -v bwrap &>/dev/null
then
if command -v apt-get &>/dev/null
then
apt-get install --yes bubblewrap
elif command -v dnf &>/dev/null
then
dnf install --assumeyes bubblewrap
elif command -v yum &>/dev/null
then
yum install --assumeyes bubblewrap
elif command -v pacman &>/dev/null
then
pacman --sync --noconfirm bubblewrap
elif command -v apk &>/dev/null
then
apk add bubblewrap
fi
fi
# These settings mirror SANDBOX_SYSCTL_SETTINGS in
# Library/Homebrew/extend/os/linux/sandbox.rb; keep both in sync.
local proc_sys_root="${HOMEBREW_PROC_SYS:-/proc/sys}"
local sysctl_value
local unprivileged_userns_clone_sysctl="${proc_sys_root}/kernel/unprivileged_userns_clone"
if [[ -e "${unprivileged_userns_clone_sysctl}" ]] &&
sysctl_value="$(sysctl -n "kernel.unprivileged_userns_clone")" &&
[[ "${sysctl_value}" != "1" ]] &&
[[ -w "${unprivileged_userns_clone_sysctl}" ]]
then
sysctl -w kernel.unprivileged_userns_clone=1 || true
fi
local max_user_namespaces_sysctl="${proc_sys_root}/user/max_user_namespaces"
if [[ -e "${max_user_namespaces_sysctl}" ]] &&
sysctl_value="$(sysctl -n "user.max_user_namespaces")" &&
[[ "${sysctl_value}" -lt 28633 ]] &&
[[ -w "${max_user_namespaces_sysctl}" ]]
then
sysctl -w user.max_user_namespaces=28633 || true
fi
local apparmor_restrict_unprivileged_userns_sysctl="${proc_sys_root}/kernel/apparmor_restrict_unprivileged_userns"
if [[ -e "${apparmor_restrict_unprivileged_userns_sysctl}" ]] &&
sysctl_value="$(sysctl -n "kernel.apparmor_restrict_unprivileged_userns")" &&
[[ "${sysctl_value}" != "0" ]] &&
[[ -w "${apparmor_restrict_unprivileged_userns_sysctl}" ]]
then
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
fi
}
-4
View File
@@ -99,15 +99,11 @@ class DependencyCollector
sig { params(related_formula_names: T::Set[String]).returns(T.nilable(Dependency)) }
def glibc_dep_if_needed(related_formula_names); end
sig { params(related_formula_names: T::Set[String]).returns(T.nilable(Dependency)) }
def bubblewrap_dep_if_needed(related_formula_names); end
# Names implicitly added to any formula's deps right now, reusing the same checks
# `Formula#add_global_deps_to_spec` uses to inject them onto a real formula.
sig { returns(T::Set[String]) }
def implicit_dependency_names
[
bubblewrap_dep_if_needed(Set.new),
gcc_dep_if_needed(Set.new),
glibc_dep_if_needed(Set.new),
].compact.to_set(&:name)
-1
View File
@@ -82,7 +82,6 @@ module Homebrew
exec_args << "--HEAD" if f.head?
Sandbox.ensure_sandbox_installed!
if Sandbox.available?
sandbox = Sandbox.new
f.logs.mkpath
+1 -1
View File
@@ -658,7 +658,7 @@ module Homebrew
default: true,
},
HOMEBREW_SANDBOX_LINUX: {
description: "The `bwrap`(1) sandbox is the default for formula installation and testing " \
description: "The Landlock sandbox is the default for formula installation and testing " \
"on Linux unless `$HOMEBREW_NO_SANDBOX_LINUX` is set.",
boolean: :set,
disabled_by: :HOMEBREW_NO_SANDBOX_LINUX,
@@ -2,22 +2,10 @@
# frozen_string_literal: true
require "os/linux/glibc"
require "sandbox"
module OS
module Linux
module DependencyCollector
sig { params(related_formula_names: T::Set[String]).returns(T.nilable(Dependency)) }
def bubblewrap_dep_if_needed(related_formula_names)
return unless bubblewrap_dependency_needed?
return if building_global_dep_tree?
return if related_formula_names.include?(BUBBLEWRAP)
return if global_dep_tree[BUBBLEWRAP]&.intersect?(related_formula_names)
return unless formula_for(BUBBLEWRAP)
Dependency.new(BUBBLEWRAP, [:implicit])
end
sig { params(related_formula_names: T::Set[String]).returns(T.nilable(Dependency)) }
def gcc_dep_if_needed(related_formula_names)
# gcc is required for libgcc_s.so.1 if glibc or gcc are too old
@@ -50,31 +38,18 @@ module OS
GLIBC = "glibc"
GCC = OS::LINUX_PREFERRED_GCC_RUNTIME_FORMULA
BUBBLEWRAP = "bubblewrap"
private_constant :GLIBC, :GCC, :BUBBLEWRAP
private_constant :GLIBC, :GCC
sig { void }
def init_global_dep_tree_if_needed!
return if building_global_dep_tree?
sandbox_tree_needed = bubblewrap_dependency_needed?
build_formulae_tree_needed = ::DevelopmentTools.needs_build_formulae?
return if !sandbox_tree_needed && !build_formulae_tree_needed
return if (!sandbox_tree_needed || global_dep_tree.key?(BUBBLEWRAP)) &&
(!build_formulae_tree_needed || (global_dep_tree.key?(GLIBC) && global_dep_tree.key?(GCC)))
return unless ::DevelopmentTools.needs_build_formulae?
return if global_dep_tree.key?(GLIBC) && global_dep_tree.key?(GCC)
building_global_dep_tree!
if sandbox_tree_needed
include_build = OS.not_tier_one_configuration? || build_formulae_tree_needed
global_dep_tree[BUBBLEWRAP] = Set.new(global_deps_for(BUBBLEWRAP, include_build:))
end
if build_formulae_tree_needed
global_dep_tree[GLIBC] = Set.new(global_deps_for(GLIBC))
# gcc depends on glibc
global_dep_tree[GCC] = Set.new([*global_deps_for(GCC), GLIBC, *@@global_dep_tree[GLIBC]])
# bubblewrap depends on gcc
global_dep_tree[BUBBLEWRAP]&.merge([GCC, *@@global_dep_tree[GCC]])
end
global_dep_tree[GLIBC] = Set.new(global_deps_for(GLIBC))
# gcc depends on glibc
global_dep_tree[GCC] = Set.new([*global_deps_for(GCC), GLIBC, *@@global_dep_tree[GLIBC]])
built_global_dep_tree!
end
@@ -86,27 +61,17 @@ module OS
nil
end
sig { returns(T::Boolean) }
def bubblewrap_dependency_needed?
return false unless ::Homebrew::EnvConfig.sandbox_linux?
return false if ENV["HOMEBREW_TESTS"]
return false if OS::Linux::Sandbox.landlock?
::Sandbox.executable.blank?
end
sig { params(name: String, include_build: T::Boolean).returns(T::Array[String]) }
def global_deps_for(name, include_build: true)
sig { params(name: String).returns(T::Array[String]) }
def global_deps_for(name)
@global_deps_for ||= T.let({}, T.nilable(T::Hash[String, T::Array[String]]))
# Always strip out glibc and gcc from all parts of dependency tree when
# we're calculating their dependency trees. Other parts of Homebrew will
# catch any circular dependencies.
@global_deps_for["#{name}|#{include_build}"] ||= if (formula = formula_for(name))
@global_deps_for[name] ||= if (formula = formula_for(name))
formula.deps.filter_map do |dep|
next if dep.test? && !dep.build?
next if dep.build? && !include_build
[dep.name, *global_deps_for(dep.name, include_build:)].compact
[dep.name, *global_deps_for(dep.name)].compact
end.flatten.uniq
else
[]
@@ -17,20 +17,8 @@ module OS
return unless Homebrew::EnvConfig.sandbox_linux?
require "sandbox"
return if !::Sandbox.available? && GitHub::Actions.env_set?
if OS::Linux::Sandbox.landlock?
unless ::Sandbox.available?
return if GitHub::Actions.env_set?
::Sandbox.ensure_sandbox_available!
end
::Sandbox.configure!
elsif GitHub::Actions.env_set?
::Sandbox.configure!
else
::Sandbox.ensure_sandbox_installed!(install_from_tests: true)
end
::Sandbox.ensure_sandbox_available!
end
+10 -50
View File
@@ -196,64 +196,24 @@ module OS
def check_linux_sandbox
return unless Homebrew::EnvConfig.sandbox_linux?
inside_docker = OS::Linux.inside_docker?
return if inside_docker && !GitHub::Actions.env_set?
return if OS::Linux.inside_docker? && !GitHub::Actions.env_set?
state = ::Sandbox.state
return if state == :available
reason = ::Sandbox.failure_reason || "The Linux sandbox is not available."
state = :landlock if OS::Linux::Sandbox.landlock?
reason_append = case state
when :setuid
"\n\nHomebrew's Linux sandbox requires a rootless `bwrap` executable."
when :unavailable
"\n\nHomebrew's Linux sandbox requires rootless Bubblewrap and unprivileged user namespaces."
fix = if state == :missing_fiddle
"Run Homebrew with its vendored Ruby, which includes Fiddle."
else
""
end
reason += reason_append
fix_lines = case state
when :missing
missing_lines = [
reason,
"",
"Install Bubblewrap and ensure a rootless `bwrap` executable is available on `PATH`.",
]
if (install_command = ::Sandbox.sandbox_install_command)
missing_lines.push("", "On this system, install it with:", " #{install_command}")
end
missing_lines
when :setuid
[
"Install a non-setuid Bubblewrap or put it earlier on `PATH`.",
]
when :unavailable
[
reason,
"",
"Homebrew's Linux sandbox requires rootless Bubblewrap and unprivileged",
"user namespaces. Run `sudo brew setup-sandbox` or check and update this system configuration:",
*::Sandbox.configuration_command_messages,
]
else
[]
end
if state == :unavailable && inside_docker && GitHub::Actions.env_set?
fix_lines.push("",
"If this is a GitHub Actions container, add `options: --privileged` to the job's " \
"`container` configuration.")
"Homebrew's Linux sandbox requires a kernel with Landlock enabled."
end
::Homebrew::Diagnostic::Finding.new(
reason,
remediation: [
*fix_lines,
"",
"As a final workaround, disable the Linux sandbox:",
" export HOMEBREW_NO_SANDBOX_LINUX=1",
].join("\n").to_s,
::Sandbox.failure_reason || "The Linux sandbox is not available.",
remediation: <<~EOS.chomp,
#{fix}
As a final workaround, disable the Linux sandbox:
export HOMEBREW_NO_SANDBOX_LINUX=1
EOS
)
end
@@ -37,7 +37,6 @@ module OS
related_formula_names.merge(versioned_formulae_names)
end
[
dependency_collector.bubblewrap_dep_if_needed(related_formula_names),
dependency_collector.gcc_dep_if_needed(related_formula_names),
dependency_collector.glibc_dep_if_needed(related_formula_names),
].compact.freeze
+10 -125
View File
@@ -1,7 +1,6 @@
# typed: strict
# frozen_string_literal: true
require "extend/os/linux/sandbox/bubblewrap"
require "extend/os/linux/sandbox/landlock"
module OS
@@ -15,31 +14,6 @@ module OS
TIOCSCTTY = 0x540E
private_constant :TIOCSCTTY
sig { returns(::PATH) }
def self.bubblewrap_candidate_paths
::Sandbox::Bubblewrap.executable_candidate_paths
end
sig { returns(T.nilable(::Pathname)) }
def self.bubblewrap_executable
::Sandbox::Bubblewrap.executable
end
sig { returns(::Pathname) }
def self.bubblewrap_executable!
::Sandbox::Bubblewrap.executable!
end
sig { returns(T::Boolean) }
def self.landlock?
ENV.fetch("HOMEBREW_SANDBOX_LINUX_LANDLOCK", nil) == "1"
end
sig { returns(T.any(T.class_of(::Sandbox::Bubblewrap), T.class_of(::Sandbox::Landlock))) }
def self.sandbox_implementation
landlock? ? ::Sandbox::Landlock : ::Sandbox::Bubblewrap
end
sig { void }
def allow_write_temp_and_cache
allow_write_path "/tmp"
@@ -67,101 +41,31 @@ module OS
requires_ancestor { T.class_of(::Sandbox) }
sig { returns(String) }
def executable_name
::Sandbox::Bubblewrap.executable_name
end
sig { params(candidate: ::Pathname).returns(T::Boolean) }
def executable_usable?(candidate)
::Sandbox::Bubblewrap.executable_usable?(candidate)
end
sig { returns(T::Array[String]) }
def system_bubblewrap_paths
::Sandbox::Bubblewrap.system_paths
end
sig { returns(::PATH) }
def executable_candidate_paths
::Sandbox::Bubblewrap.executable_candidate_paths
end
sig { returns(::PATH) }
def bubblewrap_candidate_paths
executable_candidate_paths
end
sig { returns(T.nilable(::Pathname)) }
def bubblewrap_executable
::Sandbox::Bubblewrap.executable
end
sig { returns(::Pathname) }
def bubblewrap_executable!
::Sandbox::Bubblewrap.executable!
end
sig { params(install_from_tests: T::Boolean).void }
def ensure_sandbox_installed!(install_from_tests: false)
OS::Linux::Sandbox.sandbox_implementation.ensure_installed!(install_from_tests:)
end
sig { returns(T::Boolean) }
def available?
OS::Linux::Sandbox.sandbox_implementation.available?
::Sandbox::Landlock.available?
end
sig { returns(T::Boolean) }
def full_write_isolation?
OS::Linux::Sandbox.sandbox_implementation.full_write_isolation?
end
# Bubblewrap reports this specific namespace error when an outer
# Bubblewrap sandbox prevents Homebrew from creating another rootless
# sandbox. The shared `avoid_nested_sandboxing?` only calls this once the
# `$HOMEBREW_AVOID_NESTED_SANDBOXING` opt-in is set.
sig { returns(T::Boolean) }
def nested_sandbox?
OS::Linux::Sandbox.sandbox_implementation.nested_sandbox?
::Sandbox::Landlock.full_write_isolation?
end
sig { returns(Symbol) }
def state
OS::Linux::Sandbox.sandbox_implementation.state
::Sandbox::Landlock.state
end
sig { void }
def reset_state!
::Sandbox::Bubblewrap.reset_state!
::Sandbox::Landlock.reset_state!
end
sig { returns(T::Array[String]) }
def configuration_commands
OS::Linux::Sandbox.sandbox_implementation.configuration_commands
end
sig { returns(T::Array[String]) }
def configuration_command_messages
OS::Linux::Sandbox.sandbox_implementation.configuration_command_messages
end
sig { void }
def configure!
OS::Linux::Sandbox.sandbox_implementation.configure!
end
sig { returns(T.nilable(String)) }
def failure_reason
return super if self != ::Sandbox
OS::Linux::Sandbox.sandbox_implementation.failure_reason
end
sig { returns(T.nilable(String)) }
def sandbox_install_command
OS::Linux::Sandbox.sandbox_implementation.install_command
::Sandbox::Landlock.failure_reason
end
# `ioctl` request used to attach the sandboxed child to a controlling TTY.
@@ -173,43 +77,24 @@ module OS
sig { params(args: T.any(String, ::Pathname)).void }
def run(*args)
implementation.run { super }
end
sig { params(tmpdir: String).returns(T::Array[String]) }
def bubblewrap_args(tmpdir)
bubblewrap.arguments(tmpdir)
end
sig { returns(T::Hash[String, Symbol]) }
def writable_paths
bubblewrap.writable_paths
landlock.run { super }
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)
implementation.command(args, tmpdir)
landlock.command(args, tmpdir)
end
sig { void }
def apply_sandbox
sandbox = implementation
sandbox.apply! if sandbox.is_a?(::Sandbox::Landlock)
landlock.apply!
end
sig { returns(T.any(::Sandbox::Bubblewrap, ::Sandbox::Landlock)) }
def implementation
@implementation ||= T.let(
OS::Linux::Sandbox.sandbox_implementation.new(profile),
T.nilable(T.any(::Sandbox::Bubblewrap, ::Sandbox::Landlock)),
)
end
sig { returns(::Sandbox::Bubblewrap) }
def bubblewrap
@bubblewrap ||= T.let(::Sandbox::Bubblewrap.new(profile), T.nilable(::Sandbox::Bubblewrap))
sig { returns(::Sandbox::Landlock) }
def landlock
@landlock ||= T.let(::Sandbox::Landlock.new(profile), T.nilable(::Sandbox::Landlock))
end
end
end
@@ -1,372 +0,0 @@
# typed: strict
# frozen_string_literal: true
require "fileutils"
require "env_config"
require "system_command"
require "utils/popen"
require "utils/github/actions"
require "extend/os/linux/sandbox/backend"
class Sandbox
class Bubblewrap < LinuxBackend
extend SystemCommand::Mixin
extend Utils::Output::Mixin
EXECUTABLE = "bwrap"
TEST_ARGS = [
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true"
].freeze
SYSTEM_PATHS = %w[
/usr/bin
/bin
].freeze
HOMEBREW_PATHS = [
"#{HOMEBREW_PREFIX}/bin",
].freeze
NESTED_ERROR = "Creating new namespace failed: nesting depth or /proc/sys/user/max_*_namespaces exceeded"
class SysctlSetting < T::Struct
const :assignment, String
const :description, T::Array[String]
const :optional, T::Boolean, default: false
end
# These settings mirror the `sysctl` assignments in
# Library/Homebrew/cmd/setup-sandbox.sh; keep both in sync.
SYSCTL_SETTINGS = T.let([
SysctlSetting.new(
assignment: "kernel.unprivileged_userns_clone=1",
description: [
"Allows unprivileged processes to create user namespaces. Rootless",
"Bubblewrap needs this to isolate builds without elevated privileges.",
],
),
SysctlSetting.new(
assignment: "user.max_user_namespaces=28633",
description: [
"Allows each user to allocate enough user namespaces. A zero or low",
"limit can prevent Bubblewrap from creating its sandbox.",
],
),
SysctlSetting.new(
assignment: "kernel.apparmor_restrict_unprivileged_userns=0",
description: [
"Allows unprivileged user namespaces on AppArmor-enabled systems",
"that restrict them by default. Older kernels may not provide this",
"setting.",
],
optional: true,
),
].freeze, T::Array[SysctlSetting])
# Per-distro Bubblewrap install commands, detected by package manager and
# checked in priority order. Mirrors the build tools instructions in
# `Homebrew/install`'s `install.sh`.
INSTALL_COMMANDS = T.let({
"apt-get" => "sudo apt-get install bubblewrap",
"dnf" => "sudo dnf install bubblewrap",
"yum" => "sudo yum install bubblewrap",
"pacman" => "sudo pacman -S bubblewrap",
"apk" => "sudo apk add bubblewrap",
}.freeze, T::Hash[String, String])
private_constant :EXECUTABLE, :TEST_ARGS, :SYSTEM_PATHS, :HOMEBREW_PATHS, :NESTED_ERROR, :SysctlSetting,
:SYSCTL_SETTINGS, :INSTALL_COMMANDS
class << self
sig { returns(String) }
def executable_name
EXECUTABLE
end
sig { params(candidate: ::Pathname).returns(T::Boolean) }
def executable_usable?(candidate)
!File.stat(candidate).setuid?
end
sig { returns(T::Array[String]) }
def system_paths
SYSTEM_PATHS
end
sig { returns(::PATH) }
def executable_candidate_paths
PATH.new(HOMEBREW_PATHS, system_paths, ORIGINAL_PATHS, ENV.fetch("PATH"), HOMEBREW_ORIGINAL_BREW_FILE.dirname)
end
sig { returns(T.nilable(::Pathname)) }
def 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 executable!
executable || raise("Bubblewrap is required to use the Linux sandbox.")
end
sig { params(install_from_tests: T::Boolean).void }
def ensure_installed!(install_from_tests: false)
return unless Homebrew::EnvConfig.sandbox_linux?
return if ENV["HOMEBREW_TESTS"] && !install_from_tests
return if ENV["HOMEBREW_INSTALLING_BUBBLEWRAP"]
return if executable
begin
require "exceptions"
require "formula"
with_env(HOMEBREW_INSTALLING_BUBBLEWRAP: "1") do
::Formula["bubblewrap"].ensure_installed!(reason: "Linux sandboxing")
end
reset_state!
return if executable
rescue ::FormulaUnavailableError
nil
end
return unless GitHub::Actions.env_set?
return unless ENV.fetch("HOMEBREW_GITHUB_HOSTED_RUNNER", nil)
return unless which("apt-get")
ohai "Installing Bubblewrap..."
command = ["apt-get", "install", "--yes", "bubblewrap"]
command.unshift("sudo") unless Process.euid.zero?
system(*command)
reset_state!
end
sig { returns(T::Boolean) }
def available?
state == :available
end
# Bubblewrap reports this specific namespace error when an outer
# Bubblewrap sandbox prevents Homebrew from creating another rootless
# sandbox. The shared `avoid_nested_sandboxing?` only calls this once the
# `$HOMEBREW_AVOID_NESTED_SANDBOXING` opt-in is set.
sig { returns(T::Boolean) }
def nested_sandbox?
return false unless Homebrew::EnvConfig.sandbox_linux?
bubblewrap = executable
return false unless bubblewrap
Utils.popen_read(bubblewrap.to_s, *TEST_ARGS, err: :out).include?(NESTED_ERROR)
end
sig { returns(Symbol) }
def state
return :config_disabled unless Homebrew::EnvConfig.sandbox_linux?
@state ||= T.let(compute_state, T.nilable(Symbol))
end
sig { void }
def reset_state!
@state = T.let(nil, T.nilable(Symbol))
end
sig { returns(T::Array[String]) }
def configuration_commands
SYSCTL_SETTINGS.map do |setting|
command = "sudo sysctl -w #{setting.assignment}"
command += " || true" if setting.optional
command
end
end
sig { returns(T::Array[String]) }
def configuration_command_messages
commands = configuration_commands
SYSCTL_SETTINGS.each_with_index.flat_map do |setting, index|
[
" #{commands.fetch(index)}",
*setting.description.map { |line| " #{line}" },
]
end
end
sig { void }
def configure!
unless executable
ensure_installed!(install_from_tests: true)
unless executable
reset_state!
return
end
end
ohai "Configuring Bubblewrap..."
command = [HOMEBREW_BREW_FILE.to_s, "setup-sandbox"]
command.unshift("sudo") unless Process.euid.zero?
raise ErrorDuringExecution.new(command, status: $CHILD_STATUS || 1) unless system(*command)
reset_state!
end
sig { returns(T.nilable(String)) }
def failure_reason
case state
when :config_disabled, :available
nil
when :missing
"Bubblewrap is required to use the Linux sandbox but was not found."
when :setuid
"A rootless Bubblewrap executable is required to use the Linux sandbox, " \
"but all found `bwrap` executables are setuid."
when :unavailable
"Bubblewrap is installed but cannot create a rootless sandbox."
else
"The Linux sandbox is not available."
end
end
sig { returns(T.nilable(String)) }
def install_command
INSTALL_COMMANDS.find { |package_manager, _| which(package_manager) }&.last
end
private
sig { returns(Symbol) }
def compute_state
bubblewraps = executables
return :missing if bubblewraps.empty?
bubblewraps = bubblewraps.select { |candidate| executable_usable?(candidate) }
return :setuid if bubblewraps.empty?
return :available if bubblewraps.any? { |candidate| sandbox_available?(candidate) }
:unavailable
end
sig { returns(T::Array[::Pathname]) }
def executables
executable_candidate_paths.filter_map do |path|
begin
candidate = ::Pathname.new(File.expand_path(executable_name, path))
rescue ArgumentError
next
end
candidate if candidate.file? && candidate.executable?
end
end
sig { params(bubblewrap: ::Pathname).returns(T::Boolean) }
def sandbox_available?(bubblewrap)
result = system_command(
bubblewrap,
args: TEST_ARGS,
print_stderr: false,
)
return true if result.success?
opoo "bubblewrap test probe failed"
$stderr.print result.merged_output
false
end
end
sig { params(profile: SandboxProfile).void }
def initialize(profile)
super
@masked_read_paths = T.let([], T::Array[::Pathname])
end
sig { params(block: T.proc.void).void }
def run(&block)
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?
@masked_read_paths.reverse_each { |path| FileUtils.rm_rf(path) }
@masked_read_paths.clear
end
sig { params(args: T::Array[T.any(String, ::Pathname)], tmpdir: String).returns(T::Array[T.any(String, ::Pathname)]) }
def command(args, tmpdir)
[self.class.executable!, *arguments(tmpdir), "--", *args]
end
sig { params(tmpdir: String).returns(T::Array[String]) }
def arguments(tmpdir)
args = T.let([
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--die-with-parent",
"--new-session",
"--ro-bind", "/", "/",
"--dev", "/dev",
"--proc", "/proc"
], T::Array[String])
args << "--unshare-net" if deny_all_network?
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
denied_read_paths.each do |path|
next unless File.exist?(path)
args += if File.directory?(path)
["--bind", masked_read_path, path]
else
["--ro-bind", File::NULL, path]
end
end
args += ["--bind", tmpdir, tmpdir, "--chdir", tmpdir]
args
end
private
sig { returns(T::Array[String]) }
def denied_write_paths
profile_paths(allow: false, operation: "file-write")
end
sig { returns(T::Array[String]) }
def denied_read_paths
profile_paths(allow: false, operation: "file-read")
end
sig { returns(String) }
def masked_read_path
path = ::Pathname.new(Dir.mktmpdir("homebrew-sandbox-deny-read", HOMEBREW_TEMP))
@masked_read_paths << path
path.to_s
end
end
end
@@ -94,8 +94,8 @@ class Sandbox
class << self
# Landlock cannot restrict chmod, chown, extended attributes or timestamp
# changes. Callers requiring Bubblewrap-equivalent write isolation must
# compensate for these limitations:
# changes. Callers requiring full write isolation must compensate for
# these limitations:
# https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#filesystem-flags
sig { returns(T::Boolean) }
def full_write_isolation? = false
@@ -159,26 +159,6 @@ class Sandbox
@abi_version = T.let(nil, T.nilable(Integer))
end
sig { params(install_from_tests: T::Boolean).void }
def ensure_installed!(install_from_tests: false); end
sig { void }
def configure!
ensure_available!
end
sig { returns(T::Array[String]) }
def configuration_commands = []
sig { returns(T::Array[String]) }
def configuration_command_messages = []
sig { returns(T.nilable(String)) }
def install_command = nil
sig { returns(T::Boolean) }
def nested_sandbox? = false
sig { params(attributes: T.nilable(String), size: Integer, flags: Integer).returns(Integer) }
def landlock_create_ruleset(attributes, size, flags)
@landlock_create_ruleset ||= T.let(
@@ -264,13 +244,6 @@ class Sandbox
private
sig { void }
def ensure_available!
return if available?
raise failure_reason || "Landlock is not available."
end
sig { returns(Symbol) }
def compute_state
return :config_disabled unless Homebrew::EnvConfig.sandbox_linux?
@@ -357,7 +330,7 @@ class Sandbox
# https://github.com/torvalds/linux/blob/master/drivers/char/mem.c
# POSIX shared memory and message queues use `/dev/shm` and
# `/dev/mqueue`. These grants retain normal kernel permissions but do
# not provide Bubblewrap's private IPC namespace:
# not provide a private IPC namespace:
# https://github.com/bminor/glibc/blob/master/sysdeps/posix/shm-directory.c
# https://www.kernel.org/doc/html/latest/filesystems/mqueue.html
device_path_rules = T.let({
@@ -22,10 +22,7 @@ module OS
sig { returns(T::Boolean) }
def configure_sandbox!
require "sandbox"
::Sandbox.configure!
::Sandbox.available?
rescue ::ErrorDuringExecution
false
end
end
+1 -16
View File
@@ -448,20 +448,7 @@ class FormulaInstaller
if Homebrew::EnvConfig.developer?
# `recursive_dependencies` trims cyclic dependencies, so we do one level and take the recursive deps of that.
# Mapping direct dependencies to deeper dependencies in a hash is also useful for the cyclic output below.
recursive_dep_map = formula.deps.to_h do |dep|
# We cheat a bit with bubblewrap. We eagerly add it to build dependencies on tier-one systems.
# But this cyclic dependency check is (intentionally) overly strict and forbids cyclic build dependencies,
# to help prevent cases that would break, for example, mass bottling.
recursive_deps = if dep.name == "bubblewrap" && dep.implicit?
[]
else
dep.to_formula.recursive_dependencies do |_dependent, recursive_dep|
Dependable::PRUNE if recursive_dep.name == "bubblewrap" && recursive_dep.implicit?
end
end
[dep, recursive_deps]
end
recursive_dep_map = formula.deps.to_h { |dep| [dep, dep.to_formula.recursive_dependencies] }
cyclic_dependencies = []
recursive_dep_map.each do |dep, recursive_deps|
@@ -1066,8 +1053,6 @@ on_request: installed_on_request?, options:)
# let's reset Utils::Git.available? if we just installed git
Utils::Git.clear_available_cache if formula.name == "git"
Sandbox.reset_state! if formula.name == "bubblewrap"
# use installed ca-certificates when it's needed and available
if formula.name == "ca-certificates" &&
!DevelopmentTools.ca_file_handles_most_https_certificates?
+1 -1
View File
@@ -199,7 +199,7 @@ class GitHubRunnerMatrix
unless self_hosted
container = {
image: "ghcr.io/homebrew/brew:main",
options: "--init --user linuxbrew --env HOMEBREW_SANDBOX_LINUX_LANDLOCK=1",
options: "--init --user linuxbrew",
}
workdir = "/github/home"
end
@@ -470,7 +470,7 @@ is stripped during metadata serialisation.
- [x] PR 4.1, formula install-step sandboxing.
Commit: `Sandbox formula install steps`.
Scope: run structured formula steps inside the existing post-install child
process so macOS Seatbelt and Linux Bubblewrap apply the same filesystem and
process so macOS Seatbelt and Linux Landlock apply the same filesystem and
network policy as legacy `post_install` hooks. This must land before any tap
migrations use filesystem-mutating steps.
- PR 5, default config and template writes (historical split workflow).
-19
View File
@@ -120,9 +120,6 @@ class Sandbox
true
end
sig { params(install_from_tests: T::Boolean).void }
def self.ensure_sandbox_installed!(install_from_tests: false); end
sig { void }
def self.ensure_sandbox_available!
return if available?
@@ -145,24 +142,8 @@ class Sandbox
sig { void }
def self.reset_state!; end
sig { returns(T::Array[String]) }
def self.configuration_commands = []
sig { returns(T::Array[String]) }
def self.configuration_command_messages = []
sig { returns(T.nilable(String)) }
def self.sandbox_install_command = nil
sig { void }
def self.configure!
ensure_sandbox_installed!
reset_state!
end
sig { params(command: T.any(String, Pathname), writable_path: T.any(String, Pathname), deny_network: T::Boolean).void }
def self.run_command(*command, writable_path:, deny_network: false)
ensure_sandbox_installed!
ensure_sandbox_available!
writable_path = Pathname(writable_path).expand_path
@@ -1,13 +0,0 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Homebrew::Cmd::SetupSandbox`.
# Please instead update this file by running `bin/tapioca dsl Homebrew::Cmd::SetupSandbox`.
class Homebrew::Cmd::SetupSandbox
sig { returns(Homebrew::Cmd::SetupSandbox::Args) }
def args; end
end
class Homebrew::Cmd::SetupSandbox::Args < Homebrew::CLI::Args; end
-15
View File
@@ -22,21 +22,6 @@ RSpec.describe "Bash" do
subject(:brew) { HOMEBREW_LIBRARY_PATH.parent.parent/"bin/brew" }
it { is_expected.to have_valid_bash_syntax }
it "selects Landlock on self-hosted Linux GitHub Actions runners", :needs_linux do
stdout, stderr, status = Open3.capture3(
{
"CI" => "1",
"GITHUB_ACTIONS" => "true",
"GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED" => "1",
"HOMEBREW_DEV_CMD_RUN" => "1",
"HOMEBREW_SANDBOX_LINUX_LANDLOCK" => nil,
},
brew.to_s, "ruby", "--", "-e", "print OS::Linux::Sandbox.sandbox_implementation"
)
expect([stdout, stderr, status.success?]).to eq(["Sandbox::Landlock", "", true])
end
end
describe "setup-locale" do
@@ -428,7 +428,7 @@ RSpec.describe Homebrew::Bundle::Installer do
allow(Homebrew::Bundle::Brew).to receive(:recursive_dep_names).with("alpha").and_return(Set.new)
allow(Homebrew::Bundle::Brew).to receive(:recursive_dep_names).with("beta").and_return(Set.new)
allow(DependencyCollector).to receive(:new).and_return(
instance_double(DependencyCollector, implicit_dependency_names: Set["bubblewrap"]),
instance_double(DependencyCollector, implicit_dependency_names: Set["glibc"]),
)
entries = [alpha_entry, beta_entry]
@@ -447,7 +447,7 @@ RSpec.describe Homebrew::Bundle::Installer do
allow(Homebrew::Bundle::Brew).to receive(:formulae_by_full_name).with(any_args).and_return({ dependencies: [] })
allow(Homebrew::Bundle::Brew).to receive(:recursive_dep_names).with(any_args).and_return(Set.new)
allow(DependencyCollector).to receive(:new).and_return(
instance_double(DependencyCollector, implicit_dependency_names: Set["bubblewrap"]),
instance_double(DependencyCollector, implicit_dependency_names: Set["glibc"]),
)
entries = [alpha_entry, beta_entry, gamma_entry]
@@ -469,7 +469,7 @@ RSpec.describe Homebrew::Bundle::Installer do
allow(Homebrew::Bundle::Brew).to receive(:recursive_dep_names).with("alpha").and_return(Set.new)
allow(Homebrew::Bundle::Cask).to receive(:formula_dependencies).with(["google-chrome"]).and_return([])
allow(DependencyCollector).to receive(:new).and_return(
instance_double(DependencyCollector, implicit_dependency_names: Set["bubblewrap"]),
instance_double(DependencyCollector, implicit_dependency_names: Set["glibc"]),
)
entries = [alpha_entry, installable_cask_entry]
@@ -32,7 +32,7 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
it "generates completion scripts for default shells" do
artifact = cask.artifacts.grep(described_class).first
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true)
allow(Sandbox).to receive(:available?).and_return(true)
allow(Sandbox).to receive(:new) do
instance_double(Sandbox).tap do |sandbox|
allow(sandbox).to receive(:allow_read)
@@ -61,7 +61,7 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
calls = []
homes = []
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true)
allow(Sandbox).to receive(:available?).and_return(true)
allow(Sandbox).to receive(:new) do
instance_double(Sandbox).tap do |sandbox|
expect(sandbox).to receive(:allow_read).with(path: staged_path, type: :subpath)
@@ -89,7 +89,7 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
it "warns and continues generating other shells" do
artifact = cask.artifacts.grep(described_class).first
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true)
allow(Sandbox).to receive(:available?).and_return(true)
allow(Sandbox).to receive(:new) do
instance_double(Sandbox).tap do |sandbox|
allow(sandbox).to receive(:allow_read)
@@ -146,7 +146,7 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
artifact = cask.artifacts.grep(described_class).first
captured_args = T.let([], T::Array[String])
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true)
allow(Sandbox).to receive(:available?).and_return(true)
allow(Sandbox).to receive(:new) do
instance_double(Sandbox).tap do |sandbox|
allow(sandbox).to receive(:allow_read)
@@ -1,186 +0,0 @@
# typed: true
# frozen_string_literal: true
require "fileutils"
require "open3"
require "cmd/shared_examples/args_parse"
require "cmd/setup-sandbox"
RSpec.describe Homebrew::Cmd::SetupSandbox do
let(:setup_sandbox_script) { HOMEBREW_LIBRARY_PATH/"cmd/setup-sandbox.sh" }
let(:proc_sys_root) { mktmpdir }
it_behaves_like "parseable arguments"
def run_setup_sandbox_shell(script, env = {})
Bundler.with_unbundled_env do
Open3.capture3(
{ "GITHUB_ACTIONS" => nil, "HOMEBREW_LINUX" => "1", "HOMEBREW_PROC_SYS" => proc_sys_root.to_s }
.merge(env),
"/bin/bash", "-c", script
)
end
end
def touch_proc_sys(path)
file = proc_sys_root/path
FileUtils.mkdir_p(file.dirname)
FileUtils.touch(file)
file
end
it "does nothing on non-Linux systems" do
stdout, _stderr, status = run_setup_sandbox_shell(<<~SH, "HOMEBREW_LINUX" => nil)
source "#{setup_sandbox_script}"
sysctl() { printf 'sysctl %s\\n' "$*"; }
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to be_empty
end
it "applies the sandbox sysctl settings when they are unset" do
touch_proc_sys "kernel/unprivileged_userns_clone"
touch_proc_sys "user/max_user_namespaces"
stdout, _stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl() { [[ "$1" == "-n" ]] && { echo 0; return; }; printf 'sysctl %s\\n' "$*"; }
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to eq(<<~EOS)
sysctl -w kernel.unprivileged_userns_clone=1
sysctl -w user.max_user_namespaces=28633
EOS
end
it "leaves already-configured sysctls unchanged" do
touch_proc_sys "kernel/unprivileged_userns_clone"
touch_proc_sys "user/max_user_namespaces"
touch_proc_sys "kernel/apparmor_restrict_unprivileged_userns"
stdout, _stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl() {
if [[ "$1" == "-n" ]]
then
case "$2" in
kernel.unprivileged_userns_clone) echo 1;;
user.max_user_namespaces) echo 28633;;
kernel.apparmor_restrict_unprivileged_userns) echo 0;;
esac
return
fi
printf 'sysctl %s\\n' "$*"
}
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to be_empty
end
it "skips missing sysctls and read-only sysctl writes" do
touch_proc_sys("user/max_user_namespaces").chmod(0444)
stdout, stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl_log="#{proc_sys_root}/sysctl.log"
sysctl() {
printf '%s\\n' "$*" >> "$sysctl_log"
if [[ "$1" == "-n" && "$2" == "user.max_user_namespaces" ]]
then
echo 1
return
fi
printf 'unexpected sysctl %s\\n' "$*" >&2
return 1
}
homebrew-setup-sandbox
cat "$sysctl_log"
SH
expect(status.success?).to be true
expect(stdout).to eq("-n user.max_user_namespaces\n")
expect(stderr).to be_empty
end
it "does not hide sysctl write errors" do
touch_proc_sys "user/max_user_namespaces"
stdout, stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl() {
if [[ "$1" == "-n" ]]
then
echo 1
return
fi
echo 'sysctl: setting key "user.max_user_namespaces", ignoring: Read-only file system' >&2
return 1
}
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to be_empty
expect(stderr).to eq("sysctl: setting key \"user.max_user_namespaces\", ignoring: Read-only file system\n")
end
it "installs Bubblewrap on GitHub Actions when it is missing" do
stdout, _stderr, status = run_setup_sandbox_shell(<<~SH, "GITHUB_ACTIONS" => "true")
source "#{setup_sandbox_script}"
command() { case "$2" in bwrap) return 1;; apt-get) return 0;; *) return 1;; esac; }
apt-get() { printf 'apt-get %s\\n' "$*"; }
sysctl() { [[ "$1" == "-n" ]] && { echo 1; return; }; :; }
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to eq("apt-get install --yes bubblewrap\n")
end
it "does not install Bubblewrap when it is already present" do
stdout, _stderr, status = run_setup_sandbox_shell(<<~SH, "GITHUB_ACTIONS" => "true")
source "#{setup_sandbox_script}"
command() { return 0; }
apt-get() { printf 'apt-get %s\\n' "$*"; }
sysctl() { [[ "$1" == "-n" ]] && { echo 1; return; }; :; }
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).not_to include("apt-get")
end
it "installs Bubblewrap when only the cgroup marks a GitHub Actions runner" do
stdout, _stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
grep() { return 0; }
command() { case "$2" in bwrap) return 1;; apt-get) return 0;; *) return 1;; esac; }
apt-get() { printf 'apt-get %s\\n' "$*"; }
sysctl() { [[ "$1" == "-n" ]] && { echo 1; return; }; :; }
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to eq("apt-get install --yes bubblewrap\n")
end
it "does not install Bubblewrap outside GitHub Actions" do
stdout, _stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
grep() { return 1; }
apt-get() { printf 'apt-get %s\\n' "$*"; }
sysctl() { [[ "$1" == "-n" ]] && { echo 1; return; }; :; }
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).not_to include("apt-get")
end
end
+5 -32
View File
@@ -15,61 +15,34 @@ RSpec.describe Homebrew::DevCmd::Tests do
require "sandbox"
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
allow(OS::Linux::Sandbox).to receive(:landlock?).and_return(false)
allow(GitHub::Actions).to receive(:env_set?).and_return(false)
end
it "does not require the Linux sandbox when Linux sandboxing is disabled" do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(false)
allow(Sandbox).to receive_messages(available?: false, failure_reason: "sandbox unavailable")
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
expect(Sandbox).not_to receive(:configure!)
expect(Sandbox).not_to receive(:ensure_sandbox_available!)
expect { tests.check_test_environment! }.not_to raise_error
end
it "does not fail on GitHub Actions when requested Landlock is unavailable" do
allow(OS::Linux::Sandbox).to receive(:landlock?).and_return(true)
it "does not fail on GitHub Actions when the Linux sandbox is unavailable" do
allow(Sandbox).to receive(:available?).and_return(false)
allow(GitHub::Actions).to receive(:env_set?).and_return(true)
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
expect(Sandbox).not_to receive(:configure!)
expect(Sandbox).not_to receive(:ensure_sandbox_available!)
expect { tests.check_test_environment! }.not_to raise_error
end
it "fails outside GitHub Actions when requested Landlock is unavailable" do
allow(OS::Linux::Sandbox).to receive(:landlock?).and_return(true)
it "fails outside GitHub Actions when the Linux sandbox is unavailable" do
allow(Sandbox).to receive_messages(available?: false, failure_reason: "Landlock is not available.")
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
expect(Sandbox).not_to receive(:configure!)
expect { tests.check_test_environment! }
.to raise_error(RuntimeError, "Landlock is not available.")
end
it "configures requested Landlock when it is available" do
allow(OS::Linux::Sandbox).to receive(:landlock?).and_return(true)
it "passes when the Linux sandbox is available" do
allow(Sandbox).to receive(:available?).and_return(true)
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
expect(Sandbox).to receive(:configure!)
expect { tests.check_test_environment! }.not_to raise_error
end
it "installs and checks Bubblewrap outside GitHub Actions" do
allow(Sandbox).to receive(:available?).and_return(true)
expect(Sandbox).to receive(:ensure_sandbox_installed!).with(install_from_tests: true)
expect(Sandbox).not_to receive(:configure!)
expect { tests.check_test_environment! }.not_to raise_error
end
it "configures and checks Bubblewrap on GitHub Actions" do
allow(GitHub::Actions).to receive(:env_set?).and_return(true)
allow(Sandbox).to receive(:available?).and_return(true)
expect(Sandbox).not_to receive(:ensure_sandbox_installed!)
expect(Sandbox).to receive(:configure!)
expect { tests.check_test_environment! }.not_to raise_error
end
@@ -900,78 +900,6 @@ RSpec.describe FormulaInstaller do
end.to raise_error(CannotInstallFormulaError)
end
it "does not raise on cyclic dependency through direct implicit Bubblewrap" do
ENV["HOMEBREW_DEVELOPER"] = "1"
formula_name = "homebrew-test-formula"
f = formula formula_name do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
end
dep = Dependency.new("bubblewrap", [:implicit])
allow(f).to receive_messages(deps: [dep], recursive_dependencies: [])
fi = described_class.new(f)
expect do
fi.check_install_sanity
end.not_to raise_error
end
it "does not raise on cyclic dependency through recursive implicit Bubblewrap" do
ENV["HOMEBREW_DEVELOPER"] = "1"
formula_name = "homebrew-test-formula"
f = formula formula_name do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
end
dep = Dependency.new("cmake", [:build])
implicit_bubblewrap = Dependency.new("bubblewrap", [:implicit])
recursive_dep = Dependency.new(formula_name)
dep_formula = instance_double(Formula)
allow(f).to receive_messages(deps: [dep], recursive_dependencies: [])
allow(dep).to receive(:to_formula).and_return(dep_formula)
allow(dep_formula).to receive(:recursive_dependencies) do |&block|
(block&.call(dep_formula, implicit_bubblewrap) == Dependable::PRUNE) ? [] : [recursive_dep]
end
fi = described_class.new(f)
expect do
fi.check_install_sanity
end.not_to raise_error
end
it "raises on cyclic dependency through recursive explicit Bubblewrap" do
ENV["HOMEBREW_DEVELOPER"] = "1"
formula_name = "homebrew-test-formula"
f = formula formula_name do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
end
dep = Dependency.new("cmake", [:build])
explicit_bubblewrap = Dependency.new("bubblewrap")
recursive_dep = Dependency.new(formula_name)
dep_formula = instance_double(Formula)
allow(f).to receive_messages(deps: [dep], recursive_dependencies: [])
allow(dep).to receive(:to_formula).and_return(dep_formula)
allow(dep_formula).to receive(:recursive_dependencies) do |&block|
block&.call(dep_formula, explicit_bubblewrap)
[recursive_dep]
end
fi = described_class.new(f)
expect do
fi.check_install_sanity
end.to raise_error(CannotInstallFormulaError)
end
it "raises on pinned dependency" do
dep_name = "homebrew-test-dependency"
dep_path = CoreTap.instance.new_formula_path(dep_name)
@@ -1552,7 +1480,7 @@ RSpec.describe FormulaInstaller do
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(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, deny_read_home: nil,
@@ -50,7 +50,7 @@ RSpec.describe GitHubRunnerMatrix, :no_api do
).to be(true)
end
it "uses Landlock in unprivileged Linux containers" do
it "uses unprivileged Linux containers" do
linux_containers = described_class.new([], ["deleted"], all_supported: false, dependent_matrix: false)
.active_runner_specs_hash
.filter_map { |runner| runner[:container] }
@@ -58,7 +58,7 @@ RSpec.describe GitHubRunnerMatrix, :no_api do
expect(linux_containers).to eq(Array.new(2) do
{
image: "ghcr.io/homebrew/brew:main",
options: "--init --user linuxbrew --env HOMEBREW_SANDBOX_LINUX_LANDLOCK=1",
options: "--init --user linuxbrew",
}
end)
end
@@ -2,7 +2,6 @@
# frozen_string_literal: true
require "dependency_collector"
require "sandbox"
RSpec.describe DependencyCollector do
subject(:collector) { described_class.new }
@@ -53,100 +52,13 @@ RSpec.describe DependencyCollector do
end
end
describe "#bubblewrap_dep_if_needed" do
let(:formulae) do
Hash.new { |hash, name| hash[name] = instance_double(Formula, deps: []) }
end
around do |example|
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: nil, HOMEBREW_TESTS: nil) { example.run }
end
before do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
allow(DevelopmentTools).to receive(:needs_build_formulae?).and_return(false)
allow(Sandbox).to receive(:executable)
allow(OS).to receive(:not_tier_one_configuration?).and_return(false)
allow(Formula).to receive(:[]) { |name| formulae[name] }
global_dep_tree.clear
end
after do
global_dep_tree.clear
end
def global_dep_tree
OS::Linux::DependencyCollector.module_eval { class_variable_get(:@@global_dep_tree) }
end
it "returns a Bubblewrap implicit dependency when the Linux sandbox needs one" do
expect(collector.bubblewrap_dep_if_needed(Set.new)).to eq(Dependency.new("bubblewrap", [:implicit]))
end
it "returns nil when Bubblewrap is already available" do
allow(Sandbox).to receive(:executable).and_return(Pathname("/usr/bin/bwrap"))
expect(collector.bubblewrap_dep_if_needed(Set.new)).to be_nil
end
it "returns nil when using Landlock" do
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: "1") do
expect(collector.bubblewrap_dep_if_needed(Set.new)).to be_nil
end
end
it "returns nil for Bubblewrap and its dependencies" do
collector.global_dep_tree["bubblewrap"] = Set["libcap"]
expect(collector.bubblewrap_dep_if_needed(Set["bubblewrap"])).to be_nil
expect(collector.bubblewrap_dep_if_needed(Set["libcap"])).to be_nil
end
it "returns nil when Bubblewrap is already in the dependency tree" do
expect(collector.bubblewrap_dep_if_needed(Set["bubblewrap"])).to be_nil
end
it "returns nil when a Bubblewrap runtime dependency is already in the dependency tree" do
formulae["bubblewrap"] = instance_double(Formula, deps: [Dependency.new("libcap")])
expect(collector.bubblewrap_dep_if_needed(Set["libcap"])).to be_nil
end
it "ignores Bubblewrap build dependencies when build formulae are not needed" do
formulae["bubblewrap"] = instance_double(Formula, deps: [
Dependency.new("libcap"),
Dependency.new("pkgconf", [:build]),
])
expect(collector.bubblewrap_dep_if_needed(Set["pkgconf"])).to eq(Dependency.new("bubblewrap", [:implicit]))
end
it "includes Bubblewrap build dependencies when build formulae are needed" do
allow(DevelopmentTools).to receive(:needs_build_formulae?).and_return(true)
formulae["bubblewrap"] = instance_double(Formula, deps: [
Dependency.new("pkgconf", [:build]),
])
formulae["glibc"]
formulae[OS::LINUX_PREFERRED_GCC_RUNTIME_FORMULA]
expect(collector.bubblewrap_dep_if_needed(Set["pkgconf"])).to be_nil
end
end
describe "#implicit_dependency_names" do
let(:formulae) do
Hash.new { |hash, name| hash[name] = instance_double(Formula, deps: []) }
end
around do |example|
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: nil, HOMEBREW_TESTS: nil) { example.run }
end
before do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
allow(DevelopmentTools).to receive_messages(needs_build_formulae?: false, needs_libc_formula?: false)
allow(Sandbox).to receive(:executable).and_return(nil)
allow(OS).to receive(:not_tier_one_configuration?).and_return(false)
allow(Formula).to receive(:[]) { |name| formulae[name] }
global_dep_tree.clear
end
@@ -159,13 +71,7 @@ RSpec.describe DependencyCollector do
OS::Linux::DependencyCollector.module_eval { class_variable_get(:@@global_dep_tree) }
end
it "includes bubblewrap when the sandbox needs it" do
expect(collector.implicit_dependency_names).to eq(Set["bubblewrap"])
end
it "is empty when bubblewrap is already available and build formulae aren't needed" do
allow(Sandbox).to receive(:executable).and_return(Pathname("/usr/bin/bwrap"))
it "is empty when build formulae and a libc formula aren't needed" do
expect(collector.implicit_dependency_names).to eq(Set.new)
end
@@ -7,10 +7,6 @@ require "sandbox"
RSpec.describe Homebrew::Diagnostic::Checks do
subject(:checks) { described_class.new }
around do |example|
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: nil) { example.run }
end
before do
allow(OS::Linux).to receive(:inside_docker?).and_return(false)
end
@@ -92,101 +88,53 @@ RSpec.describe Homebrew::Diagnostic::Checks do
end
end
specify "#check_linux_sandbox describes missing Bubblewrap" do
allow(Sandbox).to receive_messages(
state: :missing,
failure_reason: "Bubblewrap is required to use the Linux sandbox but was not found.",
sandbox_install_command: "sudo apt-get install bubblewrap",
)
with_env(HOMEBREW_NO_SANDBOX_LINUX: nil) do
message = checks.check_linux_sandbox&.to_s
expect(message)
.to include(
"Bubblewrap is required to use the Linux sandbox but was not found.",
"Install Bubblewrap and ensure a rootless `bwrap` executable is available on `PATH`.",
"On this system, install it with:",
" sudo apt-get install bubblewrap",
"export HOMEBREW_NO_SANDBOX_LINUX=1",
)
expect(message).not_to include("sysctl")
expect(message).to end_with(" export HOMEBREW_NO_SANDBOX_LINUX=1")
end
end
specify "#check_linux_sandbox describes setuid Bubblewrap" do
allow(Sandbox).to receive_messages(
state: :setuid,
failure_reason: "All found `bwrap` executables are setuid.",
)
with_env(HOMEBREW_NO_SANDBOX_LINUX: nil) do
message = checks.check_linux_sandbox&.to_s
expect(message)
.to include(
"All found `bwrap` executables are setuid.",
"Homebrew's Linux sandbox requires a rootless `bwrap` executable.",
"Install a non-setuid Bubblewrap or put it earlier on `PATH`.",
"export HOMEBREW_NO_SANDBOX_LINUX=1",
)
expect(message).not_to include("sysctl")
expect(message).to end_with(" export HOMEBREW_NO_SANDBOX_LINUX=1")
end
end
specify "#check_linux_sandbox describes Bubblewrap configuration" do
allow(Sandbox).to receive_messages(
state: :unavailable,
failure_reason: "Bubblewrap is installed but cannot create a rootless sandbox.",
)
with_env(HOMEBREW_NO_SANDBOX_LINUX: nil) do
message = checks.check_linux_sandbox&.to_s
expect(message)
.to include(
"Bubblewrap is installed but cannot create a rootless sandbox.",
"Homebrew's Linux sandbox requires rootless Bubblewrap and unprivileged",
"sudo sysctl -w kernel.unprivileged_userns_clone=1",
"Allows unprivileged processes to create user namespaces.",
"sudo sysctl -w user.max_user_namespaces=28633",
"Allows each user to allocate enough user namespaces.",
"sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true",
"Allows unprivileged user namespaces on AppArmor-enabled systems",
"export HOMEBREW_NO_SANDBOX_LINUX=1",
)
expect(message).to end_with(" export HOMEBREW_NO_SANDBOX_LINUX=1")
end
end
specify "#check_linux_sandbox describes unavailable Landlock" do
allow(OS::Linux).to receive(:inside_docker?).and_return(true)
specify "#check_linux_sandbox describes unsupported Landlock" do
allow(Sandbox).to receive_messages(
state: :unsupported,
failure_reason: "Landlock is not supported by this Linux kernel.",
)
with_env(GITHUB_ACTIONS: "true", HOMEBREW_NO_SANDBOX_LINUX: nil, HOMEBREW_SANDBOX_LINUX_LANDLOCK: "1") do
with_env(HOMEBREW_NO_SANDBOX_LINUX: nil) do
message = checks.check_linux_sandbox&.to_s
expect(message).to include("Landlock is not supported by this Linux kernel.")
expect(message).not_to include("Bubblewrap", "--privileged")
expect(message)
.to include(
"Landlock is not supported by this Linux kernel.",
"Homebrew's Linux sandbox requires a kernel with Landlock enabled.",
"export HOMEBREW_NO_SANDBOX_LINUX=1",
)
expect(message).to end_with(" export HOMEBREW_NO_SANDBOX_LINUX=1")
end
end
specify "#check_linux_sandbox suggests privileged GitHub Actions containers" do
specify "#check_linux_sandbox describes missing Fiddle" do
allow(Sandbox).to receive_messages(
state: :missing_fiddle,
failure_reason: "Landlock requires Ruby's bundled Fiddle library.",
)
with_env(HOMEBREW_NO_SANDBOX_LINUX: nil) do
message = checks.check_linux_sandbox&.to_s
expect(message)
.to include(
"Landlock requires Ruby's bundled Fiddle library.",
"Run Homebrew with its vendored Ruby, which includes Fiddle.",
"export HOMEBREW_NO_SANDBOX_LINUX=1",
)
expect(message).not_to include("kernel with Landlock")
end
end
specify "#check_linux_sandbox describes unavailable Landlock inside Docker on GitHub Actions" do
allow(OS::Linux).to receive(:inside_docker?).and_return(true)
allow(Sandbox).to receive_messages(
state: :unavailable,
failure_reason: "Bubblewrap is installed but cannot create a rootless sandbox.",
state: :disabled,
failure_reason: "Landlock is disabled by this Linux kernel.",
)
with_env(GITHUB_ACTIONS: "true", HOMEBREW_NO_SANDBOX_LINUX: nil) do
expect(checks.check_linux_sandbox&.to_s).to include(
"If this is a GitHub Actions container, add `options: --privileged` to the job's `container` configuration.",
)
expect(checks.check_linux_sandbox&.to_s).to include("Landlock is disabled by this Linux kernel.")
end
end
@@ -77,13 +77,6 @@ RSpec.describe Sandbox::Landlock do
expect(described_class.abi_version).to eq(1)
expect(described_class.failure_reason).to eq("Landlock ABI 2 or later is required; found ABI 1.")
end
it "only raises when explicitly configuring unavailable Landlock" do
allow(described_class).to receive_messages(available?: false, failure_reason: "Landlock is not available.")
expect { described_class.ensure_installed! }.not_to raise_error
expect { described_class.configure! }.to raise_error(RuntimeError, "Landlock is not available.")
end
end
describe "::kernel_abi_version" do
+4 -457
View File
@@ -7,455 +7,7 @@ require "extend/os/linux/sandbox" if OS.linux?
RSpec.describe Sandbox, :needs_linux do
subject(:sandbox) { described_class.new }
describe "::sandbox_implementation" do
it "uses Bubblewrap by default" do
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: nil) do
expect(OS::Linux::Sandbox.sandbox_implementation).to eq(Sandbox::Bubblewrap)
end
end
it "uses Landlock when requested" do
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: "1") do
expect(OS::Linux::Sandbox.sandbox_implementation).to eq(Sandbox::Landlock)
end
end
end
describe "::bubblewrap_executable" do
let(:sandbox_class) do
Class.new(Sandbox::Bubblewrap) 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 "searches Homebrew Bubblewrap before system Bubblewrap and skips setuid candidates" do
expect(Sandbox::Bubblewrap.executable_candidate_paths.to_a).to start_with("#{HOMEBREW_PREFIX}/bin", "/usr/bin",
"/bin")
expect(sandbox_class.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.executable! }
.to raise_error(RuntimeError, "Bubblewrap is required to use the Linux sandbox.")
end
end
describe "::available?" do
let(:sandbox_class) do
Class.new(Sandbox::Bubblewrap) do
class << self
attr_accessor :test_executable_candidate_paths
def executable_candidate_paths = test_executable_candidate_paths
end
end
end
let(:bubblewrap_dir) { mktmpdir }
let(:bubblewrap) { bubblewrap_dir/"bwrap" }
let(:fallback_bubblewrap_dir) { mktmpdir }
let(:fallback_bubblewrap) { fallback_bubblewrap_dir/"bwrap" }
let(:successful_result) { instance_double(SystemCommand::Result, success?: true) }
let(:failed_result) { instance_double(SystemCommand::Result, success?: false, merged_output: "") }
let(:bubblewrap_probe_args) do
[
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true"
]
end
let(:bubblewrap_test_args) do
[
bubblewrap.to_s,
*bubblewrap_probe_args,
{ err: :out },
]
end
before do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
FileUtils.touch bubblewrap
FileUtils.chmod "+x", bubblewrap
sandbox_class.test_executable_candidate_paths = PATH.new(bubblewrap_dir)
end
it "returns false when Linux sandboxing is disabled" do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(false)
expect(sandbox_class.available?).to be(false)
expect(sandbox_class.state).to eq(:config_disabled)
end
it "returns false when bubblewrap is unavailable" do
sandbox_class.test_executable_candidate_paths = PATH.new(mktmpdir)
expect(sandbox_class.available?).to be(false)
expect(sandbox_class.state).to eq(:missing)
end
it "probes unprivileged namespace support once" do
expect(sandbox_class).to receive(:system_command).once.with(
bubblewrap,
args: bubblewrap_probe_args,
print_stderr: false,
).and_return(successful_result)
expect(sandbox_class.available?).to be(true)
expect(sandbox_class.state).to eq(:available)
expect(sandbox_class.failure_reason).to be_nil
end
it "probes later usable Bubblewrap candidates if earlier candidates fail" do
FileUtils.touch fallback_bubblewrap
FileUtils.chmod "+x", fallback_bubblewrap
sandbox_class.test_executable_candidate_paths = PATH.new(bubblewrap_dir, fallback_bubblewrap_dir)
expect(sandbox_class).to receive(:system_command).with(
bubblewrap,
args: bubblewrap_probe_args,
print_stderr: false,
).and_return(failed_result)
expect(sandbox_class).to receive(:system_command).with(
fallback_bubblewrap,
args: bubblewrap_probe_args,
print_stderr: false,
).and_return(successful_result)
expect(sandbox_class.available?).to be(true)
end
it "reports setuid bubblewrap candidates" do
allow(File).to receive(:stat).and_call_original
allow(File).to receive(:stat).with(bubblewrap).and_return(instance_double(File::Stat, setuid?: true))
expect(sandbox_class.available?).to be(false)
expect(sandbox_class.state).to eq(:setuid)
expect(sandbox_class.failure_reason).to include("setuid")
end
it "reports bubblewrap sandbox probe failures" do
allow(sandbox_class).to receive(:system_command).and_return(failed_result)
expect(sandbox_class.available?).to be(false)
expect(sandbox_class.state).to eq(:unavailable)
expect(sandbox_class.failure_reason).to include("cannot create a rootless sandbox")
end
it "prints bubblewrap sandbox probe failure output" do
expect(sandbox_class).to receive(:system_command)
.and_return(instance_double(SystemCommand::Result, success?: false,
merged_output: "bwrap stdout\nbwrap stderr\n"))
expect(sandbox_class).to receive(:opoo).with("bubblewrap test probe failed")
expect { sandbox_class.available? }
.to output("bwrap stdout\nbwrap stderr\n").to_stderr
end
it "does not treat generic bubblewrap sandbox probe failures as nested" do
FileUtils.touch fallback_bubblewrap
FileUtils.chmod "+x", fallback_bubblewrap
sandbox_class.test_executable_candidate_paths = PATH.new(bubblewrap_dir, fallback_bubblewrap_dir)
expect(Utils).to receive(:popen_read)
.with(*bubblewrap_test_args)
.and_return("bwrap: No permissions to create a new namespace\n")
expect(sandbox_class.nested_sandbox?).to be(false)
end
it "treats a bubblewrap namespace nesting failure as nested" do
expect(Utils).to receive(:popen_read)
.with(*bubblewrap_test_args)
.and_return("bwrap: Creating new namespace failed: " \
"nesting depth or /proc/sys/user/max_*_namespaces exceeded (ENOSPC)\n")
expect(sandbox_class.nested_sandbox?).to be(true)
end
end
describe "::configuration_commands" do
let(:sandbox_class) { Class.new(Sandbox::Bubblewrap) }
around do |example|
with_env(GITHUB_ACTIONS: nil, HOMEBREW_GITHUB_HOSTED_RUNNER: nil) { example.run }
end
it "lists Linux sandbox sysctl commands" do
expect(sandbox_class.configuration_commands).to eq([
"sudo sysctl -w kernel.unprivileged_userns_clone=1",
"sudo sysctl -w user.max_user_namespaces=28633",
"sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true",
])
end
it "uses system Bubblewrap when configuring Linux sandbox sysctls" do
allow(sandbox_class).to receive(:executable).and_return(Pathname("/usr/bin/bwrap"))
allow(Process).to receive(:euid).and_return(1000)
expect(sandbox_class).not_to receive(:ensure_installed!)
expect(sandbox_class).to receive(:ohai).with("Configuring Bubblewrap...").ordered
expect(sandbox_class).to receive(:system)
.with("sudo", HOMEBREW_BREW_FILE.to_s, "setup-sandbox").and_return(true).ordered
sandbox_class.configure!
end
it "does not configure Linux sandbox sysctls when Bubblewrap remains unavailable" do
expect(sandbox_class).to receive(:executable).twice.and_return(nil)
expect(sandbox_class).to receive(:ensure_installed!)
.with(install_from_tests: true)
expect(sandbox_class).not_to receive(:system)
sandbox_class.configure!
end
it "installs Bubblewrap and configures Linux sandbox sysctls as root" do
expect(sandbox_class).to receive(:executable)
.twice
.and_return(nil, Pathname(HOMEBREW_PREFIX/"bin/bwrap"))
allow(Process).to receive(:euid).and_return(0)
expect(sandbox_class).to receive(:ensure_installed!)
.with(install_from_tests: true)
expect(sandbox_class).to receive(:ohai).with("Configuring Bubblewrap...").ordered
expect(sandbox_class).to receive(:system)
.with(HOMEBREW_BREW_FILE.to_s, "setup-sandbox").and_return(true).ordered
sandbox_class.configure!
end
it "raises when configuring Linux sandbox sysctls fails" do
allow(sandbox_class).to receive(:executable).and_return(Pathname("/usr/bin/bwrap"))
allow(Process).to receive(:euid).and_return(0)
allow(sandbox_class).to receive(:ohai)
expect(sandbox_class).to receive(:system)
.with(HOMEBREW_BREW_FILE.to_s, "setup-sandbox").and_return(false)
expect { sandbox_class.configure! }.to raise_error(ErrorDuringExecution)
end
end
describe "::sandbox_install_command" do
let(:sandbox_class) { Class.new(Sandbox::Bubblewrap) }
it "returns the distro-specific install command for the detected package manager" do
allow(sandbox_class).to receive(:which).with("apt-get").and_return(nil)
allow(sandbox_class).to receive(:which).with("dnf").and_return(Pathname("/usr/bin/dnf"))
expect(sandbox_class.install_command).to eq("sudo dnf install bubblewrap")
end
it "returns nil when no known package manager is found" do
allow(sandbox_class).to receive(:which).and_return(nil)
expect(sandbox_class.install_command).to be_nil
end
end
describe "::ensure_sandbox_installed!" do
let(:sandbox_class) { Class.new(Sandbox::Bubblewrap) }
around do |example|
with_env(GITHUB_ACTIONS: nil, HOMEBREW_GITHUB_HOSTED_RUNNER: nil,
HOMEBREW_INSTALLING_BUBBLEWRAP: nil, HOMEBREW_TESTS: nil) { example.run }
end
before do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(true)
end
it "does nothing when Homebrew Bubblewrap is already available" do
expect(sandbox_class).to receive(:executable)
.once
.and_return(Pathname(HOMEBREW_PREFIX/"bin/bwrap"))
expect(Formula).not_to receive(:[])
expect(sandbox_class).not_to receive(:which)
expect(sandbox_class).not_to receive(:system)
sandbox_class.ensure_installed!
end
it "does nothing when system Bubblewrap is already available" do
expect(sandbox_class).to receive(:executable)
.once
.and_return(Pathname("/usr/bin/bwrap"))
expect(Formula).not_to receive(:[])
expect(sandbox_class).not_to receive(:which)
expect(sandbox_class).not_to receive(:system)
sandbox_class.ensure_installed!
end
it "installs Bubblewrap with Homebrew before trying apt-get on GitHub Actions" do
expect(sandbox_class).to receive(:executable)
.twice
.and_return(nil, Pathname(HOMEBREW_PREFIX/"bin/bwrap"))
expect(Formula).to receive(:[]).with("bubblewrap")
.and_return(instance_double(Formula, ensure_installed!: nil))
expect(sandbox_class).not_to receive(:which)
expect(sandbox_class).not_to receive(:system)
with_env(GITHUB_ACTIONS: "true", HOMEBREW_GITHUB_HOSTED_RUNNER: "1") do
sandbox_class.ensure_installed!
end
end
it "falls back to sudo apt-get on GitHub Actions Ubuntu when Homebrew Bubblewrap is unavailable" do
expect(sandbox_class).to receive(:executable)
.twice
.and_return(nil)
expect(Formula).to receive(:[]).with("bubblewrap")
.and_return(instance_double(Formula, ensure_installed!: nil))
expect(sandbox_class).to receive(:which).with("apt-get").and_return(Pathname("/usr/bin/apt-get"))
expect(Process).to receive(:euid).and_return(1000)
expect(sandbox_class).to receive(:ohai).with("Installing Bubblewrap...")
expect(sandbox_class).to receive(:system)
.with("sudo", "apt-get", "install", "--yes", "bubblewrap")
.and_return(true)
with_env(GITHUB_ACTIONS: "true", HOMEBREW_GITHUB_HOSTED_RUNNER: "1") do
sandbox_class.ensure_installed!
end
end
it "falls back to apt-get as root on GitHub Actions Ubuntu when Homebrew Bubblewrap is unavailable" do
expect(sandbox_class).to receive(:executable)
.twice
.and_return(nil)
expect(Formula).to receive(:[]).with("bubblewrap")
.and_return(instance_double(Formula, ensure_installed!: nil))
expect(sandbox_class).to receive(:which).with("apt-get").and_return(Pathname("/usr/bin/apt-get"))
expect(Process).to receive(:euid).and_return(0)
expect(sandbox_class).to receive(:ohai).with("Installing Bubblewrap...")
expect(sandbox_class).to receive(:system)
.with("apt-get", "install", "--yes", "bubblewrap")
.and_return(true)
with_env(GITHUB_ACTIONS: "true", HOMEBREW_GITHUB_HOSTED_RUNNER: "1") do
sandbox_class.ensure_installed!
end
end
it "does not fall back to apt-get outside GitHub Actions Ubuntu" do
expect(sandbox_class).to receive(:executable)
.twice
.and_return(nil, nil)
expect(Formula).to receive(:[]).with("bubblewrap")
.and_return(instance_double(Formula, ensure_installed!: nil))
expect(sandbox_class).not_to receive(:which)
expect(sandbox_class).not_to receive(:system)
with_env(GITHUB_ACTIONS: "true") do
sandbox_class.ensure_installed!
end
end
it "does not fall back to apt-get outside GitHub Actions" do
expect(sandbox_class).to receive(:executable)
.twice
.and_return(nil, nil)
expect(Formula).to receive(:[]).with("bubblewrap")
.and_return(instance_double(Formula, ensure_installed!: nil))
expect(sandbox_class).not_to receive(:which)
expect(sandbox_class).not_to receive(:system)
sandbox_class.ensure_installed!
end
end
describe "#bubblewrap_args" do
let(:dir) { mktmpdir }
let(:denied_dir) { mktmpdir }
let(:tmpdir) { mktmpdir }
let(:args) { sandbox.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 host filesystem read-only" do
expect(args.each_cons(3)).to include(["--ro-bind", "/", "/"])
expect(args.index("--ro-bind")).to be < args.index("--dev")
end
it "masks denied read directories" do
sandbox.deny_read_path dir
bind = args.each_cons(3).find { |arg| arg.fetch(0) == "--bind" && arg.fetch(2) == dir.to_s }
expect(bind).not_to be_nil
expect(Pathname(bind.fetch(1)).children).to be_empty
end
it "overlays Linux runtime filesystems" do
expect(args.each_cons(2)).to include(["--dev", "/dev"], ["--proc", "/proc"])
end
it "does not need explicit mounts for allowed reads" do
file = mktmpdir/"foo.rb"
FileUtils.touch file
sandbox.allow_read path: file
expect(args.each_cons(3)).to include(["--ro-bind", "/", "/"])
expect(args.each_cons(3)).not_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.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 with Bubblewrap" do
around do |example|
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: nil) { example.run }
end
describe "#run" do
before do
skip "Sandbox not available." unless described_class.available?
end
@@ -509,15 +61,10 @@ RSpec.describe Sandbox, :needs_linux do
expect { sandbox.run "/bin/sh", "-c", 'exec "$1"', "brew-test", executable }
.to raise_error(ErrorDuringExecution)
end
end
describe "#run with Landlock" do
it "allows standard devices and shared memory" do
skip "Landlock not available." unless Sandbox::Landlock.available?
with_env(HOMEBREW_SANDBOX_LINUX_LANDLOCK: "1") do
landlock_sandbox = described_class.new
landlock_sandbox.run RUBY_PATH, "-rio/console", "-e", <<~'RUBY'
expect do
sandbox.run RUBY_PATH, "-rio/console", "-e", <<~'RUBY'
begin
File.open("/dev/tty", "r+") { |tty| tty.winsize }
rescue Errno::ENXIO, Errno::ENOENT, Errno::EACCES, Errno::EPERM
@@ -539,7 +86,7 @@ RSpec.describe Sandbox, :needs_linux do
File.unlink(path)
end
RUBY
end
end.not_to raise_error
end
end
end
+2 -3
View File
@@ -12,9 +12,8 @@ RSpec.describe Sandbox do
before do
allow(described_class).to receive_messages(
ensure_sandbox_installed!: nil,
available?: true,
new: command_sandbox,
available?: true,
new: command_sandbox,
)
allow(command_sandbox).to receive_messages(
allow_write_temp_and_cache: nil,
-17
View File
@@ -2810,22 +2810,6 @@ _brew_setup_ruby() {
__brew_complete_commands
}
_brew_setup_sandbox() {
local cur="${COMP_WORDS[COMP_CWORD]}"
case "${cur}" in
-*)
__brewcomp "
--debug
--help
--quiet
--verbose
"
return
;;
*) ;;
esac
}
_brew_sh() {
local cur="${COMP_WORDS[COMP_CWORD]}"
case "${cur}" in
@@ -3840,7 +3824,6 @@ _brew() {
search) _brew_search ;;
services) _brew_services ;;
setup-ruby) _brew_setup_ruby ;;
setup-sandbox) _brew_setup_sandbox ;;
sh) _brew_sh ;;
shellenv) _brew_shellenv ;;
source) _brew_source ;;
-7
View File
@@ -1804,13 +1804,6 @@ __fish_brew_complete_arg 'setup-ruby' -l verbose -d 'Make some output more verbo
__fish_brew_complete_arg 'setup-ruby' -a '(__fish_brew_suggest_commands)'
__fish_brew_complete_cmd 'setup-sandbox' 'Run any necessary commands to setup the Homebrew sandbox'
__fish_brew_complete_arg 'setup-sandbox' -l debug -d 'Display any debugging information'
__fish_brew_complete_arg 'setup-sandbox' -l help -d 'Show this message'
__fish_brew_complete_arg 'setup-sandbox' -l quiet -d 'Make some output more quiet'
__fish_brew_complete_arg 'setup-sandbox' -l verbose -d 'Make some output more verbose'
__fish_brew_complete_cmd 'sh' 'Enter an interactive shell for Homebrew\'s build environment'
__fish_brew_complete_arg 'sh' -l cmd -d 'Execute commands in a non-interactive shell'
__fish_brew_complete_arg 'sh' -l debug -d 'Display any debugging information'
-1
View File
@@ -76,7 +76,6 @@ sandbox-exec
search
services
setup-ruby
setup-sandbox
sh
shellenv
source
-10
View File
@@ -234,7 +234,6 @@ __brew_internal_commands() {
'search:Perform a substring search of cask tokens and formula names for text'
'services:Manage background services with macOS'\'' `launchctl`(1) daemon manager or Linux'\''s `systemctl`(1) service manager'
'setup-ruby:Installs and configures Homebrew'\''s Ruby'
'setup-sandbox:Run any necessary commands to setup the Homebrew sandbox'
'sh:Enter an interactive shell for Homebrew'\''s build environment'
'shellenv:Valid shells: bash|csh|fish|pwsh|sh|tcsh|zsh Print export statements'
'source:Open a formula'\''s source repository in a browser, or open Homebrew'\''s own repository if no argument is provided'
@@ -2325,15 +2324,6 @@ _brew_setup_ruby() {
'*:command:__brew_commands'
}
# brew setup-sandbox
_brew_setup_sandbox() {
_arguments \
'--debug[Display any debugging information]' \
'--help[Show this message]' \
'--quiet[Make some output more quiet]' \
'--verbose[Make some output more verbose]'
}
# brew sh
_brew_sh() {
_arguments \
-5
View File
@@ -2116,11 +2116,6 @@ line; lines starting with `#` are comments. Changes take effect on the next
Installs and configures Homebrew's Ruby. If `command` is passed, it will only
run Bundler if necessary for that command.
### `setup-sandbox`
Run any necessary commands to setup the Homebrew sandbox. Must be run with
`sudo`. Currently a no-op on non-Linux.
### `shellenv` \[*`shell`* ...\]
Valid shells: bash\|csh\|fish\|pwsh\|sh\|tcsh\|zsh
-2
View File
@@ -1346,8 +1346,6 @@ Output as JSON\.
Remove all unused services\.
.SS "\fBsetup\-ruby\fP \fR[\fIcommand\fP \.\.\.]"
Installs and configures Homebrew\[u2019]s Ruby\. If \fBcommand\fP is passed, it will only run Bundler if necessary for that command\.
.SS "\fBsetup\-sandbox\fP"
Run any necessary commands to setup the Homebrew sandbox\. Must be run with \fBsudo\fP\&\. Currently a no\-op on non\-Linux\.
.SS "\fBshellenv\fP \fR[\fIshell\fP \.\.\.]"
Valid shells: bash|csh|fish|pwsh|sh|tcsh|zsh
.P