mirror of
https://github.com/Homebrew/brew.git
synced 2026-08-12 22:29:27 +04:00
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:
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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. " \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -2,3 +2,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "extend/os/mac/sandbox" if OS.mac?
|
||||
require "extend/os/linux/sandbox" if OS.linux?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user