mirror of
https://github.com/Homebrew/brew.git
synced 2025-07-14 16:09:03 +08:00
Merge pull request #17081 from alebcay/formula-offline-phases
Support for opt-in network isolation in build/test sandboxes
This commit is contained in:
commit
b5f857b627
@ -44,6 +44,8 @@ FORMULA_COMPONENT_PRECEDENCE_LIST = T.let([
|
|||||||
[{ name: :go_resource, type: :block_call }, { name: :resource, type: :block_call }],
|
[{ name: :go_resource, type: :block_call }, { name: :resource, type: :block_call }],
|
||||||
[{ name: :patch, type: :method_call }, { name: :patch, type: :block_call }],
|
[{ name: :patch, type: :method_call }, { name: :patch, type: :block_call }],
|
||||||
[{ name: :needs, type: :method_call }],
|
[{ name: :needs, type: :method_call }],
|
||||||
|
[{ name: :allow_network_access!, type: :method_call }],
|
||||||
|
[{ name: :deny_network_access!, type: :method_call }],
|
||||||
[{ name: :install, type: :method_definition }],
|
[{ name: :install, type: :method_definition }],
|
||||||
[{ name: :post_install, type: :method_definition }],
|
[{ name: :post_install, type: :method_definition }],
|
||||||
[{ name: :caveats, type: :method_definition }],
|
[{ name: :caveats, type: :method_definition }],
|
||||||
|
@ -80,7 +80,7 @@ module Homebrew
|
|||||||
|
|
||||||
exec_args << "--HEAD" if f.head?
|
exec_args << "--HEAD" if f.head?
|
||||||
|
|
||||||
Utils.safe_fork do
|
Utils.safe_fork do |error_pipe|
|
||||||
if Sandbox.available?
|
if Sandbox.available?
|
||||||
sandbox = Sandbox.new
|
sandbox = Sandbox.new
|
||||||
f.logs.mkpath
|
f.logs.mkpath
|
||||||
@ -92,6 +92,7 @@ module Homebrew
|
|||||||
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
|
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
|
||||||
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
|
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
|
||||||
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
|
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
|
||||||
|
sandbox.deny_all_network_except_pipe(error_pipe) unless f.class.network_access_allowed?(:test)
|
||||||
sandbox.exec(*exec_args)
|
sandbox.exec(*exec_args)
|
||||||
else
|
else
|
||||||
exec(*exec_args)
|
exec(*exec_args)
|
||||||
|
@ -228,6 +228,21 @@ module Homebrew
|
|||||||
"of Ruby is new enough.",
|
"of Ruby is new enough.",
|
||||||
boolean: true,
|
boolean: true,
|
||||||
},
|
},
|
||||||
|
HOMEBREW_FORMULA_BUILD_NETWORK: {
|
||||||
|
description: "If set, controls network access to the sandbox for formulae builds. Overrides any " \
|
||||||
|
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
|
||||||
|
"set through this environment variable or DSL usage, the default behavior is `allow`.",
|
||||||
|
},
|
||||||
|
HOMEBREW_FORMULA_POSTINSTALL_NETWORK: {
|
||||||
|
description: "If set, controls network access to the sandbox for formulae postinstall. Overrides any " \
|
||||||
|
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
|
||||||
|
"set through this environment variable or DSL usage, the default behavior is `allow`.",
|
||||||
|
},
|
||||||
|
HOMEBREW_FORMULA_TEST_NETWORK: {
|
||||||
|
description: "If set, controls network access to the sandbox for formulae test. Overrides any " \
|
||||||
|
"controls set through DSL usage inside formulae. Must be `allow` or `deny`. If no value is " \
|
||||||
|
"set through this environment variable or DSL usage, the default behavior is `allow`.",
|
||||||
|
},
|
||||||
HOMEBREW_GITHUB_API_TOKEN: {
|
HOMEBREW_GITHUB_API_TOKEN: {
|
||||||
description: "Use this personal access token for the GitHub API, for features such as " \
|
description: "Use this personal access token for the GitHub API, for features such as " \
|
||||||
"`brew search`. You can create one at <https://github.com/settings/tokens>. If set, " \
|
"`brew search`. You can create one at <https://github.com/settings/tokens>. If set, " \
|
||||||
|
@ -70,6 +70,11 @@ class Formula
|
|||||||
extend Attrable
|
extend Attrable
|
||||||
extend APIHashable
|
extend APIHashable
|
||||||
|
|
||||||
|
SUPPORTED_NETWORK_ACCESS_PHASES = [:build, :test, :postinstall].freeze
|
||||||
|
DEFAULT_NETWORK_ACCESS_ALLOWED = true
|
||||||
|
private_constant :SUPPORTED_NETWORK_ACCESS_PHASES
|
||||||
|
private_constant :DEFAULT_NETWORK_ACCESS_ALLOWED
|
||||||
|
|
||||||
# The name of this {Formula}.
|
# The name of this {Formula}.
|
||||||
# e.g. `this-formula`
|
# e.g. `this-formula`
|
||||||
#
|
#
|
||||||
@ -410,6 +415,7 @@ class Formula
|
|||||||
!!head && !stable
|
!!head && !stable
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Stop RuboCop from erroneously indenting hash target
|
||||||
delegate [ # rubocop:disable Layout/HashAlignment
|
delegate [ # rubocop:disable Layout/HashAlignment
|
||||||
:bottle_defined?,
|
:bottle_defined?,
|
||||||
:bottle_tag?,
|
:bottle_tag?,
|
||||||
@ -469,6 +475,13 @@ class Formula
|
|||||||
# @see .version
|
# @see .version
|
||||||
delegate version: :active_spec
|
delegate version: :active_spec
|
||||||
|
|
||||||
|
# Stop RuboCop from erroneously indenting hash target
|
||||||
|
delegate [ # rubocop:disable Layout/HashAlignment
|
||||||
|
:allow_network_access!,
|
||||||
|
:deny_network_access!,
|
||||||
|
:network_access_allowed?,
|
||||||
|
] => :"self.class"
|
||||||
|
|
||||||
# Whether this formula was loaded using the formulae.brew.sh API
|
# Whether this formula was loaded using the formulae.brew.sh API
|
||||||
# @!method loaded_from_api?
|
# @!method loaded_from_api?
|
||||||
# @private
|
# @private
|
||||||
@ -3145,6 +3158,9 @@ class Formula
|
|||||||
@skip_clean_paths = Set.new
|
@skip_clean_paths = Set.new
|
||||||
@link_overwrite_paths = Set.new
|
@link_overwrite_paths = Set.new
|
||||||
@loaded_from_api = false
|
@loaded_from_api = false
|
||||||
|
@network_access_allowed = SUPPORTED_NETWORK_ACCESS_PHASES.to_h do |phase|
|
||||||
|
[phase, DEFAULT_NETWORK_ACCESS_ALLOWED]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -3225,6 +3241,59 @@ class Formula
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @!attribute [w] allow_network_access!
|
||||||
|
# The phases for which network access is allowed. By default, network
|
||||||
|
# access is allowed for all phases. Valid phases are `:build`, `:test`,
|
||||||
|
# and `:postinstall`. When no argument is passed, network access will be
|
||||||
|
# allowed for all phases.
|
||||||
|
# <pre>allow_network_access!</pre>
|
||||||
|
# <pre>allow_network_access! :build</pre>
|
||||||
|
# <pre>allow_network_access! [:build, :test]</pre>
|
||||||
|
sig { params(phases: T.any(Symbol, T::Array[Symbol])).void }
|
||||||
|
def allow_network_access!(phases = [])
|
||||||
|
phases_array = Array(phases)
|
||||||
|
if phases_array.empty?
|
||||||
|
@network_access_allowed.each_key { |phase| @network_access_allowed[phase] = true }
|
||||||
|
else
|
||||||
|
phases_array.each do |phase|
|
||||||
|
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)
|
||||||
|
|
||||||
|
@network_access_allowed[phase] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @!attribute [w] deny_network_access!
|
||||||
|
# The phases for which network access is denied. By default, network
|
||||||
|
# access is allowed for all phases. Valid phases are `:build`, `:test`,
|
||||||
|
# and `:postinstall`. When no argument is passed, network access will be
|
||||||
|
# denied for all phases.
|
||||||
|
# <pre>deny_network_access!</pre>
|
||||||
|
# <pre>deny_network_access! :build</pre>
|
||||||
|
# <pre>deny_network_access! [:build, :test]</pre>
|
||||||
|
sig { params(phases: T.any(Symbol, T::Array[Symbol])).void }
|
||||||
|
def deny_network_access!(phases = [])
|
||||||
|
phases_array = Array(phases)
|
||||||
|
if phases_array.empty?
|
||||||
|
@network_access_allowed.each_key { |phase| @network_access_allowed[phase] = false }
|
||||||
|
else
|
||||||
|
phases_array.each do |phase|
|
||||||
|
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)
|
||||||
|
|
||||||
|
@network_access_allowed[phase] = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether the specified phase should be forced offline.
|
||||||
|
sig { params(phase: Symbol).returns(T::Boolean) }
|
||||||
|
def network_access_allowed?(phase)
|
||||||
|
raise ArgumentError, "Unknown phase: #{phase}" unless SUPPORTED_NETWORK_ACCESS_PHASES.include?(phase)
|
||||||
|
|
||||||
|
env_var = Homebrew::EnvConfig.send(:"formula_#{phase}_network")
|
||||||
|
env_var.nil? ? @network_access_allowed[phase] : env_var == "allow"
|
||||||
|
end
|
||||||
|
|
||||||
# @!attribute [w] homepage
|
# @!attribute [w] homepage
|
||||||
# The homepage for the software. Used by users to get more information
|
# The homepage for the software. Used by users to get more information
|
||||||
# about the software and Homebrew maintainers as a point of contact for
|
# about the software and Homebrew maintainers as a point of contact for
|
||||||
|
@ -925,7 +925,7 @@ on_request: installed_on_request?, options:)
|
|||||||
formula.specified_path,
|
formula.specified_path,
|
||||||
].concat(build_argv)
|
].concat(build_argv)
|
||||||
|
|
||||||
Utils.safe_fork do
|
Utils.safe_fork do |error_pipe|
|
||||||
if Sandbox.available?
|
if Sandbox.available?
|
||||||
sandbox = Sandbox.new
|
sandbox = Sandbox.new
|
||||||
formula.logs.mkpath
|
formula.logs.mkpath
|
||||||
@ -937,6 +937,7 @@ on_request: installed_on_request?, options:)
|
|||||||
sandbox.allow_fossil
|
sandbox.allow_fossil
|
||||||
sandbox.allow_write_xcode
|
sandbox.allow_write_xcode
|
||||||
sandbox.allow_write_cellar(formula)
|
sandbox.allow_write_cellar(formula)
|
||||||
|
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:build)
|
||||||
sandbox.exec(*args)
|
sandbox.exec(*args)
|
||||||
else
|
else
|
||||||
exec(*args)
|
exec(*args)
|
||||||
@ -1151,7 +1152,7 @@ on_request: installed_on_request?, options:)
|
|||||||
|
|
||||||
args << post_install_formula_path
|
args << post_install_formula_path
|
||||||
|
|
||||||
Utils.safe_fork do
|
Utils.safe_fork do |error_pipe|
|
||||||
if Sandbox.available?
|
if Sandbox.available?
|
||||||
sandbox = Sandbox.new
|
sandbox = Sandbox.new
|
||||||
formula.logs.mkpath
|
formula.logs.mkpath
|
||||||
@ -1161,6 +1162,7 @@ on_request: installed_on_request?, options:)
|
|||||||
sandbox.allow_write_xcode
|
sandbox.allow_write_xcode
|
||||||
sandbox.deny_write_homebrew_repository
|
sandbox.deny_write_homebrew_repository
|
||||||
sandbox.allow_write_cellar(formula)
|
sandbox.allow_write_cellar(formula)
|
||||||
|
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:postinstall)
|
||||||
Keg::KEG_LINK_DIRECTORIES.each do |dir|
|
Keg::KEG_LINK_DIRECTORIES.each do |dir|
|
||||||
sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}"
|
sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}"
|
||||||
end
|
end
|
||||||
|
@ -91,6 +91,32 @@ class Sandbox
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
sig { params(path: T.any(String, Pathname), type: Symbol).void }
|
||||||
|
def allow_network(path:, type: :literal)
|
||||||
|
add_rule allow: true, operation: "network*", filter: path_filter(path, type)
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { params(path: T.any(String, Pathname), type: Symbol).void }
|
||||||
|
def deny_network(path:, type: :literal)
|
||||||
|
add_rule allow: false, operation: "network*", filter: path_filter(path, type)
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def allow_all_network
|
||||||
|
add_rule allow: true, operation: "network*"
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { void }
|
||||||
|
def deny_all_network
|
||||||
|
add_rule allow: false, operation: "network*"
|
||||||
|
end
|
||||||
|
|
||||||
|
sig { params(path: T.any(String, Pathname)).void }
|
||||||
|
def deny_all_network_except_pipe(path)
|
||||||
|
deny_all_network
|
||||||
|
allow_network path:, type: :literal
|
||||||
|
end
|
||||||
|
|
||||||
def exec(*args)
|
def exec(*args)
|
||||||
seatbelt = Tempfile.new(["homebrew", ".sb"], HOMEBREW_TEMP)
|
seatbelt = Tempfile.new(["homebrew", ".sb"], HOMEBREW_TEMP)
|
||||||
seatbelt.write(@profile.dump)
|
seatbelt.write(@profile.dump)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require "cmd/shared_examples/args_parse"
|
require "cmd/shared_examples/args_parse"
|
||||||
require "dev-cmd/test"
|
require "dev-cmd/test"
|
||||||
|
require "sandbox"
|
||||||
|
|
||||||
RSpec.describe Homebrew::DevCmd::Test do
|
RSpec.describe Homebrew::DevCmd::Test do
|
||||||
it_behaves_like "parseable arguments"
|
it_behaves_like "parseable arguments"
|
||||||
@ -18,4 +19,19 @@ RSpec.describe Homebrew::DevCmd::Test do
|
|||||||
.and not_to_output.to_stderr
|
.and not_to_output.to_stderr
|
||||||
.and be_a_success
|
.and be_a_success
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "blocks network access when test phase is offline", :integration_test do
|
||||||
|
if Sandbox.available?
|
||||||
|
install_test_formula "testball_offline_test", <<~RUBY
|
||||||
|
deny_network_access! :test
|
||||||
|
test do
|
||||||
|
system "curl", "example.org"
|
||||||
|
end
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
expect { brew "test", "--verbose", "testball_offline_test" }
|
||||||
|
.to output(/curl: \(6\) Could not resolve host: example\.org/).to_stdout
|
||||||
|
.and be_a_failure
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -3,11 +3,13 @@
|
|||||||
require "formula"
|
require "formula"
|
||||||
require "formula_installer"
|
require "formula_installer"
|
||||||
require "keg"
|
require "keg"
|
||||||
|
require "sandbox"
|
||||||
require "tab"
|
require "tab"
|
||||||
require "cmd/install"
|
require "cmd/install"
|
||||||
require "test/support/fixtures/testball"
|
require "test/support/fixtures/testball"
|
||||||
require "test/support/fixtures/testball_bottle"
|
require "test/support/fixtures/testball_bottle"
|
||||||
require "test/support/fixtures/failball"
|
require "test/support/fixtures/failball"
|
||||||
|
require "test/support/fixtures/failball_offline_install"
|
||||||
|
|
||||||
RSpec.describe FormulaInstaller do
|
RSpec.describe FormulaInstaller do
|
||||||
matcher :be_poured_from_bottle do
|
matcher :be_poured_from_bottle do
|
||||||
@ -70,6 +72,10 @@ RSpec.describe FormulaInstaller do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
specify "offline installation" do
|
||||||
|
expect { temporary_install(FailballOfflineInstall.new) }.to raise_error(BuildError) if Sandbox.available?
|
||||||
|
end
|
||||||
|
|
||||||
specify "Formula is not poured from bottle when compiler specified" do
|
specify "Formula is not poured from bottle when compiler specified" do
|
||||||
temporary_install(TestballBottle.new, cc: "clang") do |f|
|
temporary_install(TestballBottle.new, cc: "clang") do |f|
|
||||||
tab = Tab.for_formula(f)
|
tab = Tab.for_formula(f)
|
||||||
|
@ -42,6 +42,7 @@ RSpec.describe Formula do
|
|||||||
expect(f.alias_name).to be_nil
|
expect(f.alias_name).to be_nil
|
||||||
expect(f.full_alias_name).to be_nil
|
expect(f.full_alias_name).to be_nil
|
||||||
expect(f.specified_path).to eq(path)
|
expect(f.specified_path).to eq(path)
|
||||||
|
[:build, :test, :postinstall].each { |phase| expect(f.network_access_allowed?(phase)).to be(true) }
|
||||||
expect { klass.new }.to raise_error(ArgumentError)
|
expect { klass.new }.to raise_error(ArgumentError)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -55,6 +56,7 @@ RSpec.describe Formula do
|
|||||||
expect(f_alias.specified_path).to eq(Pathname(alias_path))
|
expect(f_alias.specified_path).to eq(Pathname(alias_path))
|
||||||
expect(f_alias.full_alias_name).to eq(alias_name)
|
expect(f_alias.full_alias_name).to eq(alias_name)
|
||||||
expect(f_alias.full_specified_name).to eq(alias_name)
|
expect(f_alias.full_specified_name).to eq(alias_name)
|
||||||
|
[:build, :test, :postinstall].each { |phase| expect(f_alias.network_access_allowed?(phase)).to be(true) }
|
||||||
expect { klass.new }.to raise_error(ArgumentError)
|
expect { klass.new }.to raise_error(ArgumentError)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1895,4 +1897,39 @@ RSpec.describe Formula do
|
|||||||
expect(f.fish_completion/"testball.fish").to be_a_file
|
expect(f.fish_completion/"testball.fish").to be_a_file
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "{allow,deny}_network_access" do
|
||||||
|
phases = [:build, :postinstall, :test].freeze
|
||||||
|
actions = %w[allow deny].freeze
|
||||||
|
phases.each do |phase|
|
||||||
|
actions.each do |action|
|
||||||
|
it "can #{action} network access for #{phase}" do
|
||||||
|
f = Class.new(Testball) do
|
||||||
|
send(:"#{action}_network_access!", phase)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(f.network_access_allowed?(phase)).to be(action == "allow")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
actions.each do |action|
|
||||||
|
it "can #{action} network access for all phases" do
|
||||||
|
f = Class.new(Testball) do
|
||||||
|
send(:"#{action}_network_access!")
|
||||||
|
end
|
||||||
|
|
||||||
|
phases.each do |phase|
|
||||||
|
expect(f.network_access_allowed?(phase)).to be(action == "allow")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#network_access_allowed?" do
|
||||||
|
it "throws an error when passed an invalid symbol" do
|
||||||
|
f = Testball.new
|
||||||
|
expect { f.network_access_allowed?(:foo) }.to raise_error(ArgumentError)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
# typed: true
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class FailballOfflineInstall < Formula
|
||||||
|
def initialize(name = "failball_offline_install", path = Pathname.new(__FILE__).expand_path, spec = :stable,
|
||||||
|
alias_path: nil, tap: nil, force_bottle: false)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
DSL_PROC = proc do
|
||||||
|
url "file://#{TEST_FIXTURE_DIR}/tarballs/testball-0.1.tbz"
|
||||||
|
sha256 TESTBALL_SHA256
|
||||||
|
deny_network_access! :build
|
||||||
|
end.freeze
|
||||||
|
private_constant :DSL_PROC
|
||||||
|
|
||||||
|
DSL_PROC.call
|
||||||
|
|
||||||
|
def self.inherited(other)
|
||||||
|
super
|
||||||
|
other.instance_eval(&DSL_PROC)
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
system "curl", "example.org"
|
||||||
|
|
||||||
|
prefix.install "bin"
|
||||||
|
prefix.install "libexec"
|
||||||
|
Dir.chdir "doc"
|
||||||
|
end
|
||||||
|
end
|
@ -37,15 +37,15 @@ module Utils
|
|||||||
pid = fork do
|
pid = fork do
|
||||||
# bootsnap doesn't like these forked processes
|
# bootsnap doesn't like these forked processes
|
||||||
ENV["HOMEBREW_NO_BOOTSNAP"] = "1"
|
ENV["HOMEBREW_NO_BOOTSNAP"] = "1"
|
||||||
|
error_pipe = server.path
|
||||||
ENV["HOMEBREW_ERROR_PIPE"] = server.path
|
ENV["HOMEBREW_ERROR_PIPE"] = error_pipe
|
||||||
server.close
|
server.close
|
||||||
read.close
|
read.close
|
||||||
write.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
write.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
||||||
|
|
||||||
Process::UID.change_privilege(Process.euid) if Process.euid != Process.uid
|
Process::UID.change_privilege(Process.euid) if Process.euid != Process.uid
|
||||||
|
|
||||||
yield
|
yield(error_pipe)
|
||||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||||
error_hash = JSON.parse e.to_json
|
error_hash = JSON.parse e.to_json
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user