diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 88d793bd9c..e32ef2c2b7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 \ diff --git a/Dockerfile b/Dockerfile index 757292aac7..557aeb0d3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/Library/Homebrew/build.rb b/Library/Homebrew/build.rb index 5ba677e35b..13926e857d 100644 --- a/Library/Homebrew/build.rb +++ b/Library/Homebrew/build.rb @@ -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. " \ diff --git a/Library/Homebrew/dev-cmd/test.rb b/Library/Homebrew/dev-cmd/test.rb index 28597b2668..0a3c2085c8 100644 --- a/Library/Homebrew/dev-cmd/test.rb +++ b/Library/Homebrew/dev-cmd/test.rb @@ -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 diff --git a/Library/Homebrew/dev-cmd/tests.rb b/Library/Homebrew/dev-cmd/tests.rb index 94afc976ca..6133498778 100644 --- a/Library/Homebrew/dev-cmd/tests.rb +++ b/Library/Homebrew/dev-cmd/tests.rb @@ -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? diff --git a/Library/Homebrew/env_config.rb b/Library/Homebrew/env_config.rb index 4e164ef949..a3e7f89b96 100644 --- a/Library/Homebrew/env_config.rb +++ b/Library/Homebrew/env_config.rb @@ -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. " \ diff --git a/Library/Homebrew/extend/os/linux/dev-cmd/tests.rb b/Library/Homebrew/extend/os/linux/dev-cmd/tests.rb index 60bc593254..c1300ef12f 100644 --- a/Library/Homebrew/extend/os/linux/dev-cmd/tests.rb +++ b/Library/Homebrew/extend/os/linux/dev-cmd/tests.rb @@ -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 diff --git a/Library/Homebrew/extend/os/linux/sandbox.rb b/Library/Homebrew/extend/os/linux/sandbox.rb new file mode 100644 index 0000000000..2e6d03aea0 --- /dev/null +++ b/Library/Homebrew/extend/os/linux/sandbox.rb @@ -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 ``; 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) diff --git a/Library/Homebrew/extend/os/sandbox.rb b/Library/Homebrew/extend/os/sandbox.rb index f34ef2b4b2..65b1246727 100644 --- a/Library/Homebrew/extend/os/sandbox.rb +++ b/Library/Homebrew/extend/os/sandbox.rb @@ -2,3 +2,4 @@ # frozen_string_literal: true require "extend/os/mac/sandbox" if OS.mac? +require "extend/os/linux/sandbox" if OS.linux? diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index 6eb80f6ae7..59e0351d17 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -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 diff --git a/Library/Homebrew/sandbox.rb b/Library/Homebrew/sandbox.rb index a4d0925108..cdb42fab51 100644 --- a/Library/Homebrew/sandbox.rb +++ b/Library/Homebrew/sandbox.rb @@ -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 diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi index be2da25efb..954f6dc1c9 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/env_config.rbi @@ -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 diff --git a/Library/Homebrew/test/dev-cmd/tests_spec.rb b/Library/Homebrew/test/dev-cmd/tests_spec.rb index cc6ac2ba40..757f72027b 100644 --- a/Library/Homebrew/test/dev-cmd/tests_spec.rb +++ b/Library/Homebrew/test/dev-cmd/tests_spec.rb @@ -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 diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 647b7aebd6..48803c4b73 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -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 diff --git a/Library/Homebrew/test/sandbox_linux_spec.rb b/Library/Homebrew/test/sandbox_linux_spec.rb new file mode 100644 index 0000000000..dbf83fd166 --- /dev/null +++ b/Library/Homebrew/test/sandbox_linux_spec.rb @@ -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 diff --git a/Library/Homebrew/test/sandbox_shared_spec.rb b/Library/Homebrew/test/sandbox_shared_spec.rb new file mode 100644 index 0000000000..42a06f9f6e --- /dev/null +++ b/Library/Homebrew/test/sandbox_shared_spec.rb @@ -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 diff --git a/Library/Homebrew/test/sandbox_spec.rb b/Library/Homebrew/test/sandbox_spec.rb index 854bf40181..35b05828e1 100644 --- a/Library/Homebrew/test/sandbox_spec.rb +++ b/Library/Homebrew/test/sandbox_spec.rb @@ -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 diff --git a/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb b/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb index 1eb6abeace..ea8e4148be 100644 --- a/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb +++ b/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb @@ -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