brew/Library/Homebrew/system_command.rb
Douglas Eichelberger 4dcd5ac47f Remove HashValidator
2023-03-19 17:37:58 -07:00

377 lines
9.4 KiB
Ruby

# typed: true
# frozen_string_literal: true
require "open3"
require "plist"
require "shellwords"
require "extend/io"
require "extend/predicable"
require "extend/time"
# Class for running sub-processes and capturing their output and exit status.
#
# @api private
class SystemCommand
extend T::Sig
using TimeRemaining
# Helper functions for calling {SystemCommand.run}.
module Mixin
extend T::Sig
def system_command(executable, **options)
SystemCommand.run(executable, **options)
end
def system_command!(command, **options)
SystemCommand.run!(command, **options)
end
end
include Context
extend Predicable
def self.run(executable, **options)
new(executable, **options).run!
end
def self.run!(command, **options)
run(command, **options, must_succeed: true)
end
sig { returns(SystemCommand::Result) }
def run!
$stderr.puts redact_secrets(command.shelljoin.gsub('\=', "="), @secrets) if verbose? || debug?
@output = []
each_output_line do |type, line|
case type
when :stdout
$stdout << redact_secrets(line, @secrets) if print_stdout?
@output << [:stdout, line]
when :stderr
$stderr << redact_secrets(line, @secrets) if print_stderr?
@output << [:stderr, line]
end
end
result = Result.new(command, @output, @status, secrets: @secrets)
result.assert_success! if must_succeed?
result
end
sig {
params(
executable: T.any(String, Pathname),
args: T::Array[T.any(String, Integer, Float, URI::Generic)],
sudo: T::Boolean,
env: T::Hash[String, String],
input: T.any(String, T::Array[String]),
must_succeed: T::Boolean,
print_stdout: T::Boolean,
print_stderr: T::Boolean,
debug: T.nilable(T::Boolean),
verbose: T.nilable(T::Boolean),
secrets: T.any(String, T::Array[String]),
chdir: T.any(String, Pathname),
timeout: T.nilable(T.any(Integer, Float)),
).void
}
def initialize(
executable,
args: [],
sudo: false,
env: {},
input: [],
must_succeed: false,
print_stdout: false,
print_stderr: true,
debug: nil,
verbose: false,
secrets: [],
chdir: T.unsafe(nil),
timeout: nil
)
require "extend/ENV"
@executable = executable
@args = args
@sudo = sudo
env.each_key do |name|
next if /^[\w&&\D]\w*$/.match?(name)
raise ArgumentError, "Invalid variable name: #{name}"
end
@env = env
@input = Array(input)
@must_succeed = must_succeed
@print_stdout = print_stdout
@print_stderr = print_stderr
@debug = debug
@verbose = verbose
@secrets = (Array(secrets) + ENV.sensitive_environment.values).uniq
@chdir = chdir
@timeout = timeout
end
sig { returns(T::Array[String]) }
def command
[*command_prefix, executable.to_s, *expanded_args]
end
private
attr_reader :executable, :args, :input, :chdir, :env
attr_predicate :sudo?, :print_stdout?, :print_stderr?, :must_succeed?
sig { returns(T::Boolean) }
def debug?
return super if @debug.nil?
@debug
end
sig { returns(T::Boolean) }
def verbose?
return super if @verbose.nil?
@verbose
end
sig { returns(T::Array[String]) }
def env_args
set_variables = env.compact.map do |name, value|
sanitized_name = Shellwords.escape(name)
sanitized_value = Shellwords.escape(value)
"#{sanitized_name}=#{sanitized_value}"
end
return [] if set_variables.empty?
set_variables
end
sig { returns(T::Array[String]) }
def sudo_prefix
askpass_flags = ENV.key?("SUDO_ASKPASS") ? ["-A"] : []
["/usr/bin/sudo", *askpass_flags, "-E", *env_args, "--"]
end
sig { returns(T::Array[String]) }
def env_prefix
["/usr/bin/env", *env_args]
end
sig { returns(T::Array[String]) }
def command_prefix
sudo? ? sudo_prefix : env_prefix
end
sig { returns(T::Array[String]) }
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::Generic)
arg.to_s
else
arg.to_str
end
end
end
class ProcessTerminatedInterrupt < StandardError; end
private_constant :ProcessTerminatedInterrupt
sig { params(block: T.proc.params(type: Symbol, line: String).void).void }
def each_output_line(&block)
executable, *args = command
options = {
# Create a new process group so that we can send `SIGINT` from
# parent to child rather than the child receiving `SIGINT` directly.
pgroup: sudo? ? nil : true,
}
options[:chdir] = chdir if chdir
pid = T.let(nil, T.nilable(Integer))
raw_stdin, raw_stdout, raw_stderr, raw_wait_thr = ignore_interrupts do
T.unsafe(Open3).popen3(env, [executable, executable], *args, **options)
.tap { |*, wait_thr| pid = wait_thr.pid }
end
write_input_to(raw_stdin)
raw_stdin.close_write
thread_ready_queue = Queue.new
thread_done_queue = Queue.new
line_thread = Thread.new do
Thread.handle_interrupt(ProcessTerminatedInterrupt => :never) do
thread_ready_queue << true
each_line_from [raw_stdout, raw_stderr], &block
end
thread_done_queue.pop
rescue ProcessTerminatedInterrupt
nil
end
end_time = Time.now + @timeout if @timeout
raise Timeout::Error if raw_wait_thr.join(end_time&.remaining).nil?
@status = raw_wait_thr.value
thread_ready_queue.pop
line_thread.raise ProcessTerminatedInterrupt.new
thread_done_queue << true
line_thread.join
rescue Interrupt
Process.kill("INT", pid) if pid && !sudo?
raise Interrupt
rescue SystemCallError => e
@status = $CHILD_STATUS
@output << [:stderr, e.message]
end
sig { params(raw_stdin: IO).void }
def write_input_to(raw_stdin)
input.each(&raw_stdin.method(:write))
end
sig { params(sources: T::Array[IO], _block: T.proc.params(type: Symbol, line: String).void).void }
def each_line_from(sources, &_block)
sources = {
sources[0] => :stdout,
sources[1] => :stderr,
}
pending_interrupt = T.let(false, T::Boolean)
until pending_interrupt
readable_sources = T.let([], T::Array[IO])
begin
Thread.handle_interrupt(ProcessTerminatedInterrupt => :on_blocking) do
readable_sources = T.must(IO.select(sources.keys)).fetch(0)
end
rescue ProcessTerminatedInterrupt
readable_sources = sources.keys
pending_interrupt = true
end
break if readable_sources.none? do |source|
loop do
line = source.readline_nonblock || ""
yield(sources.fetch(source), line)
end
rescue EOFError
source.close_read
sources.delete(source)
sources.any?
rescue IO::WaitReadable
true
end
end
sources.each_key(&:close_read)
end
# Result containing the output and exit status of a finished sub-process.
class Result
extend T::Sig
include Context
attr_accessor :command, :status, :exit_status
sig {
params(
command: T::Array[String],
output: T::Array[[Symbol, String]],
status: Process::Status,
secrets: T::Array[String],
).void
}
def initialize(command, output, status, secrets:)
@command = command
@output = output
@status = status
@exit_status = status.exitstatus
@secrets = secrets
end
sig { void }
def assert_success!
return if @status.success?
raise ErrorDuringExecution.new(command, status: @status, output: @output, secrets: @secrets)
end
sig { returns(String) }
def stdout
@stdout ||= @output.select { |type,| type == :stdout }
.map { |_, line| line }
.join
end
sig { returns(String) }
def stderr
@stderr ||= @output.select { |type,| type == :stderr }
.map { |_, line| line }
.join
end
sig { returns(String) }
def merged_output
@merged_output ||= @output.map { |_, line| line }
.join
end
sig { returns(T::Boolean) }
def success?
return false if @exit_status.nil?
@exit_status.zero?
end
sig { returns([String, String, Process::Status]) }
def to_ary
[stdout, stderr, status]
end
sig { returns(T.nilable(T.any(Array, Hash))) }
def plist
@plist ||= begin
output = stdout
output = output.sub(/\A(.*?)(\s*<\?\s*xml)/m) do
warn_plist_garbage(T.must(Regexp.last_match(1)))
Regexp.last_match(2)
end
output = output.sub(%r{(<\s*/\s*plist\s*>\s*)(.*?)\Z}m) do
warn_plist_garbage(T.must(Regexp.last_match(2)))
Regexp.last_match(1)
end
Plist.parse_xml(output, marshal: false)
end
end
sig { params(garbage: String).void }
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
# Make `system_command` available everywhere.
# FIXME: Include this explicitly only where it is needed.
include SystemCommand::Mixin # rubocop:disable Style/MixinUsage