251 lines
		
	
	
		
			5.9 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			251 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
 | 
