414 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			414 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # typed: true
 | |
| # frozen_string_literal: true
 | |
| 
 | |
| require "attrable"
 | |
| require "open3"
 | |
| require "plist"
 | |
| require "shellwords"
 | |
| 
 | |
| require "extend/io"
 | |
| require "extend/time"
 | |
| 
 | |
| # Class for running sub-processes and capturing their output and exit status.
 | |
| #
 | |
| # @api private
 | |
| class SystemCommand
 | |
|   using TimeRemaining
 | |
| 
 | |
|   # Helper functions for calling {SystemCommand.run}.
 | |
|   module Mixin
 | |
|     def system_command(executable, **options)
 | |
|       SystemCommand.run(executable, **options)
 | |
|     end
 | |
| 
 | |
|     def system_command!(command, **options)
 | |
|       SystemCommand.run!(command, **options)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   include Context
 | |
|   extend Attrable
 | |
| 
 | |
|   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
 | |
|         case @print_stdout
 | |
|         when true
 | |
|           $stdout << redact_secrets(line, @secrets)
 | |
|         when :debug
 | |
|           $stderr << redact_secrets(line, @secrets) if debug?
 | |
|         end
 | |
|         @output << [:stdout, line]
 | |
|       when :stderr
 | |
|         case @print_stderr
 | |
|         when true
 | |
|           $stderr << redact_secrets(line, @secrets)
 | |
|         when :debug
 | |
|           $stderr << redact_secrets(line, @secrets) if debug?
 | |
|         end
 | |
|         @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,
 | |
|       sudo_as_root: T::Boolean,
 | |
|       env:          T::Hash[String, String],
 | |
|       input:        T.any(String, T::Array[String]),
 | |
|       must_succeed: T::Boolean,
 | |
|       print_stdout: T.any(T::Boolean, Symbol),
 | |
|       print_stderr: T.any(T::Boolean, Symbol),
 | |
|       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,
 | |
|     sudo_as_root: 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
 | |
| 
 | |
|     raise ArgumentError, "`sudo_as_root` cannot be set if sudo is false" if !sudo && sudo_as_root
 | |
| 
 | |
|     if print_stdout.is_a?(Symbol) && print_stdout != :debug
 | |
|       raise ArgumentError, "`print_stdout` is not a valid symbol"
 | |
|     end
 | |
|     if print_stderr.is_a?(Symbol) && print_stderr != :debug
 | |
|       raise ArgumentError, "`print_stderr` is not a valid symbol"
 | |
|     end
 | |
| 
 | |
|     @sudo = sudo
 | |
|     @sudo_as_root = sudo_as_root
 | |
|     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?, :sudo_as_root?, :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.nilable(String)) }
 | |
|   def homebrew_sudo_user
 | |
|     ENV.fetch("HOMEBREW_SUDO_USER", nil)
 | |
|   end
 | |
| 
 | |
|   sig { returns(T::Array[String]) }
 | |
|   def sudo_prefix
 | |
|     askpass_flags = ENV.key?("SUDO_ASKPASS") ? ["-A"] : []
 | |
|     user_flags = []
 | |
|     if Homebrew::EnvConfig.sudo_through_sudo_user?
 | |
|       raise ArgumentError, "HOMEBREW_SUDO_THROUGH_SUDO_USER set but SUDO_USER unset!" if homebrew_sudo_user.blank?
 | |
| 
 | |
|       user_flags += ["--prompt", "Password for %p:", "-u", homebrew_sudo_user,
 | |
|                      *askpass_flags,
 | |
|                      "-E", *env_args,
 | |
|                      "--", "/usr/bin/sudo"]
 | |
|     end
 | |
|     user_flags += ["-u", "root"] if sudo_as_root?
 | |
|     ["/usr/bin/sudo", *user_flags, *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
 | |
| 
 | |
|     raw_stdin, raw_stdout, raw_stderr, raw_wait_thr = ignore_interrupts do
 | |
|       Open3.popen3(
 | |
|         env.merge({ "COLUMNS" => Tty.width.to_s }),
 | |
|         [executable, executable],
 | |
|         *args,
 | |
|         **options,
 | |
|       )
 | |
|     end
 | |
| 
 | |
|     write_input_to(raw_stdin)
 | |
|     raw_stdin.close_write
 | |
| 
 | |
|     thread_context = Context.current
 | |
|     thread_ready_queue = Queue.new
 | |
|     thread_done_queue = Queue.new
 | |
|     line_thread = Thread.new do
 | |
|       # Ensure the new thread inherits the current context.
 | |
|       Context.current = thread_context
 | |
| 
 | |
|       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", raw_wait_thr.pid) if raw_wait_thr && !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 || sources.empty?
 | |
|       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
 | |
| 
 | |
|       readable_sources.each do |source|
 | |
|         loop do
 | |
|           line = source.readline_nonblock || ""
 | |
|           yield(sources.fetch(source), line)
 | |
|         end
 | |
|       rescue EOFError
 | |
|         source.close_read
 | |
|         sources.delete(source)
 | |
|       rescue IO::WaitReadable
 | |
|         # We've got all the data that was ready, but the other end of the stream isn't finished yet
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     sources.each_key(&: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
 | |
| 
 | |
|     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
 | 
