mirror of
https://github.com/Homebrew/brew.git
synced 2026-08-12 22:29:27 +04:00
Sandbox structured cask operations
- Run each complete cask step block in one isolated subprocess and all generated completions in another phase-scoped sandbox. - Share sandbox selection, fork fallback, install-hook rules and child error reporting with formula build, post-install and test processes. - Restrict home, network and filesystem access while preserving `brew` and supporting explicit command write paths. - Keep JSON payloads compact and independent of cask Ruby files. - Remove the completed official-tap migration plan.
This commit is contained in:
@@ -12,10 +12,8 @@ require_relative "global"
|
||||
require "build_options"
|
||||
require "keg"
|
||||
require "extend/ENV"
|
||||
require "fcntl"
|
||||
require "utils/socket"
|
||||
require "cmd/install"
|
||||
require "json/add/exception"
|
||||
require "utils/fork"
|
||||
require "utils/output"
|
||||
require "extend/pathname/write_mkpath_extension"
|
||||
|
||||
@@ -264,8 +262,7 @@ begin
|
||||
args = Homebrew::Cmd::InstallCmd.new.args
|
||||
Context.current = args.context
|
||||
|
||||
error_pipe = Utils::UNIXSocketExt.open(ENV.fetch("HOMEBREW_ERROR_PIPE"), &:recv_io)
|
||||
error_pipe.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
||||
error_pipe = Utils.forked_child_error_pipe
|
||||
|
||||
trap("INT", old_trap)
|
||||
|
||||
@@ -283,32 +280,6 @@ begin
|
||||
# Any exception means the build did not complete.
|
||||
# The `case` for what to do per-exception class is further down.
|
||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||
error_hash = JSON.parse e.to_json
|
||||
|
||||
# Special case: need to recreate BuildErrors in full
|
||||
# for proper analytics reporting and error messages.
|
||||
# BuildErrors are specific to build processes and not other
|
||||
# children, which is why we create the necessary state here
|
||||
# and not in Utils.safe_fork.
|
||||
case e
|
||||
when BuildError
|
||||
error_hash["cmd"] = e.cmd
|
||||
error_hash["args"] = e.args
|
||||
error_hash["env"] = e.env
|
||||
when ErrorDuringExecution
|
||||
error_hash["cmd"] = e.cmd
|
||||
error_hash["status"] = if e.status.is_a?(Process::Status)
|
||||
{
|
||||
exitstatus: e.exitstatus,
|
||||
termsig: e.termsig,
|
||||
}
|
||||
else
|
||||
e.status
|
||||
end
|
||||
error_hash["output"] = e.output
|
||||
end
|
||||
|
||||
error_pipe&.puts error_hash.to_json
|
||||
error_pipe&.close
|
||||
Utils.report_forked_child_error(error_pipe, e)
|
||||
exit! 1
|
||||
end
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
require "extend/object/deep_dup"
|
||||
require "env_config"
|
||||
require "json"
|
||||
require "sandbox"
|
||||
require "tempfile"
|
||||
require "tmpdir"
|
||||
require "utils/output"
|
||||
|
||||
@@ -203,50 +203,47 @@ module Cask
|
||||
|
||||
sig { returns(T.nilable(Sandbox)) }
|
||||
def cask_sandbox
|
||||
return unless Sandbox.available?
|
||||
return unless Sandbox.use_for?("running cask artifact operations")
|
||||
|
||||
Sandbox.new.tap do |sandbox|
|
||||
sandbox.allow_read(path: cask.staged_path, type: :subpath)
|
||||
sandbox.allow_write_temp_and_cache
|
||||
sandbox.deny_read_home
|
||||
sandbox.deny_all_network
|
||||
sandbox.add_install_hook_rules(network_access_allowed: false)
|
||||
end
|
||||
end
|
||||
|
||||
sig {
|
||||
params(
|
||||
env: T::Hash[String, T.any(String, T::Boolean, PATH)],
|
||||
args: T::Array[T.any(String, Pathname)],
|
||||
home: String,
|
||||
).returns(T::Array[T.any(String, Pathname)])
|
||||
}
|
||||
def cask_sandbox_command(env, args, home:)
|
||||
env = { "HOME" => home }.merge(env)
|
||||
["/usr/bin/env", *env.map { |key, value| "#{key}=#{value}" }, *args]
|
||||
end
|
||||
|
||||
sig {
|
||||
params(
|
||||
sandbox: Sandbox,
|
||||
args: T::Array[T.any(String, Pathname)],
|
||||
input: T.any(String, T::Array[String]),
|
||||
payload: T::Hash[String, T.untyped],
|
||||
).void
|
||||
}
|
||||
def run_cask_sandbox(sandbox, args, input: [])
|
||||
return sandbox.run(*args) if Array(input).empty?
|
||||
def run_cask_sandbox(sandbox, payload)
|
||||
# Formulae sandbox the complete `postinstall.rb` process. Do the same
|
||||
# for cask operations so Ruby file changes and every command share one
|
||||
# profile, instead of forwarding command input and output through files.
|
||||
Dir.mktmpdir("homebrew-cask-sandbox", HOMEBREW_TEMP) do |temporary_directory|
|
||||
temporary_path = Pathname(temporary_directory)
|
||||
home = temporary_path/"home"
|
||||
payload_path = temporary_path/"payload.json"
|
||||
home.mkpath
|
||||
payload_path.write(JSON.generate(payload))
|
||||
sandbox.allow_read(path: payload_path)
|
||||
|
||||
Tempfile.create("homebrew-cask-script-input", HOMEBREW_TEMP) do |input_file|
|
||||
input_file.write(Array(input).join)
|
||||
input_file.close
|
||||
sandbox.allow_read(path: input_file.path)
|
||||
sandbox.run(
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"input=$1; shift; exec \"$@\" < \"$input\"",
|
||||
"sh",
|
||||
input_file.path,
|
||||
*args,
|
||||
)
|
||||
# The payload carries only structured data, not a cask `.rb` file.
|
||||
# Set HOME before starting this child so its boot process and any
|
||||
# commands it runs cannot discover the user's real home directory.
|
||||
Sandbox.with_preserved_brew_file do
|
||||
sandbox.run(
|
||||
"/usr/bin/env",
|
||||
"HOME=#{home}",
|
||||
"nice",
|
||||
*HOMEBREW_RUBY_EXEC_ARGS,
|
||||
"-I", $LOAD_PATH.join(File::PATH_SEPARATOR),
|
||||
"--",
|
||||
HOMEBREW_LIBRARY_PATH/"cask_artifact.rb",
|
||||
payload_path
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ require "cask/artifact/bashcompletion"
|
||||
require "cask/artifact/fishcompletion"
|
||||
require "cask/artifact/zshcompletion"
|
||||
require "extend/hash/keys"
|
||||
require "tempfile"
|
||||
require "utils/shell_completion"
|
||||
|
||||
module Cask
|
||||
@@ -92,19 +91,33 @@ module Cask
|
||||
sig { params(_options: T.untyped).void }
|
||||
def install_phase(**_options)
|
||||
executable = staged_path_join_executable(commands.fetch(0))
|
||||
|
||||
shells.each do |shell|
|
||||
completion_commands = [executable, *commands[1..]]
|
||||
completions = shells.map do |shell|
|
||||
popen_read_env = { "SHELL" => shell.to_s }
|
||||
shell_parameter = ::Utils::ShellCompletion.completion_shell_parameter(
|
||||
shell_parameter_format, shell, executable.to_s, popen_read_env
|
||||
)
|
||||
|
||||
script_path = completion_script_path(shell)
|
||||
script_path.dirname.mkpath
|
||||
script_path.write(generate_completion_output([executable, *commands[1..]], shell_parameter, popen_read_env))
|
||||
rescue => e
|
||||
opoo "Failed to generate #{shell} completions from #{executable}: #{e}"
|
||||
{
|
||||
"shell" => shell.to_s,
|
||||
"commands" => completion_commands.map(&:to_s),
|
||||
"shell_parameter" => ::Utils::ShellCompletion.completion_shell_parameter(
|
||||
shell_parameter_format, shell, executable.to_s, popen_read_env
|
||||
),
|
||||
"env" => popen_read_env,
|
||||
"output_path" => completion_script_path(shell).to_s,
|
||||
}
|
||||
end
|
||||
|
||||
if (sandbox = cask_sandbox)
|
||||
completions.map { |completion| Pathname(completion.fetch("output_path")).dirname }.uniq.each do |directory|
|
||||
sandbox.allow_write_path directory
|
||||
end
|
||||
begin
|
||||
run_cask_sandbox(sandbox, { "action" => "generated_completions", "completions" => completions })
|
||||
rescue => e
|
||||
opoo e
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
completions.each { |completion| write_completion(completion, executable) }
|
||||
end
|
||||
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.untyped).void }
|
||||
@@ -121,39 +134,17 @@ module Cask
|
||||
|
||||
private
|
||||
|
||||
sig {
|
||||
params(
|
||||
completion_commands: T::Array[T.any(Pathname, String)],
|
||||
shell_parameter: T.nilable(T.any(String, T::Array[String])),
|
||||
env: T::Hash[String, String],
|
||||
).returns(String)
|
||||
}
|
||||
def generate_completion_output(completion_commands, shell_parameter, env)
|
||||
sandbox = cask_sandbox
|
||||
unless sandbox
|
||||
return ::Utils::ShellCompletion.generate_completion_output(completion_commands, shell_parameter,
|
||||
env)
|
||||
end
|
||||
|
||||
Tempfile.create("homebrew-cask-completions", HOMEBREW_TEMP) do |output|
|
||||
Dir.mktmpdir("homebrew-cask-home") do |home|
|
||||
sandbox.run(
|
||||
*cask_sandbox_command(
|
||||
env,
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"output=$1; shift; exec \"$@\" > \"$output\"#{" 2>/dev/null" unless ENV["HOMEBREW_STDERR"]}",
|
||||
"sh",
|
||||
output.path,
|
||||
*(completion_commands + Array(shell_parameter)),
|
||||
],
|
||||
home:,
|
||||
),
|
||||
)
|
||||
end
|
||||
output.read
|
||||
end
|
||||
sig { params(completion: T::Hash[String, T.untyped], executable: Pathname).void }
|
||||
def write_completion(completion, executable)
|
||||
output_path = Pathname(completion.fetch("output_path"))
|
||||
output_path.dirname.mkpath
|
||||
output_path.write(
|
||||
::Utils::ShellCompletion.generate_completion_output(
|
||||
completion.fetch("commands"), completion["shell_parameter"], completion.fetch("env")
|
||||
),
|
||||
)
|
||||
rescue => e
|
||||
opoo "Failed to generate #{completion.fetch("shell")} completions from #{executable}: #{e}"
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
require "cask/artifact/abstract_artifact"
|
||||
require "install_steps"
|
||||
require "keg"
|
||||
|
||||
module Cask
|
||||
module Artifact
|
||||
@@ -29,9 +30,40 @@ module Cask
|
||||
|
||||
private
|
||||
|
||||
sig { params(command: T.class_of(SystemCommand)).returns(Homebrew::InstallSteps::Runner) }
|
||||
def runner(command)
|
||||
Homebrew::InstallSteps::Runner.new(context: cask, command:)
|
||||
sig { params(command: T.class_of(SystemCommand), phase: Symbol).void }
|
||||
def run_steps(command, phase: :install)
|
||||
runner = Homebrew::InstallSteps::Runner.new(context: cask, command:)
|
||||
sandbox = cask_sandbox
|
||||
unless sandbox
|
||||
runner.run(steps, phase:)
|
||||
return
|
||||
end
|
||||
|
||||
sandbox.allow_write_path cask.caskroom_path
|
||||
sandbox.allow_write_path cask.config.appdir
|
||||
Keg.keg_link_directories.each { |directory| sandbox.allow_write_path HOMEBREW_PREFIX/directory }
|
||||
original_home = Pathname(Dir.home).expand_path
|
||||
runner.sandbox_write_paths(steps, phase:).each do |path|
|
||||
sandbox.allow_write_path path
|
||||
sandbox.allow_read(path:, type: :subpath) if path.expand_path.ascend.include?(original_home)
|
||||
end
|
||||
run_cask_sandbox(
|
||||
sandbox,
|
||||
{
|
||||
"action" => "install_steps",
|
||||
"context" => {
|
||||
"name" => cask.name,
|
||||
"token" => cask.token,
|
||||
"version" => cask.version.to_s,
|
||||
"staged_path" => cask.staged_path.to_s,
|
||||
"caskroom_path" => cask.caskroom_path.to_s,
|
||||
"home" => Dir.home,
|
||||
"config" => cask.config.to_json,
|
||||
},
|
||||
"phase" => phase.to_s,
|
||||
"steps" => steps,
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,12 +71,12 @@ module Cask
|
||||
class PreflightSteps < AbstractInstallSteps
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.anything).void }
|
||||
def install_phase(command: SystemCommand, **_options)
|
||||
runner(command).run(steps)
|
||||
run_steps(command)
|
||||
end
|
||||
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.anything).void }
|
||||
def uninstall_phase(command: SystemCommand, **_options)
|
||||
runner(command).run(steps, phase: :uninstall)
|
||||
run_steps(command, phase: :uninstall)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,12 +84,12 @@ module Cask
|
||||
class PostflightSteps < AbstractInstallSteps
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.anything).void }
|
||||
def install_phase(command: SystemCommand, **_options)
|
||||
runner(command).run(steps)
|
||||
run_steps(command)
|
||||
end
|
||||
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.anything).void }
|
||||
def uninstall_phase(command: SystemCommand, **_options)
|
||||
runner(command).run(steps, phase: :uninstall)
|
||||
run_steps(command, phase: :uninstall)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,7 +97,7 @@ module Cask
|
||||
class UninstallPreflightSteps < AbstractInstallSteps
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.anything).void }
|
||||
def uninstall_phase(command: SystemCommand, **_options)
|
||||
runner(command).run(steps)
|
||||
run_steps(command)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -73,7 +105,7 @@ module Cask
|
||||
class UninstallPostflightSteps < AbstractInstallSteps
|
||||
sig { params(command: T.class_of(SystemCommand), _options: T.anything).void }
|
||||
def uninstall_phase(command: SystemCommand, **_options)
|
||||
runner(command).run(steps)
|
||||
run_steps(command)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
raise "#{__FILE__} must not be loaded via `require`." if $PROGRAM_NAME != __FILE__
|
||||
|
||||
old_trap = trap("INT") { exit! 130 }
|
||||
|
||||
require_relative "global"
|
||||
|
||||
require "json"
|
||||
require "cask/config"
|
||||
require "extend/ENV"
|
||||
require "install_steps"
|
||||
require "utils/fork"
|
||||
require "utils/shell_completion"
|
||||
|
||||
module Cask
|
||||
# Minimal cask state needed to resolve structured install-step paths and tokens.
|
||||
class InstallStepsContext
|
||||
sig { returns(T.any(String, T::Array[String])) }
|
||||
attr_reader :name
|
||||
|
||||
sig { returns(String) }
|
||||
attr_reader :token
|
||||
|
||||
sig { returns(String) }
|
||||
attr_reader :version
|
||||
|
||||
sig { returns(Pathname) }
|
||||
attr_reader :staged_path
|
||||
|
||||
sig { returns(Pathname) }
|
||||
attr_reader :caskroom_path
|
||||
|
||||
sig { returns(Pathname) }
|
||||
attr_reader :home
|
||||
|
||||
sig { returns(Config) }
|
||||
attr_reader :config
|
||||
|
||||
sig { params(context: T::Hash[String, T.untyped]).void }
|
||||
def initialize(context)
|
||||
@name = T.let(context.fetch("name"), T.any(String, T::Array[String]))
|
||||
@token = T.let(context.fetch("token"), String)
|
||||
@version = T.let(context.fetch("version"), String)
|
||||
@staged_path = T.let(Pathname(context.fetch("staged_path")), Pathname)
|
||||
@caskroom_path = T.let(Pathname(context.fetch("caskroom_path")), Pathname)
|
||||
@home = T.let(Pathname(context.fetch("home")), Pathname)
|
||||
@config = T.let(Config.from_json(context.fetch("config")), Config)
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
def to_s = token
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
error_pipe = Utils.forked_child_error_pipe
|
||||
|
||||
trap("INT", old_trap)
|
||||
|
||||
# Match formula post-install isolation inside the sandboxed child. The
|
||||
# original cask context is supplied in JSON and never needs a `.rb` file.
|
||||
ENV["TMPDIR"] = HOMEBREW_TEMP.to_s
|
||||
ENV["TEMP"] = HOMEBREW_TEMP.to_s
|
||||
ENV["TMP"] = HOMEBREW_TEMP.to_s
|
||||
ENV.delete("HOMEBREW_PATH")
|
||||
ENV["PATH"] = PATH.new(ORIGINAL_PATHS).to_s
|
||||
ENV.clear_sensitive_environment!
|
||||
ENV.activate_extensions!
|
||||
Pathname.activate_extensions!
|
||||
|
||||
payload = T.cast(JSON.parse(Pathname(ARGV.fetch(0)).read), T::Hash[String, T.untyped])
|
||||
case payload.fetch("action")
|
||||
when "install_steps"
|
||||
context = Cask::InstallStepsContext.new(payload.fetch("context"))
|
||||
steps = payload.fetch("steps")
|
||||
phase = payload.fetch("phase").to_sym
|
||||
Homebrew::InstallSteps::Runner.new(context:).run(steps, phase:)
|
||||
when "generated_completions"
|
||||
errors = []
|
||||
payload.fetch("completions").each do |completion|
|
||||
commands = completion.fetch("commands")
|
||||
output_path = Pathname(completion.fetch("output_path"))
|
||||
output_path.dirname.mkpath
|
||||
output_path.write(
|
||||
Utils::ShellCompletion.generate_completion_output(
|
||||
commands, completion["shell_parameter"], completion.fetch("env")
|
||||
),
|
||||
)
|
||||
rescue => e
|
||||
errors << "Failed to generate #{completion.fetch("shell")} completions from #{commands.fetch(0)}: #{e}"
|
||||
end
|
||||
raise errors.join("\n") unless errors.empty?
|
||||
else
|
||||
raise ArgumentError, "unknown sandboxed cask action: #{payload.fetch("action")}"
|
||||
end
|
||||
|
||||
# Handle all possible exceptions.
|
||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||
Utils.report_forked_child_error(error_pipe, e)
|
||||
exit! 1
|
||||
end
|
||||
@@ -82,8 +82,11 @@ module Homebrew
|
||||
|
||||
exec_args << "--HEAD" if f.head?
|
||||
|
||||
if Sandbox.available?
|
||||
sandbox = Sandbox.new
|
||||
Sandbox.run_or_fork(
|
||||
*exec_args,
|
||||
step: "testing #{f.full_name}",
|
||||
warn_without_sandbox: false,
|
||||
) do |sandbox|
|
||||
f.logs.mkpath
|
||||
sandbox.record_log(f.logs/"test.sandbox.log")
|
||||
sandbox.allow_write_temp_and_cache
|
||||
@@ -95,11 +98,6 @@ module Homebrew
|
||||
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
|
||||
Utils.safe_fork do
|
||||
exec(*exec_args)
|
||||
end
|
||||
end
|
||||
# Rescue any possible exception types.
|
||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||
|
||||
@@ -1631,7 +1631,9 @@ class Formula
|
||||
PATH: PATH.new(ORIGINAL_PATHS),
|
||||
}
|
||||
|
||||
Dir.mktmpdir("#{name}-postinstall-") do |home|
|
||||
# Formula post-install creates its isolated HOME inside the child because
|
||||
# the entire `postinstall.rb` process is already sandboxed by its parent.
|
||||
Dir.mktmpdir("#{name}-postinstall-", HOMEBREW_TEMP) do |home|
|
||||
postinstall_home = Pathname(home)
|
||||
new_env[:HOME] = postinstall_home.to_s
|
||||
new_env.merge!(common_sandbox_env(postinstall_home))
|
||||
|
||||
@@ -1151,8 +1151,7 @@ on_request: installed_on_request?, options:)
|
||||
formula_path,
|
||||
].concat(build_argv)
|
||||
|
||||
if use_sandbox?("building")
|
||||
sandbox = Sandbox.new
|
||||
Sandbox.run_or_fork(*args, step: "building") do |sandbox|
|
||||
sandbox.allow_read_if_exists path: formula_path
|
||||
if Homebrew::EnvConfig.require_tap_trust?
|
||||
require "trust"
|
||||
@@ -1172,11 +1171,6 @@ on_request: installed_on_request?, options:)
|
||||
sandbox.allow_write_xcode
|
||||
sandbox.allow_write_cellar(formula)
|
||||
sandbox.deny_all_network unless formula.network_access_allowed?(:build)
|
||||
sandbox.run(*args)
|
||||
else
|
||||
Utils.safe_fork do
|
||||
exec(*args)
|
||||
end
|
||||
end
|
||||
|
||||
formula.update_head_version
|
||||
@@ -1405,26 +1399,19 @@ on_request: installed_on_request?, options:)
|
||||
|
||||
args << post_install_formula_path
|
||||
|
||||
with_preserved_brew_file do
|
||||
if use_sandbox?("running post-install")
|
||||
sandbox = Sandbox.new
|
||||
Sandbox.with_preserved_brew_file do
|
||||
Sandbox.run_or_fork(*args, step: "running post-install") do |sandbox|
|
||||
formula.logs.mkpath
|
||||
sandbox.record_log(formula.logs/"postinstall.sandbox.log")
|
||||
sandbox.allow_write_temp_and_cache
|
||||
sandbox.allow_write_log(formula)
|
||||
sandbox.allow_write_xcode
|
||||
sandbox.deny_write_homebrew_repository
|
||||
sandbox.deny_read_home
|
||||
sandbox.allow_write_cellar(formula)
|
||||
sandbox.deny_all_network unless formula.network_access_allowed?(:postinstall)
|
||||
sandbox.add_install_hook_rules(
|
||||
network_access_allowed: formula.network_access_allowed?(:postinstall),
|
||||
)
|
||||
Keg.keg_link_directories.each do |dir|
|
||||
sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}"
|
||||
end
|
||||
sandbox.run(*args)
|
||||
else
|
||||
Utils.safe_fork do
|
||||
exec(*args)
|
||||
end
|
||||
end
|
||||
end
|
||||
# Handle all possible exceptions when postinstall does not complete.
|
||||
@@ -1840,61 +1827,6 @@ on_request: installed_on_request?, options:)
|
||||
|
||||
private
|
||||
|
||||
# Landlock cannot protect `bin/brew` while allowing post-install writes to
|
||||
# `bin`, so a malicious post-install could replace `brew` to persist into
|
||||
# later invocations. Snapshot and restore the entry, using a pre-opened `bin`
|
||||
# descriptor to restore its mode before repairing the entry if needed.
|
||||
sig { params(block: T.proc.void).void }
|
||||
def with_preserved_brew_file(&block)
|
||||
return yield if Sandbox.full_write_isolation?
|
||||
|
||||
brew_file = HOMEBREW_PREFIX/"bin/brew"
|
||||
File.open(brew_file.dirname) do |brew_directory|
|
||||
brew_directory_mode = brew_directory.stat.mode & 07777
|
||||
symlink = brew_file.symlink?
|
||||
contents = if symlink
|
||||
brew_file.readlink.to_s
|
||||
else
|
||||
brew_file.binread
|
||||
end
|
||||
brew_file_mode = brew_file.lstat.mode & 07777
|
||||
|
||||
begin
|
||||
yield
|
||||
ensure
|
||||
brew_directory.chmod brew_directory_mode
|
||||
if symlink && (!brew_file.symlink? || brew_file.readlink.to_s != contents)
|
||||
FileUtils.rm_rf brew_file
|
||||
brew_file.make_symlink contents
|
||||
elsif !symlink && (brew_file.symlink? || !brew_file.file? || brew_file.binread != contents ||
|
||||
(brew_file.lstat.mode & 07777) != brew_file_mode)
|
||||
FileUtils.rm_rf brew_file
|
||||
brew_file.atomic_write contents
|
||||
brew_file.chmod brew_file_mode
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Whether to run the given install `step` (e.g. `"building"`) inside
|
||||
# Homebrew's sandbox. Warns when it will not: noting reliance on the outer
|
||||
# sandbox when `$HOMEBREW_AVOID_NESTED_SANDBOXING` skips a nested sandbox,
|
||||
# otherwise that no sandbox is available.
|
||||
sig { params(step: String).returns(T::Boolean) }
|
||||
def use_sandbox?(step)
|
||||
unless Sandbox.available?
|
||||
opoo "Sandbox unavailable: #{step} without sandboxing!"
|
||||
return false
|
||||
end
|
||||
|
||||
if Sandbox.avoid_nested_sandboxing?
|
||||
opoo "#{step.capitalize} without Homebrew's sandbox; relying on the outer sandbox."
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def auto_link_versioned_keg_only?
|
||||
return false unless installed_on_request?
|
||||
|
||||
@@ -189,7 +189,7 @@ module Homebrew
|
||||
when Symbol
|
||||
obj.to_s
|
||||
when Array
|
||||
if %w[guards paths].include?(key)
|
||||
if %w[guards paths writable_paths].include?(key)
|
||||
obj.map { |value| normalise_path_value(value) }
|
||||
else
|
||||
obj.map(&:to_s)
|
||||
@@ -662,20 +662,22 @@ module Homebrew
|
||||
|
||||
sig {
|
||||
params(
|
||||
command: ::T.any(::String, ::Pathname),
|
||||
args: ::T::Array[::T.any(::String, ::Pathname)],
|
||||
base: ::T.nilable(::T.any(::String, ::Symbol)),
|
||||
env: ::T::Hash[::String, ::String],
|
||||
sudo: ::T::Boolean,
|
||||
print_stdout: ::T::Boolean,
|
||||
print_stderr: ::T::Boolean,
|
||||
stdin_path: ::T.nilable(::T.any(::String, ::Pathname)),
|
||||
stdout_path: ::T.nilable(::T.any(::String, ::Pathname)),
|
||||
chdir: ::T.nilable(::T.any(::String, ::Pathname)),
|
||||
command: ::T.any(::String, ::Pathname),
|
||||
args: ::T::Array[::T.any(::String, ::Pathname)],
|
||||
base: ::T.nilable(::T.any(::String, ::Symbol)),
|
||||
env: ::T::Hash[::String, ::String],
|
||||
sudo: ::T::Boolean,
|
||||
print_stdout: ::T::Boolean,
|
||||
print_stderr: ::T::Boolean,
|
||||
stdin_path: ::T.nilable(::T.any(::String, ::Pathname)),
|
||||
stdout_path: ::T.nilable(::T.any(::String, ::Pathname)),
|
||||
chdir: ::T.nilable(::T.any(::String, ::Pathname)),
|
||||
writable_paths: Paths,
|
||||
writable_base: ::T.nilable(::T.any(::String, ::Symbol)),
|
||||
).void
|
||||
}
|
||||
def run(command, args: [], base: nil, env: {}, sudo: false, print_stdout: false, print_stderr: true,
|
||||
stdin_path: nil, stdout_path: nil, chdir: nil)
|
||||
stdin_path: nil, stdout_path: nil, chdir: nil, writable_paths: [], writable_base: nil)
|
||||
add_step("run",
|
||||
"command" => path_spec(command, base:, default_base: nil),
|
||||
"args" => args.map(&:to_s),
|
||||
@@ -685,7 +687,12 @@ module Homebrew
|
||||
"suppress_stderr" => !print_stderr,
|
||||
"stdin_path" => optional_path_spec(stdin_path, default_base: @default_base),
|
||||
"stdout_path" => optional_path_spec(stdout_path, default_base: @default_base),
|
||||
"chdir" => optional_path_spec(chdir, default_base: @default_base))
|
||||
"chdir" => optional_path_spec(chdir, default_base: @default_base),
|
||||
"writable_paths" => path_specs(
|
||||
writable_paths,
|
||||
base: writable_base,
|
||||
default_base: @default_base,
|
||||
))
|
||||
end
|
||||
|
||||
sig {
|
||||
@@ -878,6 +885,47 @@ module Homebrew
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(steps: Steps, phase: Symbol).returns(T::Array[Pathname]) }
|
||||
def sandbox_write_paths(steps, phase: :install)
|
||||
DSL.normalise_steps(steps).flat_map do |step|
|
||||
if phase == :uninstall
|
||||
next [] if step["type"] != "symlink" || step["uninstall"] != true
|
||||
|
||||
next [resolve_path(step_path(step, "target")).parent]
|
||||
end
|
||||
|
||||
case step.fetch("type")
|
||||
when "mkdir", "mkdir_p", "touch", "write"
|
||||
[resolve_path(step_path(step, "path")).parent]
|
||||
when "move"
|
||||
[resolve_path(step_path(step, "source")).parent, resolve_path(step_path(step, "target")).parent]
|
||||
when "move_children", "move_contents"
|
||||
[resolve_path(step_path(step, "source")), resolve_path(step_path(step, "target"))]
|
||||
when "copy", "symlink"
|
||||
[resolve_path(step_path(step, "target")).parent]
|
||||
when "remove"
|
||||
step_paths(step, "paths").flat_map { |path| expand_path_glob(path) }.map(&:parent)
|
||||
when "inreplace", "change_dylib_id"
|
||||
key = (step["type"] == "inreplace") ? "path" : "source"
|
||||
[resolve_path(step_path(step, key))]
|
||||
when "link_dir", "link_children"
|
||||
[resolve_path(step_path(step, "target"))]
|
||||
when "run"
|
||||
paths = step.key?("stdout_path") ? [resolve_path(step_path(step, "stdout_path")).parent] : []
|
||||
if step.key?("writable_paths")
|
||||
paths.concat(step_paths(step, "writable_paths").map do |path|
|
||||
resolve_path(path)
|
||||
end)
|
||||
end
|
||||
paths
|
||||
when "set_permissions", "set_ownership"
|
||||
existing_step_paths(step)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end.uniq
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig { params(step: Step).void }
|
||||
@@ -1443,7 +1491,7 @@ module Homebrew
|
||||
def root_path(base, formula)
|
||||
case base
|
||||
when "home"
|
||||
Pathname(Dir.home)
|
||||
context_value(:home) ? context_path(base) : Pathname(Dir.home)
|
||||
when "temp"
|
||||
HOMEBREW_TEMP
|
||||
when "homebrew_prefix"
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
# JSON API Postinstall/Preflight/Postflight Plan
|
||||
|
||||
This plan tracks repeated Ruby-only install behaviours that can be expressed as
|
||||
structured DSL data and exposed through the JSON APIs.
|
||||
|
||||
Install step data is stored as an ordered array of step hashes. Ruby hashes
|
||||
preserve insertion order, but the outer array makes JSON ordering explicit for
|
||||
API consumers in any language.
|
||||
|
||||
The first implemented high-level DSL is named `steps`, exposed as
|
||||
`post_install_steps` for formulae and as `preflight_steps`, `postflight_steps`,
|
||||
`uninstall_preflight_steps` and `uninstall_postflight_steps` for casks. The
|
||||
blocks are deliberately narrow: they may only contain literal calls to the
|
||||
step DSL, with no wider Ruby execution and no access to the surrounding formula
|
||||
or cask DSL.
|
||||
|
||||
The canonical step methods follow existing Formula, Cask, `Pathname`,
|
||||
`FileUtils`, `SystemCommand` and utility naming where practical. Shared file
|
||||
operations use `mkdir_p`, `touch`, `move`, `move_contents`, `copy`, `remove`,
|
||||
`inreplace`, `symlink`, `symlink_tree`, `symlink_children` and `write_file`.
|
||||
Formula steps should specify `base: :var` for paths under `var`, while
|
||||
source/target paths default to `prefix`. Cask steps default `base`,
|
||||
`source_base` and `target_base` to `staged_path`.
|
||||
|
||||
Formula `post_install_steps` may temporarily coexist with `post_install` so tap
|
||||
conversions can peel supported repeated statements out of larger hooks. Runtime
|
||||
handling runs formula steps first and then runs `post_install` last for the
|
||||
remaining Ruby work. Cask `*flight_steps` also temporarily coexist with the
|
||||
matching legacy flight block and run before it. Formula post-install steps run
|
||||
in the same sandboxed subprocess as the remaining `post_install` hook,
|
||||
preserving its filesystem and network restrictions for structured Ruby
|
||||
operations and any commands they invoke. Future cask work should sandbox all
|
||||
`*flight` run scripts from non-Homebrew and non-system sources, for example
|
||||
scripts shipped by upstream artifacts.
|
||||
|
||||
The final target is not to keep legacy hooks and structured steps side by side.
|
||||
Once `homebrew/core` and `homebrew/cask` have been converted, all
|
||||
`homebrew/core` `post_install` blocks and all `homebrew/cask` legacy
|
||||
`preflight`, `postflight`, `uninstall_preflight` and `uninstall_postflight`
|
||||
blocks should be removed. Only after all five legacy hook counts reach zero at
|
||||
the current tap heads should `Homebrew/brew` reject side-by-side usage again or
|
||||
deprecate the legacy hooks for third-party taps.
|
||||
|
||||
During the temporary bridge, structured steps must appear before the matching
|
||||
legacy block to make the runtime order obvious: `post_install_steps` before
|
||||
`post_install`, and each cask `*flight_steps` stanza before its matching legacy
|
||||
`*flight` stanza.
|
||||
|
||||
For each future operation type, check `homebrew/core` and `homebrew/cask`
|
||||
separately and add formula support only when needed by `homebrew/core` or cask
|
||||
support only when needed by `homebrew/cask`.
|
||||
|
||||
Named specialised actions require at least `2` current package usages across
|
||||
`homebrew/core` and `homebrew/cask` before being added to the structured DSL.
|
||||
One formula and one cask count as two usages. A named action or artifact used by
|
||||
only one formula or cask should be refactored into generic steps or a packaged
|
||||
helper. Orthogonal options on generic primitives, such as command input or a
|
||||
symlink guard, are judged by the distinct behaviours they serialise rather than
|
||||
by one exact keyword spelling.
|
||||
|
||||
When adding install step DSL methods, update the matching RuboCop allow-list so
|
||||
formula or cask tap syntax checks accept the new method in the same context.
|
||||
|
||||
Once an install step DSL method has shipped in a stable `Homebrew/brew`
|
||||
release, keep accepting and executing it throughout this migration. A better
|
||||
replacement may stop being documented and become the target of tap migrations,
|
||||
but the old method must remain marked with `# odeprecated` until it can be
|
||||
deprecated in a later release. Each capability PR must continue to pass
|
||||
tap-wide syntax checks against the default branches of `homebrew/core` and
|
||||
`homebrew/cask`; paired tap branches cannot provide atomic compatibility. The
|
||||
final official-hook enforcement PR is the deliberate exception: keep it at the
|
||||
top of the stack and red until both tap migrations have landed.
|
||||
|
||||
RuboCop autocorrection converts the simplest existing `post_install` and
|
||||
`*flight` Ruby blocks to steps blocks when every statement is a supported file
|
||||
preparation operation with literal paths and known bases. Future post-install
|
||||
and `*flight` DSLs should include the same style of conservative autocorrection
|
||||
from the matching legacy Ruby pattern where possible.
|
||||
|
||||
Before opening follow-up PRs, run `bundle exec rake lint` from `docs/` to catch
|
||||
markdown lint issues and run `brew style homebrew/core homebrew/cask` to catch
|
||||
tap-wide formula or cask opportunities exposed by the new DSLs.
|
||||
|
||||
## Migration Pull Request Workflow
|
||||
|
||||
Finish all DSL implementation work before enforcement, autocorrection or
|
||||
bridge conflicts. For each capability, the `Homebrew/brew` implementation must
|
||||
include the serialised data shape, runner or artifact behaviour, literal-block
|
||||
allow-list entries, tests and public documentation. At the same time, apply the
|
||||
candidate conversion to local `homebrew/core` and `homebrew/cask` migration
|
||||
branches and rescan the residual hooks. If the scan exposes another reusable
|
||||
behaviour, add the generic DSL or shared action and repeat until all five hook
|
||||
searches are empty.
|
||||
|
||||
Unique complex formula logic belongs in a deterministic helper packaged in the
|
||||
bottle and invoked by `run`. Prefer an existing cask artifact or a
|
||||
`generated_script` for unique cask installer logic. Do not create a named DSL
|
||||
action to hide a one-package algorithm.
|
||||
|
||||
The delivery order is now:
|
||||
|
||||
1. Review and merge the `Homebrew/brew` capability commits in order. Every
|
||||
commit is below `300` insertions and includes tests, documentation and a
|
||||
description suitable for an independent pull request.
|
||||
1. Cut a stable `Homebrew/brew` release containing the complete DSL stack.
|
||||
1. Refresh and merge the committed `homebrew/core` and `homebrew/cask` stacks
|
||||
in their recorded order. A tap file containing several step types is
|
||||
assigned to the latest brew capability it needs so every intermediate tap
|
||||
commit remains loadable.
|
||||
1. Keep the official-tap enforcement PR at the top of the stack. Its tap syntax
|
||||
job is expected to fail until both tap stacks reach zero legacy hooks, then
|
||||
it can merge without changing runtime compatibility.
|
||||
1. Add conflicts or legacy-hook deprecations only after the merged tap heads,
|
||||
rather than only local branches, pass the zero-hook gate.
|
||||
|
||||
The `Homebrew/brew` implementation stack is:
|
||||
|
||||
| Commit, PR or branch | Capability |
|
||||
| --- | --- |
|
||||
| `3f237af118` | scoped path and platform guards |
|
||||
| `a51ad058fa` | shared path, token and privilege handling |
|
||||
| `f98b955d42` | copies |
|
||||
| `af66235c7e` | removals |
|
||||
| `390c74bd49` | `inreplace` steps |
|
||||
| `e05c08f986` | recursive install-step validation |
|
||||
| `c33846e9da` | cask command wrappers with declared names |
|
||||
| `eeaae4a0dc` | generated cask scripts |
|
||||
| `677dad25a5` | formula permission steps |
|
||||
| `cf5bc21c6b` | constrained commands |
|
||||
| `2c2089fc74` | process termination |
|
||||
| `46b43d57d0` | scoped warnings |
|
||||
| `7dc5f2bbaf` | GCC runtime configuration and idempotence |
|
||||
| `a959228644` | gzipped executable installation |
|
||||
| `d39b823008` | glibc runtime setup |
|
||||
| `b312752fc0` | Clang system configuration |
|
||||
| `Homebrew/brew#23191` | PHP configuration |
|
||||
| `Homebrew/brew#23192` | Python bootstrap |
|
||||
| `Homebrew/brew#23193` | compatibility and canonical interface alignment |
|
||||
| `Homebrew/brew#23194` | temporary cask flight migration bridge |
|
||||
| `Homebrew/brew#23195` | canonical autocorrection and validation |
|
||||
| `Homebrew/brew#23196` | canonical cookbook documentation |
|
||||
| `install-step-25-compact-json` | compact structured step payloads |
|
||||
| `install-step-26-official-hook-enforcement` | reject new legacy hooks in official taps |
|
||||
|
||||
### Compatibility bridge
|
||||
|
||||
Step methods and values already released from `origin/main` remain accepted by
|
||||
the Ruby DSL and by serialised API payloads during the migration. Their public
|
||||
cookbook entries have been removed and their source definitions are marked
|
||||
with `# odeprecated` so a later release can emit deprecations without combining
|
||||
that change with this capability stack. This includes the former short file
|
||||
operation names, cache action names, broad `name` token, keychain certificate
|
||||
name and database initialiser values.
|
||||
|
||||
Canonical calls preserve shipped behaviour where the method name remains the
|
||||
same. In particular, `move` replaces an existing destination by default, like
|
||||
`FileUtils.mv`; `copy` follows the same convention and both accept
|
||||
`overwrite: false` to reject replacement. Compatibility-only names remain
|
||||
valid in literal-block checks but are omitted from their public error message.
|
||||
Spellings introduced only on this unmerged branch are changed directly rather
|
||||
than retained as aliases.
|
||||
|
||||
The `homebrew/core` branch `install-step-migrations` is split into this stack:
|
||||
|
||||
| Commit | Migration | Brew dependency |
|
||||
| --- | --- | --- |
|
||||
| `8b2a3736ad2` | copies | `e05c08f986` |
|
||||
| `a410f86f0e5` | removals | `e05c08f986` |
|
||||
| `8fff4cfe6ac` | `inreplace` steps | `e05c08f986` |
|
||||
| `36a86e4cd3` | formula permissions | `677dad25a5` |
|
||||
| `b737b6ddfc5` | commands | `cf5bc21c6b` |
|
||||
| `de01d0cb71f` | process termination | `2c2089fc74` |
|
||||
| `99f7e7ee196` | config warnings | `46b43d57d0` |
|
||||
| `954c3eeb0e1` | GCC runtimes | `7dc5f2bbaf` |
|
||||
| `60b9fd667cb` | gzipped executables | `a959228644` |
|
||||
| `12494317fea` | glibc runtime setup | `d39b823008` |
|
||||
| `1ae21b8bacc` | Clang configs | `b312752fc0` |
|
||||
| `b1363e7f414` | PHP configuration | `Homebrew/brew#23191` |
|
||||
| `54b3a12d201` | Python bootstrap | `Homebrew/brew#23192` |
|
||||
| `b6f242bc378` | existing rebuild actions | `Homebrew/brew#23193` |
|
||||
| `fba7166ace8` | canonical names | `Homebrew/brew#23193` |
|
||||
|
||||
The `homebrew/cask` branch `install-step-migrations` is split into this stack:
|
||||
|
||||
| Commit | Migration | Brew dependency |
|
||||
| --- | --- | --- |
|
||||
| `98460ee76f7` | removals | `e05c08f986` |
|
||||
| `c4600c11d1c` | `inreplace` steps | `e05c08f986` |
|
||||
| `cc918a9697d` | command wrappers | `c33846e9da` |
|
||||
| `1010ee37a29` | generated scripts | `eeaae4a0dc` |
|
||||
| `a6dda33a841` | commands and mixed steps | `cf5bc21c6b` |
|
||||
| `e6787710ae2` | process termination | `2c2089fc74` |
|
||||
| `46ac0e3034b` | existing structured flight steps | `Homebrew/brew#23193` |
|
||||
| `79ac6f5fc5c` | canonical names | `Homebrew/brew#23193` |
|
||||
|
||||
`gcloud-cli` is the only cask copy user, but its matching flight blocks also
|
||||
need removal, linking and command steps. It therefore lands in the command
|
||||
commit rather than an earlier cask-only copy commit. The generic `copy` step is
|
||||
not single-package DSL because three formulae use it too.
|
||||
|
||||
Verify each local tap pass with tap-wide `./bin/brew style` and
|
||||
`./bin/brew readall`. Run targeted audits when the tap changes are split into
|
||||
their own review branches. Before opening a follow-up PR, run the documentation
|
||||
lint and the full `Homebrew/brew` verification described in `AGENTS.md`.
|
||||
|
||||
## Legacy Hook Removal Gate
|
||||
|
||||
The zero-hook gate is stricter than a scan for side-by-side legacy and steps
|
||||
blocks. The baseline audit used `homebrew/core` at `2603b0ce7788`, with `8,470`
|
||||
formula files and `82` `post_install` methods, and `homebrew/cask` at
|
||||
`892cff1a33bb`, with `7,701` cask files and `146` legacy flight blocks in `124`
|
||||
casks.
|
||||
|
||||
The refreshed local tap migrations at `homebrew/core` `fba7166ace8` and
|
||||
`homebrew/cask` `79ac6f5fc5c` now pass the gate:
|
||||
|
||||
- `homebrew/core` has `0` `post_install` methods after converting all `86`
|
||||
hook-bearing files.
|
||||
- `homebrew/cask` has `0` `preflight`, `postflight`, `uninstall_preflight` or
|
||||
`uninstall_postflight` blocks after converting all `121` hook-bearing files.
|
||||
- Tap-wide style and `readall` checks pass for both migrations.
|
||||
|
||||
The compatibility naming pass also updates structured-step users that did not
|
||||
have a legacy hook. The complete local stacks therefore differ from their tap
|
||||
heads in `140` formula files and `137` cask files.
|
||||
|
||||
The final brew-only audit on 30 July 2026 found `21` formula hooks at
|
||||
`homebrew/core` `2927a618306` and `12` cask flight blocks at `homebrew/cask`
|
||||
`13de0293aa0`. Every remaining file is already covered by the prepared
|
||||
migration stacks, but those stacks now conflict with their moving tap heads and
|
||||
must be rebased during PR 29. Until that happens, the official-hook enforcement
|
||||
PR is expected to fail tap syntax.
|
||||
|
||||
This proves that the implemented DSL is sufficient, but it does not authorise
|
||||
bridge conflicts yet. The tap stacks must first be reviewed and merged against
|
||||
current heads after a stable `Homebrew/brew` release contains the new DSL. The
|
||||
same zero-hook scans must then pass again at the merged tap heads.
|
||||
|
||||
Do not add conflict enforcement, change runtime precedence or deprecate a
|
||||
legacy hook while any of these searches returns a result:
|
||||
|
||||
```sh
|
||||
rg -n '^\s+def post_install\b' Library/Taps/homebrew/homebrew-core/Formula
|
||||
for hook in preflight postflight uninstall_preflight uninstall_postflight; do
|
||||
rg -n "^\s+${hook}\b" Library/Taps/homebrew/homebrew-cask/Casks
|
||||
done
|
||||
```
|
||||
|
||||
Refresh the counts when preparing the tap review branches because new hooks may
|
||||
have landed since this local audit. Closing the bridge requires all five
|
||||
searches to be empty at the merged tap heads, tap `readall` and style checks to
|
||||
pass and the zero result to be recorded here.
|
||||
|
||||
## Completed Formula DSL Work
|
||||
|
||||
The `82` formula hooks in the baseline were inspected as syntax trees. These
|
||||
buckets overlap because a hook can use several kinds of operation:
|
||||
|
||||
- `63` hooks make `119` command or command-output calls.
|
||||
- `31` create directories, `23` remove paths, `26` create or maintain links,
|
||||
`17` change permissions, `17` replace file content, `16` write files, `13`
|
||||
copy or install paths, `5` touch files and `3` move paths.
|
||||
- Existing actions should be re-applied to cache work in `easy-tag`, `gtk+3`
|
||||
and `sysprof`, and the existing MySQL initialiser should cover the bootstrap
|
||||
portion of both Percona hooks. The completed conversion combines those
|
||||
existing actions with the new generic primitives.
|
||||
|
||||
The repeated formula families now use named, data-only operations:
|
||||
|
||||
- `8` GCC formulae generate runtime links and specs files.
|
||||
- `8` formulae unpack a compressed executable and then install it with the
|
||||
required mode.
|
||||
- `5` PHP formulae configure shared PEAR and PECL state.
|
||||
- `5` Python-family formulae bootstrap packaging state: `3` CPython and `2`
|
||||
PyPy formulae.
|
||||
- `4` LLVM formulae generate platform configuration files.
|
||||
- `3` glibc formulae generate locales and maintain host timezone links.
|
||||
|
||||
The GHC cache refreshes and other reusable command shapes use generic `run`
|
||||
steps. Individual XML catalogue, CA bundle, package cache, Mach-O relocation
|
||||
and service transaction algorithms are deterministic helpers installed into
|
||||
their bottles and invoked by `run`. This removed the hooks without adding
|
||||
formula-specific one-use DSL actions.
|
||||
|
||||
## Completed Cask DSL Work
|
||||
|
||||
The `146` cask flight blocks in the baseline were also inspected as syntax
|
||||
trees. The overlapping capability buckets are:
|
||||
|
||||
- `70` blocks make `75` file writes. `66` of those writes generate command
|
||||
wrappers in `63` casks, `5` generate installer or uninstaller scripts in `4`
|
||||
casks and `4` rewrite other files in `3` casks.
|
||||
- `53` blocks make `69` command calls. Repeated groups include `10` `pkill`
|
||||
calls, `4` `killall` calls, `8` Parallels `inittool` calls, `7` Parallels
|
||||
`chflags` calls, `7` Parallels `xattr` calls and `4` `gcloud` calls.
|
||||
- `16` blocks remove paths, `8` create links, `8` enumerate globs or children,
|
||||
`6` move paths, `6` change permissions or ownership and `1` copies paths.
|
||||
|
||||
The migration uses `67` `command_wrapper` artifacts in `64` casks and `6`
|
||||
`generated_script` artifacts in `5` casks. Generic guards, matching removal,
|
||||
temporary-path moves and uninstall-aware symlinks preserve conditional cleanup
|
||||
and state. App-bundled helpers use `run`, while repeated termination behaviour
|
||||
uses `terminate_process`.
|
||||
|
||||
## Completed Local Migration Workstreams
|
||||
|
||||
The zero-hook local tap result uses these capabilities:
|
||||
|
||||
1. Guarded path mutation with `copy`, `remove`, `inreplace`,
|
||||
globs, collections, ownership, permissions and serialised predicates.
|
||||
1. Cask `command_wrapper` and `generated_script` artifacts for owned
|
||||
wrapper and helper scripts.
|
||||
1. Literal command execution with arguments, environment, standard input and
|
||||
output paths, working directory, platform and path guards. Sandbox and
|
||||
advanced runner controls remain later non-DSL changes.
|
||||
1. `terminate_process` for the `16` repeated termination calls and packaged
|
||||
helpers for multi-command service transactions.
|
||||
1. Shared GCC, compressed-executable, glibc, Clang, PHP and Python formula
|
||||
actions, with generic commands or packaged helpers for the long tail.
|
||||
1. Matching cleanup, state preservation and uninstall-aware symlinks using the
|
||||
generic path primitives.
|
||||
1. Repeated rescans and conversions until all five legacy-hook searches became
|
||||
empty.
|
||||
|
||||
The final syntax-tree usage audit found no specialised install-step method or
|
||||
artifact used by only one package. The narrowest methods are `symlink_children`,
|
||||
`update_desktop_database`, `update_mime_database` and `warn`, each used by two
|
||||
formulae. `copy` has seven calls across three formulae and one cask. Formula
|
||||
`set_permissions` has three users;
|
||||
`set_ownership` remains cask-only because no formula needs it and `36` casks
|
||||
already use it.
|
||||
|
||||
The former one-user GIO cache action was refactored to `run`; the one-use
|
||||
architecture token was replaced by a generic glob, the one-formula
|
||||
non-overwriting copy option was replaced by `unless_path_exists` and the
|
||||
one-cask fallible command was moved into the existing `uninstall` artifact.
|
||||
Generic command and guard options may have a single current spelling without
|
||||
encoding a package-specific algorithm. `stdin_path`, `stdout_path`, `chdir`
|
||||
and `sudo: :if_needed` each serialise an orthogonal file or command behaviour
|
||||
rather than a package-specific action.
|
||||
|
||||
The one apparent exception is `on_linux`, currently used by `mono` only. It is
|
||||
retained because it is the existing Formula DSL spelling and the symmetric
|
||||
counterpart to `on_macos`, which has three users; it is a generic platform
|
||||
scope rather than a package action. A combined `on_system` spelling would be
|
||||
less consistent with Formula syntax without reducing the runner surface.
|
||||
|
||||
## API Source Download Gates
|
||||
|
||||
Formula JSON API installs load `post_install_steps` through `FormulaStruct` and
|
||||
run them without downloading formula Ruby. Bottles retain the formula stored in
|
||||
the keg only for legacy `post_install` compatibility. Source builds and local
|
||||
patch handling still use `Homebrew::API::Formula.source_download_formula` for
|
||||
build-time reasons outside this post-install DSL work.
|
||||
|
||||
Cask JSON API installs use `Homebrew::API::Cask.source_download_cask` when
|
||||
`Cask#caskfile_only?` is true. Legacy `preflight`, `postflight`,
|
||||
`uninstall_preflight` and `uninstall_postflight` blocks need the source because
|
||||
API data only records that a block exists, not the Ruby body. Current API data
|
||||
stores each language block's locale group, default marker, return value and
|
||||
resulting stanza differences, so language-specific URLs can be resolved before
|
||||
the download is enqueued. Older API data with only the flat `languages` array
|
||||
continues to download source as a compatibility fallback. Once the official tap
|
||||
contains only structured flight steps, those artifacts do not make
|
||||
`Cask#caskfile_only?` true and cask Ruby is not downloaded.
|
||||
|
||||
Formula structs omit empty steps and false legacy-hook markers. Cask structs
|
||||
store artifacts as compact positional arrays and omit blank fields. Individual
|
||||
steps omit values supplied by runner defaults while retaining values, such as
|
||||
the canonical `move` overwrite default, that are required to distinguish them
|
||||
from already released payloads.
|
||||
|
||||
## Installed Cask Metadata Format
|
||||
|
||||
Store supported installed cask metadata as regular `<token>.json`, not Ruby
|
||||
caskfiles or internal JSON. Casks with `uninstall_preflight` or
|
||||
`uninstall_postflight` Ruby blocks should keep using Ruby caskfiles in the
|
||||
Caskroom until those blocks are ported to structured JSON data. The installed
|
||||
caskfile is a post-install snapshot, so it should only retain data that can be
|
||||
useful after installation has finished. This lets future uninstall, reinstall,
|
||||
upgrade and zap runs reload supported installed metadata without evaluating the
|
||||
original Ruby caskfile.
|
||||
|
||||
The installed JSON is deliberately minimal. It relies on
|
||||
`INSTALL_RECEIPT.json` for receipt-owned data such as the installed cask
|
||||
`version` and uninstallable artifacts, and only keeps data not otherwise
|
||||
available after installation, such as `url_specs.only_path` when needed to
|
||||
reconstruct staged artifact sources. It omits the full API snapshot so future
|
||||
JSON API or DSL changes cannot affect post-install operations through fields
|
||||
that are not needed after installation.
|
||||
|
||||
The installed JSON omits legacy `preflight` and `postflight` Ruby block
|
||||
placeholders because JSON cannot represent their block bodies and they are not
|
||||
needed after installation. Casks with `uninstall_preflight` or
|
||||
`uninstall_postflight` Ruby blocks must remain backed by Ruby metadata so those
|
||||
blocks continue to run on uninstall, zap, reinstall and upgrade. The goal is to
|
||||
replace those Ruby blocks with structured uninstall step DSLs so they can be
|
||||
migrated to JSON too.
|
||||
|
||||
The `brew update` migration should convert existing supported Caskroom `.rb`
|
||||
and `.internal.json` caskfiles to regular `.json` caskfiles.
|
||||
|
||||
As the cask step DSLs grow, keep migrating post-install behaviour from legacy
|
||||
Ruby flight blocks into structured JSON data so less installed cask behaviour
|
||||
is stripped during metadata serialisation.
|
||||
|
||||
## Install Step Examples
|
||||
|
||||
- `Formula/l/languagetool.rb`: `post_install_steps` with
|
||||
`mkdir_p "log/languagetool", base: :var`.
|
||||
- `Formula/i/icecast.rb`: `post_install_steps` with one `mkdir_p` and two
|
||||
`touch` steps under `var/"log/icecast"`.
|
||||
- `Formula/o/openssl@3.rb`: `post_install_steps` with an overwriting `symlink`
|
||||
from `ca-certificates` `pkgetc/"cert.pem"` into the formula `pkgetc`.
|
||||
- `Casks/8/86box.rb`: `preflight_steps` with a home-directory `mkdir_p` for
|
||||
the shared ROM directory.
|
||||
- `Casks/k/klayout.rb`: `preflight_steps` with `move_contents` from the
|
||||
staged root into the nested `KLayout` directory.
|
||||
- `Casks/l/libcblite.rb`: `postflight_steps` with relative `symlink` steps
|
||||
marked for uninstall cleanup.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] PR 1, shared install steps framework.
|
||||
Commit: `Add install steps framework`.
|
||||
Scope: shared ordered step data, a confined steps DSL, a shared runner, cask
|
||||
stanza ordering, RuboCop registration, migration bridge ordering and the
|
||||
refactor plan.
|
||||
This PR does not wire formula or cask JSON API output or run steps from
|
||||
install phases.
|
||||
Estimated existing formulae/casks affected: `0` runtime behaviour changes.
|
||||
It created the guardrails for the then-current `144` formulae with
|
||||
`post_install` blocks and `170` casks with flight blocks, but no existing
|
||||
formula or cask opted into the new DSL yet.
|
||||
Notes for the next PRs: keep the step payload as an ordered array; keep
|
||||
`_steps` blocks literal-only; for formulae, steps run before a remaining
|
||||
`post_install` hook during the temporary bridge; for casks, steps run before
|
||||
the matching legacy Ruby block. Add conservative autocorrection
|
||||
only where every legacy statement maps mechanically.
|
||||
- [x] PR 2, formula `post_install_steps`.
|
||||
Commit: `Add formula install steps`.
|
||||
Scope: formula DSL, formula JSON API data, API formula loading, installer and
|
||||
`brew postinstall` execution, formula cookbook docs and formula fixture.
|
||||
Estimated existing formulae affected: at implementation time, `144` formulae
|
||||
defined `post_install`. The first useful conversion surface was roughly `79`
|
||||
formulae creating shared directories; parts of the `19` service data
|
||||
directory and `17` certificate/trust formulae could also move once their
|
||||
operations fit the supported step set. Runtime behaviour changes only for
|
||||
formulae that opt into `post_install_steps`.
|
||||
Notes for implementation: formula definitions should specify `base: :var`
|
||||
explicitly for `mkdir_p`/`touch` and other single-path steps, while
|
||||
source/target paths default to `prefix`; expose the ordered array through
|
||||
`FormulaStruct`; make
|
||||
`post_install_steps` run before any remaining `post_install`; document that
|
||||
the two forms may coexist only as an incremental conversion bridge. Keep the
|
||||
tap-wide autocorrect audit in a follow-up commit so the implementation can
|
||||
land before converted formulae.
|
||||
- [x] PR 3, cask flight steps.
|
||||
Commit: `Add cask install steps`.
|
||||
Scope: cask artifacts for `preflight_steps`, `postflight_steps`,
|
||||
`uninstall_preflight_steps` and `uninstall_postflight_steps`, cask API
|
||||
serialisation through artifact data, installer casts, cask cookbook docs,
|
||||
cask fixture/API loader coverage.
|
||||
Estimated existing casks affected: at implementation time, `170` casks used
|
||||
flight blocks. The first useful conversion surface was roughly `68` casks
|
||||
that created or touched files or directories and the supported subset of
|
||||
`13` casks that moved or symlinked files. Runtime behaviour changed only for
|
||||
casks that opted into the new `*_steps` stanzas.
|
||||
Notes for implementation: default all relative cask paths to `staged_path`;
|
||||
keep steps as normal cask artifacts so API loader round-trips work; run steps
|
||||
before a matching Ruby flight artifact during migration; keep
|
||||
`remove_on_uninstall: true` symlink cleanup available for install-phase
|
||||
steps. Keep the tap-wide autocorrect audit in a follow-up commit so the
|
||||
implementation can land before converted casks.
|
||||
- [x] PR 4, desktop and cache rebuild actions.
|
||||
Estimated existing formulae/casks affected: about `27` formulae run rebuild
|
||||
tools such as `glib-compile-schemas`, `gtk*-update-icon-cache`,
|
||||
`gio-querymodules`, `gdk-pixbuf-query-loaders`, `update-mime-database` and
|
||||
`update-desktop-database`; no cask count was identified in the initial scan.
|
||||
Scope: shared named action types for GSettings schemas, GDK Pixbuf loaders,
|
||||
GTK icon caches, MIME databases and desktop databases, runner dispatch
|
||||
through Homebrew-owned tools and docs. The proposed GIO modules action was
|
||||
removed after the final tap audit found only one user; that formula uses
|
||||
generic `run` instead.
|
||||
Notes for implementation: define idempotence and failure handling; decide
|
||||
whether any action invokes non-Homebrew code and should be ready for future
|
||||
sandboxing. Land RuboCop autocorrection and tap-wide conversions in a
|
||||
separate follow-up after the new DSL methods are available in a stable
|
||||
Homebrew release.
|
||||
- [x] PR 4.1, formula install-step sandboxing.
|
||||
Commit: `Sandbox formula install steps`.
|
||||
Scope: run structured formula steps inside the existing post-install child
|
||||
process so macOS Seatbelt and Linux Landlock apply the same filesystem and
|
||||
network policy as legacy `post_install` hooks. This must land before any tap
|
||||
migrations use filesystem-mutating steps.
|
||||
- PR 5, default config and template writes (historical split workflow).
|
||||
Estimated existing formulae/casks affected: about `112` formulae write or
|
||||
patch default configuration/data files, and a subset of the `68` file-prep
|
||||
cask flight blocks write small files.
|
||||
Notes for implementation: use scoped token expansion instead of arbitrary
|
||||
Ruby interpolation; require literal templates or API-safe template data;
|
||||
define overwrite, `unless_path_exists` and upgrade semantics before adding
|
||||
autocorrection.
|
||||
- [x] PR 5.1, add the `write_file` DSL in `Homebrew/brew`.
|
||||
Commit: `Add install step config writes`.
|
||||
Scope: shared `write_file` step method with `base:`, exact atomic overwrite
|
||||
behaviour matching `Pathname#atomic_write`, formula and cask step block
|
||||
allow-list entries, non-interpolated heredoc (`dstr`) support so
|
||||
`write_file` content can use heredocs, runner tests and cookbook docs.
|
||||
`unless_path_exists` preserves user-edited files across upgrades. Content
|
||||
stays a literal template in the JSON API but supports a fixed `{{...}}`
|
||||
token allow-list (`HOMEBREW_PREFIX`, `prefix`, `opt_prefix`, `bin`, `var`,
|
||||
`etc`, `pkgetc`, `version`, `version.major_minor`; casks add `staged_path`
|
||||
and `appdir`) expanded at install time; any other `{{...}}` is left
|
||||
verbatim. Dynamic interpolation (random cookies, `popen`-derived paths,
|
||||
`File.read` rewrites) is intentionally out of scope and stays as legacy
|
||||
Ruby.
|
||||
- [x] PR 5.2, add the `write_file` enforcing RuboCops in `Homebrew/brew`.
|
||||
Commit: `Add install step write cops`.
|
||||
Scope: formula and cask RuboCops conservatively autocorrect literal,
|
||||
newline-terminated `.write`, `.atomic_write` and `File.write` legacy blocks
|
||||
to `*_steps` `write_file` calls. Content is preserved exactly.
|
||||
- [x] PR 5.3, convert `homebrew/core` formulae to `write_file`.
|
||||
Branch `install-steps-config-write`, commits
|
||||
`tronbyt-server: use post_install_steps` and `node@18: use
|
||||
post_install_steps`. `tronbyt-server` mapped with literal content;
|
||||
`node@18` became convertible once `{{HOMEBREW_PREFIX}}` token expansion
|
||||
landed (its whole `post_install` was one `atomic_write`). All other `.write`
|
||||
formulae interpolate paths, interpolate unsupported values, or run
|
||||
unsupported Ruby (`cp_r`, `inreplace`, `safe_popen_read`, loops).
|
||||
- [x] PR 5.4, convert `homebrew/cask` casks to `write_file`.
|
||||
Branch `install-steps-config-write`, commit
|
||||
`dnsmonitor: use postflight_steps`. Only `dnsmonitor` had a flight block
|
||||
with literal content. Token expansion does not unblock more casks: the
|
||||
`{{appdir}}`-content flight writes all target a `shimscript` local that is
|
||||
also wired to a `binary` stanza, and the literal-path LibreOffice packs
|
||||
interpolate an unsupported language `token` and run `system_command`.
|
||||
- [x] PR 6.1, database and service data directory initialisation.
|
||||
Commit: `Add install step data directories`.
|
||||
Estimated existing formulae/casks affected: about `19` formulae initialise
|
||||
service data directories.
|
||||
Scope: formula `init_data_dir` step, runner execution, formula step block
|
||||
allow-list entries, fixture coverage and formula cookbook docs. The step
|
||||
creates service data directories and supports named bootstrap commands for
|
||||
PostgreSQL `initdb`, MySQL `mysqld --initialize-insecure` and MariaDB
|
||||
`mysql_install_db`, including the marker-file and CI-skip guards used by
|
||||
current `homebrew/core` formulae. Permission and ownership metadata were
|
||||
skipped because current tap usages fit future permission/ownership action
|
||||
work instead. PostgreSQL versioned link maintenance is handled by generic
|
||||
`symlink_tree` and `symlink_children` steps. MySQL conflicting configuration
|
||||
warnings stay as legacy Ruby until a separate named action is added.
|
||||
Local tap work for this step was prepared with
|
||||
`./bin/brew tap --force homebrew/core` and
|
||||
`./bin/brew tap --force homebrew/cask`. In this checkout,
|
||||
`./bin/brew --repository homebrew/core` resolves to
|
||||
`Library/Taps/homebrew/homebrew-core` at `369b5855942`, and
|
||||
`./bin/brew --repository homebrew/cask` resolves to
|
||||
`Library/Taps/homebrew/homebrew-cask` at `16a3a6e4562`.
|
||||
Success target for tap conversions: use the bridge to move database
|
||||
bootstrap statements out of every current MySQL and PostgreSQL hook, including
|
||||
`Formula/m/mysql.rb`, `Formula/m/mysql@8.0.rb`, `Formula/m/mysql@8.4.rb`,
|
||||
`Formula/p/postgresql@17.rb` and `Formula/p/postgresql@18.rb`, while their
|
||||
remaining warning or link maintenance work stays in `post_install` until
|
||||
separate named actions cover it. Fully remove `post_install` from
|
||||
bootstrap-only formulae such as `Formula/m/mariadb.rb`,
|
||||
`Formula/m/mariadb@10.11.rb`, `Formula/m/mariadb@10.5.rb`,
|
||||
`Formula/m/mariadb@10.6.rb`, `Formula/m/mariadb@11.4.rb`,
|
||||
`Formula/m/mariadb@11.8.rb`, `Formula/p/postgresql@12.rb`,
|
||||
`Formula/p/postgresql@13.rb`, `Formula/p/postgresql@15.rb` and
|
||||
`Formula/p/postgresql@16.rb`. Verify the `homebrew/core` conversion with
|
||||
`./bin/brew style homebrew/core`, targeted `./bin/brew audit --strict
|
||||
--online --formula ...` for the changed formulae and `./bin/brew readall
|
||||
homebrew/core`.
|
||||
- [x] PR 6.2, database and link enforcement.
|
||||
Commit: `Add install step enforcement cops`.
|
||||
Scope: the formula install-step cop conservatively autocorrects recognised
|
||||
PostgreSQL, MySQL and MariaDB bootstrap statements to `init_data_dir`, and
|
||||
recognised PostgreSQL link maintenance to `symlink_tree` or
|
||||
`symlink_children`. Partial conversions preserve existing
|
||||
`post_install_steps` ordering and leave unsupported warning or maintenance
|
||||
work in `post_install`. Matching Percona bootstrap hooks remain unchanged
|
||||
because they were not part of the recorded MySQL formula conversion.
|
||||
- [x] PR 7.1, certificate and trust store actions.
|
||||
Commit: `Add install step keychain cleanup`.
|
||||
Estimated existing formulae/casks affected: about `17` formulae update
|
||||
certificate/trust state and `8` cask flight blocks invoke
|
||||
`/usr/bin/security` for keychain certificate cleanup.
|
||||
Scope: cask `delete_keychain_certificates` step, runner execution through
|
||||
fixed `/usr/bin/security find-certificate` and `delete-certificate` calls,
|
||||
optional local certificate fingerprint matching for selective deletion,
|
||||
cask step block allow-list entries and docs. Formula-owned `cert.pem`
|
||||
symlinks use `symlink` with `overwrite: true`, `source_formula` and
|
||||
`source_base: :formula_pkgetc`; specialised trust store generation such as
|
||||
`ca-certificates` bundle regeneration and Mono `cert-sync` stays legacy Ruby
|
||||
because current repeated usage is below the named-variant threshold.
|
||||
- [x] PR 7.2, certificate and keychain enforcement.
|
||||
Commit: `Add install step enforcement cops`.
|
||||
Scope: the cask install-step cop converts fixed `/usr/bin/security`
|
||||
certificate deletion flights to `delete_keychain_certificates`. The formula
|
||||
cop converts the three direct `pkgetc` certificate bundle replacements to
|
||||
forced `symlink` steps using `source_formula` and
|
||||
`source_base: :formula_pkgetc`. Dynamic paths, altered commands and
|
||||
specialised certificate generation remain unsupported.
|
||||
- [x] PR 8.1, cask permission and ownership actions.
|
||||
Commit: `Add cask permission steps`.
|
||||
Estimated existing casks affected: about `21` casks change permissions and
|
||||
`36` change ownership.
|
||||
Scope: cask `set_permissions` and `set_ownership` steps, path-array API
|
||||
normalisation, runner execution through `chmod` and `sudo chown`, cask
|
||||
installer command routing, App Management checks before ownership changes,
|
||||
cask step block allow-list entries, fixture/API loader coverage and cask
|
||||
cookbook docs. The steps skip missing paths like the existing flight
|
||||
mini-DSL. `set_ownership` defaults to the current user and `staff` group
|
||||
unless `user:` or `group:` are provided.
|
||||
Local tap work converted pure `set_permissions` and `set_ownership` flight
|
||||
blocks in `48` `homebrew/cask` casks. `homebrew/core` had no matching
|
||||
formula conversions for the cask-only DSL and was untapped after the clean
|
||||
scan. Remaining cask legacy blocks are `Casks/s/starnet++.rb`,
|
||||
`Casks/h/hummingbird.rb`, `Casks/m/mplabx-ide.rb` and
|
||||
`Casks/p/proxy-audio-device.rb`; they depend on unsupported local variables,
|
||||
architecture data or additional `system_command` work.
|
||||
- [x] PR 8.2, permission and ownership enforcement.
|
||||
Commit: `Add install step enforcement cops`.
|
||||
Scope: the cask install-step cop converts pure legacy flight blocks using
|
||||
`set_permissions` and `set_ownership` to matching `*_steps` blocks. Mixed
|
||||
flights, dynamic paths and unsupported arguments remain unchanged.
|
||||
- [x] PR 9, cask language variations in API data.
|
||||
Commit: `Serialise cask language variations`.
|
||||
Estimated existing casks affected: `27` casks use language blocks, with large
|
||||
examples including `Casks/f/firefox.rb`,
|
||||
`Casks/l/libreoffice-language-pack.rb` and `Casks/t/thunderbird.rb`.
|
||||
Scope: serialise a deterministic default plus ordered language variation
|
||||
deltas containing locale groups, the default marker, return values and all
|
||||
resulting API stanza changes. Public and internal API loaders select exact or
|
||||
partial locale matches and fall back to the default. Cask downloads use the
|
||||
selected URL and checksum directly, while older API data still falls back to
|
||||
source. Artifact differences are included so all `27` current language casks,
|
||||
including `cave-story` and `wondershare-edrawmax`, can use API data.
|
||||
- [x] PR 10, audit the legacy hook removal gate.
|
||||
Commit: `Plan remaining install hook migration`.
|
||||
Scope: retain the formula incremental bridge, record exact residual counts
|
||||
at `homebrew/core` `2603b0ce7788` and `homebrew/cask` `892cff1a33bb`, assign
|
||||
the remaining behaviour to migration workstreams and make zero legacy hooks
|
||||
a hard prerequisite for conflicts or deprecations. The absence of matching
|
||||
legacy and steps blocks in one file is not a completion signal.
|
||||
- [x] PR 11, guarded path predicates, `7501685232`.
|
||||
Scope: serialise path collections, globs, bases, template tokens and scoped
|
||||
`if_path_exists`, `unless_path_exists`, `on_macos` and `on_linux` predicates
|
||||
shared by later path and command steps.
|
||||
- [x] PR 12, copy steps, `05341645e6`.
|
||||
Scope: add recursive and globbed copies with per-target preservation guards.
|
||||
- [x] PR 13, removal steps, `d7361407f5`.
|
||||
Scope: add recursive, privileged and matching removals for install and
|
||||
uninstall phases.
|
||||
- [x] PR 14, `inreplace` steps, `8ed34862ff`.
|
||||
Scope: add literal and regular-expression replacements with Formula-compatible
|
||||
audit and global defaults plus scoped path guards.
|
||||
- [x] PR 14.1, recursive install-step validation, `7bfe8cf697`.
|
||||
Scope: validate nested path and platform guards, template identifiers and
|
||||
existing step aliases before the first tap migration which uses them.
|
||||
- [x] PR 15, command wrappers, `82e7898974`.
|
||||
Scope: serialise owned cask launchers as normal binary artifacts.
|
||||
- [x] PR 16, generated scripts, `053658e0fb`.
|
||||
Scope: serialise fixed executable scripts consumed by installers or steps.
|
||||
- [x] PR 17, formula permissions, `516d7e209e`.
|
||||
Scope: allow formula `set_permissions`; keep ownership cask-only because no
|
||||
formula conversion needs it.
|
||||
- [x] PR 18, constrained commands, `f4b8adfc25`.
|
||||
Scope: add `run` with `SystemCommand`-aligned arguments, environment,
|
||||
standard input and output paths, working directory and output defaults.
|
||||
Package complex one-off formula logic as deterministic helpers.
|
||||
- [x] PR 19, process termination, `996b7168a3`.
|
||||
Scope: add name or full-command matching, a total attempts count, notices,
|
||||
privilege and a non-fatal default failure policy.
|
||||
- [x] PR 20, path warnings, `b312195625`.
|
||||
Scope: combine generic `warn` with `if_path_exists` for the shared Percona
|
||||
configuration warning without adding a database-specific action.
|
||||
- [x] PR 21, GCC runtime action, `644eae48d6`.
|
||||
Scope: share the Linux runtime-link and specs generation used by eight GCC
|
||||
formulae.
|
||||
- [x] PR 22, gzipped executable action, `9b45d57aca`.
|
||||
Scope: share staged gzip decompression and fixed-mode executable installation
|
||||
across eight formulae.
|
||||
- [x] PR 23, glibc runtime action, `c315929071`.
|
||||
Scope: share locale generation and timezone-link maintenance across three
|
||||
glibc formulae.
|
||||
- [x] PR 24, Clang system config action, `39956d5347`.
|
||||
Scope: extract the existing LLVM SDK and architecture configuration into a
|
||||
shared utility used by both installation and four LLVM post-install steps.
|
||||
- [x] PR 25, PHP configuration action, `8714d6873d`.
|
||||
Scope: share PEAR, PECL and versioned extension setup across five formulae.
|
||||
- [x] PR 26, Python bootstrap action, `3e52a3efda`.
|
||||
Scope: share CPython and PyPy packaging state across five formulae while
|
||||
reusing `Language::Python.homebrew_site_packages` for CPython paths.
|
||||
- [x] PRs 27.1-27.15, prepare the `homebrew/core` migration stack.
|
||||
Scope: the `15` committed capability layers listed above remove all `86`
|
||||
remaining formula hooks and carry independent review descriptions.
|
||||
- [x] PRs 28.1-28.8, prepare the `homebrew/cask` migration stack.
|
||||
Scope: the `8` committed capability layers listed above remove all legacy
|
||||
flight blocks from `121` casks and carry independent review descriptions.
|
||||
- [ ] PR 29, refresh and merge both tap stacks.
|
||||
Scope: after the brew DSL ships in a stable release, rebase each stack onto
|
||||
the current tap head, repeat the zero-hook gate and merge in order.
|
||||
- [x] PR 30, compact structured step payloads.
|
||||
Scope: omit runner defaults from internal JSON while preserving explicit
|
||||
values needed to distinguish released compatibility behaviour.
|
||||
- [ ] PR 31, official-tap enforcement.
|
||||
Scope: reject `post_install` and legacy flight blocks in taps owned by the
|
||||
Homebrew organisation, remove their authoring documentation and mark the
|
||||
runtime call sites with commented `odeprecated` calls. Keep this PR at the
|
||||
top of the stack and expect tap syntax to fail until PR 29 reaches zero
|
||||
hooks. Do not introduce runtime conflicts or warnings here; third-party
|
||||
compatibility remains intact.
|
||||
- [ ] PR 32, close the bridges and deprecate legacy hooks.
|
||||
Hard prerequisite: the merged `homebrew/core` head has no `post_install`
|
||||
methods and the merged `homebrew/cask` head has no legacy flight blocks. Add
|
||||
actual `odeprecated` calls in the next major or minor Homebrew release.
|
||||
- [ ] PR 33, remove the documented formula `var` default while retaining it
|
||||
temporarily as a runtime compatibility fallback.
|
||||
- [ ] PR 34, migrate every implicit `var` path in `homebrew/core` to an
|
||||
explicit `base: :var`. `homebrew/cask` uses `staged_path` rather than `var`
|
||||
as its install-step default and requires no matching migration.
|
||||
- [ ] PR 35, audit and autocorrect implicit formula `var` paths so new
|
||||
official-tap uses cannot be introduced.
|
||||
- [ ] PR 36, remove the formula runtime compatibility fallback after the
|
||||
official-tap migration and enforcement have landed.
|
||||
@@ -7,11 +7,9 @@ old_trap = trap("INT") { exit! 130 }
|
||||
|
||||
require_relative "global"
|
||||
|
||||
require "fcntl"
|
||||
require "utils/socket"
|
||||
require "cli/parser"
|
||||
require "cmd/postinstall"
|
||||
require "json/add/exception"
|
||||
require "utils/fork"
|
||||
require "extend/pathname/write_mkpath_extension"
|
||||
|
||||
begin
|
||||
@@ -20,8 +18,7 @@ begin
|
||||
ENV["HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS"] = "1"
|
||||
|
||||
args = Homebrew::Cmd::Postinstall.new.args
|
||||
error_pipe = Utils::UNIXSocketExt.open(ENV.fetch("HOMEBREW_ERROR_PIPE"), &:recv_io)
|
||||
error_pipe.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
||||
error_pipe = Utils.forked_child_error_pipe
|
||||
|
||||
trap("INT", old_trap)
|
||||
|
||||
@@ -36,7 +33,6 @@ begin
|
||||
|
||||
# Handle all possible exceptions.
|
||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||
error_pipe&.puts e.to_json
|
||||
error_pipe&.close
|
||||
Utils.report_forked_child_error(error_pipe, e)
|
||||
exit! 1
|
||||
end
|
||||
|
||||
@@ -120,6 +120,69 @@ class Sandbox
|
||||
true
|
||||
end
|
||||
|
||||
sig { params(step: String, warn_without_sandbox: T::Boolean).returns(T::Boolean) }
|
||||
def self.use_for?(step, warn_without_sandbox: true)
|
||||
unless available?
|
||||
opoo "Sandbox unavailable: #{step} without sandboxing!" if warn_without_sandbox
|
||||
return false
|
||||
end
|
||||
|
||||
if avoid_nested_sandboxing?
|
||||
opoo "#{step.capitalize} without Homebrew's sandbox; relying on the outer sandbox." if warn_without_sandbox
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
sig {
|
||||
params(
|
||||
args: T.any(String, Pathname),
|
||||
step: String,
|
||||
warn_without_sandbox: T::Boolean,
|
||||
_block: T.proc.params(sandbox: Sandbox).void,
|
||||
).void
|
||||
}
|
||||
def self.run_or_fork(*args, step:, warn_without_sandbox: true, &_block)
|
||||
if use_for?(step, warn_without_sandbox:)
|
||||
sandbox = new
|
||||
yield sandbox
|
||||
sandbox.run(*args)
|
||||
else
|
||||
Utils.safe_fork { exec(*args) }
|
||||
end
|
||||
end
|
||||
|
||||
# Landlock cannot protect `bin/brew` while allowing writes to `bin`, so a
|
||||
# sandboxed install hook could replace `brew` to persist into later commands.
|
||||
sig { params(block: T.proc.void).void }
|
||||
def self.with_preserved_brew_file(&block)
|
||||
return yield if full_write_isolation?
|
||||
|
||||
brew_file = HOMEBREW_PREFIX/"bin/brew"
|
||||
File.open(brew_file.dirname) do |brew_directory|
|
||||
brew_directory_mode = brew_directory.stat.mode & 07777
|
||||
symlink = brew_file.symlink?
|
||||
contents = symlink ? brew_file.readlink.to_s : brew_file.binread
|
||||
brew_file_mode = brew_file.lstat.mode & 07777
|
||||
|
||||
begin
|
||||
yield
|
||||
ensure
|
||||
brew_directory.chmod brew_directory_mode
|
||||
if symlink && (!brew_file.symlink? || brew_file.readlink.to_s != contents)
|
||||
FileUtils.rm_rf brew_file
|
||||
brew_file.make_symlink contents
|
||||
elsif !symlink && (brew_file.symlink? || !brew_file.file? || brew_file.binread != contents ||
|
||||
(brew_file.lstat.mode & 07777) != brew_file_mode)
|
||||
FileUtils.rm_rf brew_file
|
||||
brew_file.atomic_write contents
|
||||
brew_file.chmod brew_file_mode
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def self.ensure_sandbox_available!
|
||||
return if available?
|
||||
@@ -415,6 +478,14 @@ class Sandbox
|
||||
allow_write_path HOMEBREW_CACHE
|
||||
end
|
||||
|
||||
sig { params(network_access_allowed: T::Boolean).void }
|
||||
def add_install_hook_rules(network_access_allowed:)
|
||||
allow_write_temp_and_cache
|
||||
deny_write_homebrew_repository
|
||||
deny_read_home
|
||||
deny_all_network unless network_access_allowed
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def allow_cvs
|
||||
allow_write_path "#{Dir.home(ENV.fetch("USER"))}/.cvspass"
|
||||
|
||||
@@ -10,11 +10,9 @@ require "extend/ENV"
|
||||
require "timeout"
|
||||
require "formula_assertions"
|
||||
require "formula_free_port"
|
||||
require "fcntl"
|
||||
require "utils/socket"
|
||||
require "cli/parser"
|
||||
require "dev-cmd/test"
|
||||
require "json/add/exception"
|
||||
require "utils/fork"
|
||||
require "extend/pathname/write_mkpath_extension"
|
||||
|
||||
DEFAULT_TEST_TIMEOUT_SECONDS = T.let(5 * 60, Integer)
|
||||
@@ -27,8 +25,7 @@ begin
|
||||
args = Homebrew::DevCmd::Test.new.args
|
||||
Context.current = args.context
|
||||
|
||||
error_pipe = Utils::UNIXSocketExt.open(ENV.fetch("HOMEBREW_ERROR_PIPE"), &:recv_io)
|
||||
error_pipe.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
||||
error_pipe = Utils.forked_child_error_pipe
|
||||
|
||||
trap("INT", old_trap)
|
||||
|
||||
@@ -67,8 +64,7 @@ begin
|
||||
end
|
||||
# Any exceptions during the test run are reported.
|
||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||
error_pipe&.puts e.to_json
|
||||
error_pipe&.close
|
||||
Utils.report_forked_child_error(error_pipe, e)
|
||||
ensure
|
||||
pid = Process.pid.to_s
|
||||
pkill = "/usr/bin/pkill"
|
||||
|
||||
@@ -16,8 +16,12 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
|
||||
let(:bash_dir) { cask.config.bash_completion }
|
||||
let(:zsh_dir) { cask.config.zsh_completion }
|
||||
let(:fish_dir) { cask.config.fish_completion }
|
||||
let(:run_sandboxed_payload) do
|
||||
proc { |args| Utils.safe_fork { exec(*args.map(&:to_s)) } }
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Sandbox).to receive(:with_preserved_brew_file).and_yield
|
||||
allow(cask).to receive(:staged_path).and_return(staged_path)
|
||||
(staged_path/"bin").mkpath
|
||||
(staged_path/"bin/foo").write("#!/bin/sh\necho \"$SHELL completion\"")
|
||||
@@ -36,11 +40,10 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
|
||||
allow(Sandbox).to receive(:new) do
|
||||
instance_double(Sandbox).tap do |sandbox|
|
||||
allow(sandbox).to receive(:allow_read)
|
||||
allow(sandbox).to receive(:allow_write_temp_and_cache)
|
||||
allow(sandbox).to receive(:deny_read_home)
|
||||
allow(sandbox).to receive(:deny_all_network)
|
||||
allow(sandbox).to receive(:add_install_hook_rules)
|
||||
allow(sandbox).to receive(:allow_write_path)
|
||||
allow(sandbox).to receive(:run) do |*args|
|
||||
Pathname(args.fetch(7)).write("#{args.grep(/^SHELL=/).first.delete_prefix("SHELL=")} completion output")
|
||||
run_sandboxed_payload.call(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -48,11 +51,11 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
|
||||
artifact.install_phase
|
||||
|
||||
expect(bash_dir/"foo").to be_a_file
|
||||
expect((bash_dir/"foo").read).to eq("bash completion output")
|
||||
expect((bash_dir/"foo").read).to eq("bash completion\n")
|
||||
expect(zsh_dir/"_foo").to be_a_file
|
||||
expect((zsh_dir/"_foo").read).to eq("zsh completion output")
|
||||
expect((zsh_dir/"_foo").read).to eq("zsh completion\n")
|
||||
expect(fish_dir/"foo.fish").to be_a_file
|
||||
expect((fish_dir/"foo.fish").read).to eq("fish completion output")
|
||||
expect((fish_dir/"foo.fish").read).to eq("fish completion\n")
|
||||
end
|
||||
|
||||
it "sandboxes completion generation without network access" do
|
||||
@@ -64,14 +67,16 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
|
||||
allow(Sandbox).to receive(:available?).and_return(true)
|
||||
allow(Sandbox).to receive(:new) do
|
||||
instance_double(Sandbox).tap do |sandbox|
|
||||
allow(sandbox).to receive(:allow_read)
|
||||
expect(sandbox).to receive(:allow_read).with(path: staged_path, type: :subpath)
|
||||
expect(sandbox).to receive(:allow_write_temp_and_cache)
|
||||
expect(sandbox).to receive(:deny_read_home)
|
||||
expect(sandbox).to receive(:deny_all_network) { calls << :deny_all_network }
|
||||
expect(sandbox).to receive(:add_install_hook_rules).with(network_access_allowed: false) do
|
||||
calls << :add_install_hook_rules
|
||||
end
|
||||
allow(sandbox).to receive(:allow_write_path)
|
||||
allow(sandbox).to receive(:run) do |*args|
|
||||
calls << :run
|
||||
homes << Pathname(args.grep(/^HOME=/).first.delete_prefix("HOME="))
|
||||
Pathname(args.fetch(7)).write("completion")
|
||||
run_sandboxed_payload.call(args)
|
||||
end
|
||||
sandboxes << sandbox
|
||||
end
|
||||
@@ -79,27 +84,29 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
|
||||
|
||||
artifact.install_phase
|
||||
|
||||
expect(sandboxes.length).to eq(3)
|
||||
expect(calls).to eq([:deny_all_network, :run, :deny_all_network, :run, :deny_all_network, :run])
|
||||
expect(homes.uniq.length).to eq(3)
|
||||
expect(sandboxes.length).to eq(1)
|
||||
expect(calls).to eq([:add_install_hook_rules, :run])
|
||||
expect(homes.uniq.length).to eq(1)
|
||||
expect(homes).to all(satisfy { |home| !home.exist? })
|
||||
end
|
||||
|
||||
context "when generation fails for one shell" do
|
||||
it "warns and continues generating other shells" do
|
||||
artifact = cask.artifacts.grep(described_class).first
|
||||
(staged_path/"bin/foo").write <<~SH
|
||||
#!/bin/sh
|
||||
[ "$SHELL" = bash ] && exit 1
|
||||
echo "$SHELL completion"
|
||||
SH
|
||||
|
||||
allow(Sandbox).to receive(:available?).and_return(true)
|
||||
allow(Sandbox).to receive(:new) do
|
||||
instance_double(Sandbox).tap do |sandbox|
|
||||
allow(sandbox).to receive(:allow_read)
|
||||
allow(sandbox).to receive(:allow_write_temp_and_cache)
|
||||
allow(sandbox).to receive(:deny_read_home)
|
||||
allow(sandbox).to receive(:deny_all_network)
|
||||
allow(sandbox).to receive(:add_install_hook_rules)
|
||||
allow(sandbox).to receive(:allow_write_path)
|
||||
allow(sandbox).to receive(:run) do |*args|
|
||||
raise "boom" if args.include?("SHELL=bash")
|
||||
|
||||
Pathname(args.fetch(7)).write("zsh completion")
|
||||
run_sandboxed_payload.call(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -144,26 +151,24 @@ RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
|
||||
|
||||
it "generates only for the specified shell with the correct format" do
|
||||
artifact = cask.artifacts.grep(described_class).first
|
||||
captured_args = T.let([], T::Array[String])
|
||||
captured_payload = T.let({}, T::Hash[String, T.untyped])
|
||||
|
||||
allow(Sandbox).to receive(:available?).and_return(true)
|
||||
allow(Sandbox).to receive(:new) do
|
||||
instance_double(Sandbox).tap do |sandbox|
|
||||
allow(sandbox).to receive(:allow_read)
|
||||
allow(sandbox).to receive(:allow_write_temp_and_cache)
|
||||
allow(sandbox).to receive(:deny_read_home)
|
||||
allow(sandbox).to receive(:deny_all_network)
|
||||
allow(sandbox).to receive(:add_install_hook_rules)
|
||||
allow(sandbox).to receive(:allow_write_path)
|
||||
allow(sandbox).to receive(:run) do |*args|
|
||||
captured_args = args.map(&:to_s)
|
||||
Pathname(args.fetch(7)).write("zsh completion")
|
||||
captured_payload = JSON.parse(Pathname(args.last).read)
|
||||
run_sandboxed_payload.call(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
artifact.install_phase
|
||||
|
||||
expect(captured_args).to include("--shell=zsh")
|
||||
expect(captured_args.fetch(5)).to end_with(" 2>/dev/null")
|
||||
expect(captured_payload.fetch("completions").fetch(0).fetch("shell_parameter")).to eq("--shell=zsh")
|
||||
expect(zsh_dir/"_bar").to be_a_file
|
||||
expect(bash_dir/"bar").not_to exist
|
||||
expect(fish_dir/"bar.fish").not_to exist
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Cask::Artifact::AbstractInstallSteps, :cask do
|
||||
before do
|
||||
allow(Sandbox).to receive(:available?).and_return(false)
|
||||
end
|
||||
|
||||
let(:cask) do
|
||||
Cask::Cask.new("with-install-steps") do
|
||||
version "1.2.3"
|
||||
@@ -60,7 +64,59 @@ RSpec.describe Cask::Artifact::AbstractInstallSteps, :cask do
|
||||
artifact = cask.artifacts.find { |candidate| candidate.is_a?(Cask::Artifact::PostflightSteps) }
|
||||
run_step = artifact.steps.find { |step| step["type"] == "run" }
|
||||
|
||||
expect(run_step).not_to include("print_stdout", "suppress_stderr")
|
||||
expect(run_step).not_to include("print_stdout", "suppress_stderr", "writable_paths")
|
||||
end
|
||||
|
||||
it "sandboxes complete step blocks, including system commands" do
|
||||
original_home = mktmpdir
|
||||
ENV["HOME"] = original_home.to_s
|
||||
cask = Cask::Cask.new("with-sandboxed-install-steps") do
|
||||
version "1.2.3"
|
||||
sha256 :no_check
|
||||
url "file://#{TEST_FIXTURE_DIR}/cask/container.zip"
|
||||
|
||||
postflight_steps do
|
||||
touch "Library/Application Support/cask-home-state", base: :home
|
||||
run "helper", args: ["/"], base: :staged_path,
|
||||
writable_paths: ["/Library/Example"]
|
||||
run "/usr/bin/true"
|
||||
end
|
||||
end
|
||||
sandbox = instance_double(Sandbox).as_null_object
|
||||
cask.staged_path.mkpath
|
||||
cask.config_path.dirname.mkpath
|
||||
(cask.staged_path/"helper").write <<~SH
|
||||
#!/bin/sh
|
||||
touch "#{cask.staged_path}/sandbox-ran"
|
||||
SH
|
||||
(cask.staged_path/"helper").chmod 0755
|
||||
|
||||
allow(Sandbox).to receive_messages(available?: true, new: sandbox)
|
||||
allow(sandbox).to receive(:allow_write_path)
|
||||
expect(Sandbox).to receive(:with_preserved_brew_file).and_yield
|
||||
expect(sandbox).to receive(:add_install_hook_rules).with(network_access_allowed: false)
|
||||
expect(sandbox).to receive(:allow_write_path).with(cask.caskroom_path)
|
||||
expect(sandbox).to receive(:allow_write_path).with(Pathname("/Library/Example"))
|
||||
expect(sandbox).not_to receive(:allow_write_path).with(Pathname("/"))
|
||||
expect(sandbox).to receive(:allow_write_path).with(original_home/"Library/Application Support")
|
||||
expect(sandbox).to receive(:allow_read)
|
||||
.with(path: original_home/"Library/Application Support", type: :subpath)
|
||||
expect(sandbox).to receive(:run).once do |*args|
|
||||
expect(args).to include(HOMEBREW_LIBRARY_PATH/"cask_artifact.rb")
|
||||
|
||||
payload = JSON.parse(Pathname(args.last).read)
|
||||
expect(payload.fetch("action")).to eq("install_steps")
|
||||
expect(payload.fetch("steps").filter_map do |step|
|
||||
step.dig("command", "path") if step["type"] == "run"
|
||||
end)
|
||||
.to eq(%w[helper /usr/bin/true])
|
||||
Utils.safe_fork { exec(*args.map(&:to_s)) }
|
||||
end
|
||||
|
||||
Cask::Installer.new(cask, command: NeverSudoSystemCommand).install_artifacts
|
||||
|
||||
expect(cask.staged_path/"sandbox-ran").to exist
|
||||
expect(original_home/"Library/Application Support/cask-home-state").to exist
|
||||
end
|
||||
|
||||
it "runs a flight block after matching steps during migration" do
|
||||
|
||||
@@ -146,6 +146,30 @@ RSpec.describe FormulaInstaller do
|
||||
end
|
||||
|
||||
describe "#post_install" do
|
||||
it "runs structured post-install steps inside the formula sandbox" do
|
||||
formula = formula("sandboxed-install-steps") do
|
||||
T.bind(self, T.class_of(Formula))
|
||||
url "foo-1.0"
|
||||
|
||||
post_install_steps do
|
||||
touch "state", base: :var
|
||||
end
|
||||
end
|
||||
installer = described_class.new(formula)
|
||||
sandbox = instance_double(Sandbox).as_null_object
|
||||
|
||||
allow(installer).to receive(:post_install_formula_path).and_return(formula.path)
|
||||
allow(formula).to receive_messages(logs: mktmpdir, network_access_allowed?: false)
|
||||
allow(Sandbox).to receive_messages(new: sandbox, use_for?: true)
|
||||
expect(Sandbox).to receive(:with_preserved_brew_file).and_yield
|
||||
expect(sandbox).to receive(:add_install_hook_rules).with(network_access_allowed: false)
|
||||
expect(sandbox).to receive(:run) do |*args|
|
||||
expect(args).to include(HOMEBREW_LIBRARY_PATH/"postinstall.rb", formula.path)
|
||||
end
|
||||
|
||||
installer.post_install
|
||||
end
|
||||
|
||||
it "restores bin/brew after a Landlock-sandboxed post-install replaces it" do
|
||||
prefix = mktmpdir
|
||||
stub_const("HOMEBREW_PREFIX", prefix)
|
||||
@@ -164,9 +188,9 @@ RSpec.describe FormulaInstaller do
|
||||
installer = described_class.new(formula)
|
||||
sandbox = instance_double(Sandbox).as_null_object
|
||||
|
||||
allow(installer).to receive_messages(post_install_formula_path: formula.path, use_sandbox?: true)
|
||||
allow(installer).to receive(:post_install_formula_path).and_return(formula.path)
|
||||
allow(formula).to receive_messages(logs: mktmpdir, network_access_allowed?: true)
|
||||
allow(Sandbox).to receive_messages(full_write_isolation?: false, new: sandbox)
|
||||
allow(Sandbox).to receive_messages(full_write_isolation?: false, new: sandbox, use_for?: true)
|
||||
allow(sandbox).to receive(:run) do
|
||||
FileUtils.rm_f brew_file
|
||||
brew_file.write "malicious\n"
|
||||
@@ -1472,7 +1496,7 @@ RSpec.describe FormulaInstaller do
|
||||
|
||||
# Stub out the actual build subprocess since we only care about the guard
|
||||
allow(installer).to receive(:build_argv).and_return([])
|
||||
allow(Utils).to receive(:safe_fork)
|
||||
allow(Sandbox).to receive(:run_or_fork)
|
||||
allow(source_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))
|
||||
|
||||
@@ -1160,6 +1160,7 @@ RSpec.describe Formula do
|
||||
|
||||
allow(Tab).to receive(:for_formula).with(f).and_return(f.build)
|
||||
allow(f).to receive(:post_install) { env = ENV.to_hash }
|
||||
expect(Dir).to receive(:mktmpdir).with("#{f.name}-postinstall-", HOMEBREW_TEMP).and_call_original
|
||||
|
||||
f.run_post_install
|
||||
|
||||
|
||||
@@ -69,6 +69,17 @@ RSpec.describe Homebrew::InstallSteps do
|
||||
expect((root/"stage/linked-target").readlink).to eq(Pathname("move-target"))
|
||||
end
|
||||
|
||||
specify "allows directory creation through parent sandbox paths" do
|
||||
steps = Homebrew::InstallSteps::DSL.build(default_base: :prefix) do
|
||||
mkdir "one"
|
||||
mkdir_p "two/three"
|
||||
end
|
||||
|
||||
paths = Homebrew::InstallSteps::Runner.new(context:).sandbox_write_paths(steps)
|
||||
|
||||
expect(paths).to contain_exactly(root/"prefix", root/"prefix/two")
|
||||
end
|
||||
|
||||
specify "changes an explicit Mach-O dylib ID" do
|
||||
steps = Homebrew::InstallSteps::DSL.build(default_source_base: :prefix) do
|
||||
on_macos do
|
||||
@@ -603,11 +614,16 @@ RSpec.describe Homebrew::InstallSteps do
|
||||
|
||||
specify "serialises command environments as JSON objects" do
|
||||
steps = Homebrew::InstallSteps::DSL.build do
|
||||
run "helper", env: { "EXAMPLE" => "{{formula_name}}" }
|
||||
run "helper", env: { "EXAMPLE" => "{{formula_name}}" },
|
||||
writable_paths: ["Library/Application Support/Example"], writable_base: :home
|
||||
end
|
||||
|
||||
expect(steps).to include(a_hash_including(
|
||||
"type" => "run", "env" => { "EXAMPLE" => "{{formula_name}}" },
|
||||
"type" => "run",
|
||||
"env" => { "EXAMPLE" => "{{formula_name}}" },
|
||||
"writable_paths" => [
|
||||
{ "base" => "home", "path" => "Library/Application Support/Example" },
|
||||
],
|
||||
))
|
||||
end
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ RSpec.describe RuboCop::Cop::Cask::InstallSteps, :config do
|
||||
write_file "foo.conf", "key = value\n"
|
||||
set_permissions "Foo.app", "0755"
|
||||
set_ownership "Foo.app", user: "root", group: "wheel"
|
||||
run "foo", args: ["--repair"]
|
||||
run "foo", args: ["--repair"], writable_paths: ["Library/Application Support/Foo"], writable_base: :home
|
||||
terminate_process "foo", attempts: 3
|
||||
change_dylib_id "Foo.app/Contents/Frameworks/libfoo.dylib", "@rpath/libfoo.dylib"
|
||||
delete_keychain_certificates "Charles"
|
||||
|
||||
@@ -6,6 +6,107 @@ require "sandbox"
|
||||
RSpec.describe Sandbox do
|
||||
subject(:sandbox) { described_class.new }
|
||||
|
||||
describe "::use_for?" do
|
||||
it "uses an available non-nested sandbox" do
|
||||
allow(described_class).to receive_messages(available?: true, avoid_nested_sandboxing?: false)
|
||||
|
||||
expect(described_class.use_for?("running install hooks")).to be(true)
|
||||
end
|
||||
|
||||
it "warns when the sandbox is unavailable" do
|
||||
allow(described_class).to receive(:available?).and_return(false)
|
||||
expect(described_class).to receive(:opoo).with("Sandbox unavailable: running install hooks without sandboxing!")
|
||||
|
||||
expect(described_class.use_for?("running install hooks")).to be(false)
|
||||
end
|
||||
|
||||
it "can quietly fall back when the sandbox is unavailable" do
|
||||
allow(described_class).to receive(:available?).and_return(false)
|
||||
expect(described_class).not_to receive(:opoo)
|
||||
|
||||
expect(described_class.use_for?("testing a formula", warn_without_sandbox: false)).to be(false)
|
||||
end
|
||||
|
||||
it "warns when relying on an outer sandbox" do
|
||||
allow(described_class).to receive_messages(available?: true, avoid_nested_sandboxing?: true)
|
||||
expect(described_class).to receive(:opoo)
|
||||
.with("Running install hooks without Homebrew's sandbox; relying on the outer sandbox.")
|
||||
|
||||
expect(described_class.use_for?("running install hooks")).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe "::run_or_fork" do
|
||||
let(:command_sandbox) { instance_double(described_class) }
|
||||
|
||||
it "configures and uses the sandbox when available" do
|
||||
allow(described_class).to receive_messages(new: command_sandbox, use_for?: true)
|
||||
expect(command_sandbox).to receive(:run).with("command", "argument")
|
||||
|
||||
described_class.run_or_fork("command", "argument", step: "running a command") do |configured|
|
||||
expect(configured).to eq(command_sandbox)
|
||||
end
|
||||
end
|
||||
|
||||
it "forks without configuring a sandbox when unavailable" do
|
||||
allow(described_class).to receive(:use_for?).and_return(false)
|
||||
expect(described_class).not_to receive(:new)
|
||||
expect(Utils).to receive(:safe_fork)
|
||||
|
||||
described_class.run_or_fork("command", step: "running a command") do
|
||||
raise "sandbox should not be configured"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "::with_preserved_brew_file" do
|
||||
it "restores bin/brew after a sandboxed process replaces it" do
|
||||
prefix = mktmpdir
|
||||
stub_const("HOMEBREW_PREFIX", prefix)
|
||||
brew_file = prefix/"bin/brew"
|
||||
original_brew_file = prefix/"Homebrew/bin/brew"
|
||||
original_brew_file.dirname.mkpath
|
||||
original_brew_file.write "#!/bin/sh\n"
|
||||
brew_file.dirname.mkpath
|
||||
brew_file.make_relative_symlink original_brew_file
|
||||
original_target = brew_file.readlink
|
||||
original_directory_mode = brew_file.dirname.stat.mode & 07777
|
||||
allow(described_class).to receive(:full_write_isolation?).and_return(false)
|
||||
|
||||
described_class.with_preserved_brew_file do
|
||||
FileUtils.rm_f brew_file
|
||||
brew_file.write "malicious\n"
|
||||
brew_file.dirname.chmod 0500
|
||||
end
|
||||
|
||||
expect(brew_file).to be_a_symlink
|
||||
expect(brew_file.readlink).to eq(original_target)
|
||||
expect(brew_file.dirname.stat.mode & 07777).to eq(original_directory_mode)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#add_install_hook_rules" do
|
||||
it "applies common install hook restrictions" do
|
||||
expect(sandbox).to receive(:allow_write_temp_and_cache).ordered
|
||||
expect(sandbox).to receive(:deny_write_homebrew_repository).ordered
|
||||
expect(sandbox).to receive(:deny_read_home).ordered
|
||||
expect(sandbox).to receive(:deny_all_network).ordered
|
||||
|
||||
sandbox.add_install_hook_rules(network_access_allowed: false)
|
||||
end
|
||||
|
||||
it "allows network access when requested" do
|
||||
allow(sandbox).to receive_messages(
|
||||
allow_write_temp_and_cache: nil,
|
||||
deny_write_homebrew_repository: nil,
|
||||
deny_read_home: nil,
|
||||
)
|
||||
expect(sandbox).not_to receive(:deny_all_network)
|
||||
|
||||
sandbox.add_install_hook_rules(network_access_allowed: true)
|
||||
end
|
||||
end
|
||||
|
||||
describe "::run_command" do
|
||||
let(:command_sandbox) { instance_double(described_class) }
|
||||
let(:writable_path) { mktmpdir }
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
require "utils/fork"
|
||||
|
||||
RSpec.describe Utils do
|
||||
describe "::child_error_hash" do
|
||||
it "preserves build error details" do
|
||||
error = BuildError.new(nil, "make", ["install"], { "PATH" => "/bin" })
|
||||
|
||||
expect(described_class.child_error_hash(error)).to include(
|
||||
"cmd" => "make", "args" => ["install"], "env" => { "PATH" => "/bin" },
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#safe_fork" do
|
||||
it "raises a RuntimeError on an error that isn't ErrorDuringExecution" do
|
||||
expect do
|
||||
|
||||
@@ -5,6 +5,44 @@ require "fcntl"
|
||||
require "utils/socket"
|
||||
|
||||
module Utils
|
||||
sig { returns(IO) }
|
||||
def self.forked_child_error_pipe
|
||||
UNIXSocketExt.open(ENV.fetch("HOMEBREW_ERROR_PIPE"), &:recv_io).tap do |error_pipe|
|
||||
error_pipe.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(error: Exception).returns(T::Hash[String, T.untyped]) }
|
||||
def self.child_error_hash(error)
|
||||
require "json/add/exception"
|
||||
|
||||
error_hash = T.cast(JSON.parse(error.to_json), T::Hash[String, T.untyped])
|
||||
case error
|
||||
when BuildError
|
||||
error_hash["cmd"] = error.cmd
|
||||
error_hash["args"] = error.args
|
||||
error_hash["env"] = error.env
|
||||
when ErrorDuringExecution
|
||||
error_hash["cmd"] = error.cmd
|
||||
error_hash["status"] = if error.status.is_a?(Process::Status)
|
||||
{
|
||||
exitstatus: error.exitstatus,
|
||||
termsig: error.termsig,
|
||||
}
|
||||
else
|
||||
error.status
|
||||
end
|
||||
error_hash["output"] = error.output
|
||||
end
|
||||
error_hash
|
||||
end
|
||||
|
||||
sig { params(error_pipe: T.nilable(IO), error: Exception).void }
|
||||
def self.report_forked_child_error(error_pipe, error)
|
||||
error_pipe&.puts child_error_hash(error).to_json
|
||||
error_pipe&.close
|
||||
end
|
||||
|
||||
sig { params(child_error: T::Hash[String, T.untyped]).returns(Exception) }
|
||||
def self.rewrite_child_error(child_error)
|
||||
# The error class name comes from the forked child's serialised JSON.
|
||||
@@ -42,8 +80,6 @@ module Utils
|
||||
_blk: T.proc.params(arg0: T.nilable(String)).void).void
|
||||
}
|
||||
def self.safe_fork(directory: nil, yield_parent: false, &_blk)
|
||||
require "json/add/exception"
|
||||
|
||||
block = proc do |tmpdir|
|
||||
UNIXServerExt.open("#{tmpdir}/socket") do |server|
|
||||
read, write = IO.pipe
|
||||
@@ -62,26 +98,7 @@ module Utils
|
||||
yield(error_pipe)
|
||||
# This could be any type of exception, so rescue them all.
|
||||
rescue Exception => e # rubocop:disable Lint/RescueException
|
||||
error_hash = JSON.parse e.to_json
|
||||
|
||||
# Special case: We need to recreate ErrorDuringExecutions
|
||||
# for proper error messages and because other code expects
|
||||
# to rescue them further down.
|
||||
if e.is_a?(ErrorDuringExecution)
|
||||
error_hash["cmd"] = e.cmd
|
||||
error_hash["status"] = if e.status.is_a?(Process::Status)
|
||||
{
|
||||
exitstatus: e.exitstatus,
|
||||
termsig: e.termsig,
|
||||
}
|
||||
else
|
||||
e.status
|
||||
end
|
||||
error_hash["output"] = e.output
|
||||
end
|
||||
|
||||
write.puts error_hash.to_json
|
||||
write.close
|
||||
report_forked_child_error(write, e)
|
||||
|
||||
exit!
|
||||
else
|
||||
|
||||
@@ -182,7 +182,7 @@ The `app_image` stanza is Linux-only, macOS integration stanzas such as `app` an
|
||||
|
||||
Homebrew treats cask installation artifacts as trusted vendor installation actions once the cask has been accepted. Artifact stanzas such as [`app`](#stanza-app), [`pkg`](#stanza-pkg) and [`installer script`](#installer-script) are expected to install software and may write outside the Caskroom through Homebrew-managed moves, macOS installer services or vendor installer code.
|
||||
|
||||
Generated completion artifacts are different: `generate_completions_from_executable` runs an installed executable only to produce shell completion text. That execution is sandboxed where Homebrew has an available sandbox. The sandbox allows reading the staged cask, writing temporary/cache files and blocks network access. This limits side effects from commands that should only print completion data.
|
||||
Generated completion artifacts are different: `generate_completions_from_executable` runs an installed executable only to produce shell completion text. The complete generation operation, including writing the completion, runs in an isolated Ruby subprocess where Homebrew has an available sandbox. The sandbox allows reading the staged cask, writing the completion and temporary/cache files and blocks network access. This limits side effects from commands that should only print completion data.
|
||||
|
||||
`installer script:` is not sandboxed. Many installer scripts are vendor installers that require broad filesystem writes, macOS services or `sudo`; macOS sandboxing does not work for root processes, and narrowing the write allowlist to the Caskroom plus uninstall or zap paths would break installers that legitimately write elsewhere. It would also change documented `SystemCommand` behaviours such as `sudo:`, `must_succeed:` and output handling.
|
||||
|
||||
@@ -659,7 +659,7 @@ Relative paths default to `staged_path` for `base:`, `source_base:` and `target_
|
||||
|
||||
Use `if_path_exists`, `unless_path_exists`, `on_macos` and `on_linux` blocks to guard one or more steps. A condition is evaluated once when its scope begins, so related steps make the same decision. Use `unless_path_exists` around `write_file` when an existing file must be preserved.
|
||||
|
||||
`run` does not evaluate a shell command string. It supports a literal `env:`, `stdin_path:`, `stdout_path:`, `chdir:` and `sudo:`. Standard output is hidden by default and standard error is printed; use `print_stdout: true` or `print_stderr: false` to change that behaviour.
|
||||
`run` does not evaluate a shell command string. It supports a literal `env:`, `stdin_path:`, `stdout_path:`, `chdir:` and `sudo:`. Standard output is hidden by default and standard error is printed; use `print_stdout: true` or `print_stderr: false` to change that behaviour. The complete steps block runs in an isolated Ruby subprocess without network or general home-directory access where Homebrew has an available sandbox. The sandbox permits writes to declared step destinations, the caskroom, app directory, temporary and cache directories and Homebrew's link directories. Ruby file operations and system or cask-provided commands therefore share the same restrictions. Use `writable_paths:` with directory roots, and `writable_base:` for relative roots, when an opaque command needs another declared write location.
|
||||
|
||||
#### Interpolation in steps blocks
|
||||
|
||||
|
||||
@@ -1137,7 +1137,7 @@ Content, replacements, command arguments and command environments may use a fixe
|
||||
|
||||
#### Command and lifecycle steps
|
||||
|
||||
`run` executes one command with a literal argument array; it does not evaluate a shell command string. Select the executable with `base:`, such as `:bin`, `:libexec` or `:homebrew_prefix`, or pass an absolute system executable. The step also supports a literal `env:`, `stdin_path:`, `stdout_path:`, `chdir:` and `sudo:`. Standard output is hidden by default and standard error is printed, matching `SystemCommand`; use `print_stdout: true` or `print_stderr: false` to change that behaviour.
|
||||
`run` executes one command with a literal argument array; it does not evaluate a shell command string. Select the executable with `base:`, such as `:bin`, `:libexec` or `:homebrew_prefix`, or pass an absolute system executable. The step also supports a literal `env:`, `stdin_path:`, `stdout_path:`, `chdir:` and `sudo:`. Standard output is hidden by default and standard error is printed, matching `SystemCommand`; use `print_stdout: true` or `print_stderr: false` to change that behaviour. Like all formula post-install steps, the command runs inside the formula post-install sandbox.
|
||||
|
||||
```ruby
|
||||
run "foo-helper", args: ["--prefix", "{{HOMEBREW_PREFIX}}"], base: :libexec
|
||||
|
||||
Reference in New Issue
Block a user