brew/Library/Homebrew/system_command.rb

250 lines
5.9 KiB
Ruby

# frozen_string_literal: true
require "open3"
require "ostruct"
require "plist"
require "shellwords"
require "extend/io"
require "extend/hash_validator"
using HashValidator
# Make `system_command` available everywhere.
#
# @api private
module Kernel
def system_command(*args)
SystemCommand.run(*args)
end
def system_command!(*args)
SystemCommand.run!(*args)
end
end
# Class for running sub-processes and capturing their output and exit status.
#
# @api private
class SystemCommand
include Context
extend Predicable
attr_reader :pid
def self.run(executable, **options)
new(executable, **options).run!
end
def self.run!(command, **options)
run(command, **options, must_succeed: true)
end
def run!
puts redact_secrets(command.shelljoin.gsub('\=', "="), @secrets) if verbose? || debug?
@output = []
each_output_line do |type, line|
case type
when :stdout
$stdout << line if print_stdout?
@output << [:stdout, line]
when :stderr
$stderr << line if print_stderr?
@output << [:stderr, line]
end
end
result = Result.new(command, @output, @status, secrets: @secrets)
result.assert_success! if must_succeed?
result
end
def initialize(executable, args: [], sudo: false, env: {}, input: [], must_succeed: false,
print_stdout: false, print_stderr: true, verbose: false, secrets: [], **options)
require "extend/ENV"
@executable = executable
@args = args
@sudo = sudo
@input = Array(input)
@print_stdout = print_stdout
@print_stderr = print_stderr
@verbose = verbose
@secrets = (Array(secrets) + ENV.sensitive_environment.values).uniq
@must_succeed = must_succeed
options.assert_valid_keys!(:chdir)
@options = options
@env = env
@env.each_key do |name|
next if /^[\w&&\D]\w*$/.match?(name)
raise ArgumentError, "Invalid variable name: '#{name}'"
end
end
def command
[*sudo_prefix, *env_args, executable.to_s, *expanded_args]
end
private
attr_reader :executable, :args, :input, :options, :env
attr_predicate :sudo?, :print_stdout?, :print_stderr?, :must_succeed?
def verbose?
return super if @verbose.nil?
@verbose
end
def env_args
set_variables = env.reject { |_, value| value.nil? }
.map do |name, value|
sanitized_name = Shellwords.escape(name)
sanitized_value = Shellwords.escape(value)
"#{sanitized_name}=#{sanitized_value}"
end
return [] if set_variables.empty?
["/usr/bin/env", *set_variables]
end
def sudo_prefix
return [] unless sudo?
askpass_flags = ENV.key?("SUDO_ASKPASS") ? ["-A"] : []
["/usr/bin/sudo", *askpass_flags, "-E", "--"]
end
def expanded_args
@expanded_args ||= args.map do |arg|
if arg.respond_to?(:to_path)
File.absolute_path(arg)
elsif arg.is_a?(Integer) || arg.is_a?(Float) || arg.is_a?(URI)
arg.to_s
else
arg.to_str
end
end
end
def each_output_line(&b)
executable, *args = command
raw_stdin, raw_stdout, raw_stderr, raw_wait_thr =
Open3.popen3(env, [executable, executable], *args, **options)
@pid = raw_wait_thr.pid
write_input_to(raw_stdin)
raw_stdin.close_write
each_line_from [raw_stdout, raw_stderr], &b
@status = raw_wait_thr.value
rescue SystemCallError => e
@status = $CHILD_STATUS
@output << [:stderr, e.message]
end
def write_input_to(raw_stdin)
input.each(&raw_stdin.method(:write))
end
def each_line_from(sources)
loop do
readable_sources, = IO.select(sources)
readable_sources = readable_sources.reject(&:eof?)
break if readable_sources.empty?
readable_sources.each do |source|
line = source.readline_nonblock || ""
type = (source == sources[0]) ? :stdout : :stderr
yield(type, line)
rescue IO::WaitReadable, EOFError
next
end
end
sources.each(&:close_read)
end
# Result containing the output and exit status of a finished sub-process.
class Result
include Context
attr_accessor :command, :status, :exit_status
def initialize(command, output, status, secrets:)
@command = command
@output = output
@status = status
@exit_status = status.exitstatus
@secrets = secrets
end
def assert_success!
return if @status.success?
raise ErrorDuringExecution.new(command, status: @status, output: @output, secrets: @secrets)
end
def stdout
@stdout ||= @output.select { |type,| type == :stdout }
.map { |_, line| line }
.join
end
def stderr
@stderr ||= @output.select { |type,| type == :stderr }
.map { |_, line| line }
.join
end
def merged_output
@merged_output ||= @output.map { |_, line| line }
.join
end
def success?
return false if @exit_status.nil?
@exit_status.zero?
end
def to_ary
[stdout, stderr, status]
end
def plist
@plist ||= begin
output = stdout
if /\A(?<garbage>.*?)<\?\s*xml/m =~ output
output = output.sub(/\A#{Regexp.escape(garbage)}/m, "")
warn_plist_garbage(garbage)
end
if %r{<\s*/\s*plist\s*>(?<garbage>.*?)\Z}m =~ output
output = output.sub(/#{Regexp.escape(garbage)}\Z/, "")
warn_plist_garbage(garbage)
end
Plist.parse_xml(output)
end
end
def warn_plist_garbage(garbage)
return unless verbose?
return unless garbage.match?(/\S/)
opoo "Received non-XML output from #{Formatter.identifier(command.first)}:"
$stderr.puts garbage.strip
end
private :warn_plist_garbage
end
end