system_command.rb 7.22 KB
Newer Older
Markus Reiter's avatar
Markus Reiter committed
1
# typed: true
2
3
# frozen_string_literal: true

4
require "open3"
5
require "ostruct"
6
require "plist"
7
8
9
require "shellwords"

require "extend/io"
10
require "extend/predicable"
11
12
require "extend/hash_validator"

13
# Class for running sub-processes and capturing their output and exit status.
Markus Reiter's avatar
Markus Reiter committed
14
15
#
# @api private
16
class SystemCommand
Markus Reiter's avatar
Markus Reiter committed
17
18
  extend T::Sig

Markus Reiter's avatar
Markus Reiter committed
19
20
  using HashValidator

21
  # Helper functions for calling {SystemCommand.run}.
22
23
  module Mixin
    def system_command(*args)
Markus Reiter's avatar
Markus Reiter committed
24
      T.unsafe(SystemCommand).run(*args)
25
    end
Markus Reiter's avatar
Markus Reiter committed
26

27
    def system_command!(*args)
Markus Reiter's avatar
Markus Reiter committed
28
      T.unsafe(SystemCommand).run!(*args)
29
    end
30
  end
Markus Reiter's avatar
Markus Reiter committed
31

32
  include Context
33
34
  extend Predicable

35
36
  attr_reader :pid

37
  def self.run(executable, **options)
Markus Reiter's avatar
Markus Reiter committed
38
    T.unsafe(self).new(executable, **options).run!
39
40
41
  end

  def self.run!(command, **options)
Markus Reiter's avatar
Markus Reiter committed
42
    T.unsafe(self).run(command, **options, must_succeed: true)
43
44
  end

Markus Reiter's avatar
Markus Reiter committed
45
  sig { returns(SystemCommand::Result) }
46
  def run!
47
    puts redact_secrets(command.shelljoin.gsub('\=', "="), @secrets) if verbose? || debug?
48

Markus Reiter's avatar
Markus Reiter committed
49
    @output = []
50
51
52
53

    each_output_line do |type, line|
      case type
      when :stdout
54
        $stdout << line if print_stdout?
Markus Reiter's avatar
Markus Reiter committed
55
        @output << [:stdout, line]
56
      when :stderr
57
        $stderr << line if print_stderr?
Markus Reiter's avatar
Markus Reiter committed
58
        @output << [:stderr, line]
59
60
61
      end
    end

62
63
    result = Result.new(command, @output, @status, secrets: @secrets)
    result.assert_success! if must_succeed?
64
65
66
    result
  end

Markus Reiter's avatar
Markus Reiter committed
67
68
69
70
71
72
73
74
75
76
77
  sig do
    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,
      verbose:      T::Boolean,
78
      secrets:      T.any(String, T::Array[String]),
Markus Reiter's avatar
Markus Reiter committed
79
80
81
      chdir:        T.any(String, Pathname),
    ).void
  end
82
  def initialize(executable, args: [], sudo: false, env: {}, input: [], must_succeed: false,
Markus Reiter's avatar
Markus Reiter committed
83
                 print_stdout: false, print_stderr: true, verbose: false, secrets: [], chdir: T.unsafe(nil))
84
    require "extend/ENV"
85
86
87
    @executable = executable
    @args = args
    @sudo = sudo
Markus Reiter's avatar
Markus Reiter committed
88
89
90
91
92
93
    env.each_key do |name|
      next if /^[\w&&\D]\w*$/.match?(name)

      raise ArgumentError, "Invalid variable name: '#{name}'"
    end
    @env = env
Jonathan Chang's avatar
Jonathan Chang committed
94
    @input = Array(input)
Markus Reiter's avatar
Markus Reiter committed
95
    @must_succeed = must_succeed
96
97
    @print_stdout = print_stdout
    @print_stderr = print_stderr
98
    @verbose = verbose
99
    @secrets = (Array(secrets) + ENV.sensitive_environment.values).uniq
Markus Reiter's avatar
Markus Reiter committed
100
    @chdir = chdir
101
102
  end

Markus Reiter's avatar
Markus Reiter committed
103
  sig { returns(T::Array[String]) }
104
105
106
107
108
109
  def command
    [*sudo_prefix, *env_args, executable.to_s, *expanded_args]
  end

  private

Markus Reiter's avatar
Markus Reiter committed
110
  attr_reader :executable, :args, :input, :chdir, :env
111

112
113
  attr_predicate :sudo?, :print_stdout?, :print_stderr?, :must_succeed?

Markus Reiter's avatar
Markus Reiter committed
114
  sig { returns(T::Boolean) }
115
116
117
118
119
  def verbose?
    return super if @verbose.nil?

    @verbose
  end
120

Markus Reiter's avatar
Markus Reiter committed
121
  sig { returns(T::Array[String]) }
122
  def env_args
123
124
125
126
127
    set_variables = env.compact.map do |name, value|
      sanitized_name = Shellwords.escape(name)
      sanitized_value = Shellwords.escape(value)
      "#{sanitized_name}=#{sanitized_value}"
    end
128

129
    return [] if set_variables.empty?
130

Mike McQuaid's avatar
Mike McQuaid committed
131
    ["/usr/bin/env", *set_variables]
132
133
  end

Markus Reiter's avatar
Markus Reiter committed
134
  sig { returns(T::Array[String]) }
135
136
  def sudo_prefix
    return [] unless sudo?
Markus Reiter's avatar
Markus Reiter committed
137

138
139
140
141
    askpass_flags = ENV.key?("SUDO_ASKPASS") ? ["-A"] : []
    ["/usr/bin/sudo", *askpass_flags, "-E", "--"]
  end

Markus Reiter's avatar
Markus Reiter committed
142
  sig { returns(T::Array[String]) }
143
144
145
146
  def expanded_args
    @expanded_args ||= args.map do |arg|
      if arg.respond_to?(:to_path)
        File.absolute_path(arg)
Markus Reiter's avatar
Markus Reiter committed
147
      elsif arg.is_a?(Integer) || arg.is_a?(Float) || arg.is_a?(URI::Generic)
148
149
150
151
152
153
154
155
156
157
158
        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 =
Markus Reiter's avatar
Markus Reiter committed
159
      T.unsafe(Open3).popen3(env, [executable, executable], *args, **{ chdir: chdir }.compact)
160
    @pid = raw_wait_thr.pid
161
162
163
164
165

    write_input_to(raw_stdin)
    raw_stdin.close_write
    each_line_from [raw_stdout, raw_stderr], &b

166
167
168
    @status = raw_wait_thr.value
  rescue SystemCallError => e
    @status = $CHILD_STATUS
Markus Reiter's avatar
Markus Reiter committed
169
    @output << [:stderr, e.message]
170
171
172
173
174
175
176
177
178
179
  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)

Markus Reiter's avatar
Markus Reiter committed
180
      readable_sources = T.must(readable_sources).reject(&:eof?)
181
182
183
184

      break if readable_sources.empty?

      readable_sources.each do |source|
185
186
187
188
189
        line = source.readline_nonblock || ""
        type = (source == sources[0]) ? :stdout : :stderr
        yield(type, line)
      rescue IO::WaitReadable, EOFError
        next
190
191
192
193
194
195
      end
    end

    sources.each(&:close_read)
  end

Markus Reiter's avatar
Markus Reiter committed
196
  # Result containing the output and exit status of a finished sub-process.
197
  class Result
Markus Reiter's avatar
Markus Reiter committed
198
199
    extend T::Sig

200
201
    include Context

Markus Reiter's avatar
Markus Reiter committed
202
203
    attr_accessor :command, :status, :exit_status

Markus Reiter's avatar
Markus Reiter committed
204
205
206
207
208
209
210
211
    sig do
      params(
        command: T::Array[String],
        output:  T::Array[[Symbol, String]],
        status:  Process::Status,
        secrets: T::Array[String],
      ).void
    end
212
    def initialize(command, output, status, secrets:)
Markus Reiter's avatar
Markus Reiter committed
213
214
215
216
      @command       = command
      @output        = output
      @status        = status
      @exit_status   = status.exitstatus
217
218
219
      @secrets       = secrets
    end

Markus Reiter's avatar
Markus Reiter committed
220
    sig { void }
221
222
223
224
    def assert_success!
      return if @status.success?

      raise ErrorDuringExecution.new(command, status: @status, output: @output, secrets: @secrets)
Markus Reiter's avatar
Markus Reiter committed
225
226
    end

Markus Reiter's avatar
Markus Reiter committed
227
    sig { returns(String) }
Markus Reiter's avatar
Markus Reiter committed
228
229
230
231
232
233
    def stdout
      @stdout ||= @output.select { |type,| type == :stdout }
                         .map { |_, line| line }
                         .join
    end

Markus Reiter's avatar
Markus Reiter committed
234
    sig { returns(String) }
Markus Reiter's avatar
Markus Reiter committed
235
236
237
238
    def stderr
      @stderr ||= @output.select { |type,| type == :stderr }
                         .map { |_, line| line }
                         .join
239
240
    end

Markus Reiter's avatar
Markus Reiter committed
241
    sig { returns(String) }
242
243
244
245
246
    def merged_output
      @merged_output ||= @output.map { |_, line| line }
                                .join
    end

Markus Reiter's avatar
Markus Reiter committed
247
    sig { returns(T::Boolean) }
248
    def success?
249
      return false if @exit_status.nil?
250

251
252
253
      @exit_status.zero?
    end

Markus Reiter's avatar
Markus Reiter committed
254
    sig { returns([String, String, Process::Status]) }
255
256
257
258
    def to_ary
      [stdout, stderr, status]
    end

Markus Reiter's avatar
Markus Reiter committed
259
    sig { returns(T.nilable(T.any(Array, Hash))) }
260
261
262
263
    def plist
      @plist ||= begin
        output = stdout

Markus Reiter's avatar
Markus Reiter committed
264
265
266
        output = output.sub(/\A(.*?)(\s*<\?\s*xml)/m) do
          warn_plist_garbage(T.must(Regexp.last_match(1)))
          Regexp.last_match(2)
267
268
        end

Markus Reiter's avatar
Markus Reiter committed
269
270
271
        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)
272
273
274
275
276
277
        end

        Plist.parse_xml(output)
      end
    end

Markus Reiter's avatar
Markus Reiter committed
278
    sig { params(garbage: String).void }
279
    def warn_plist_garbage(garbage)
280
      return unless verbose?
281
      return unless garbage.match?(/\S/)
Markus Reiter's avatar
Markus Reiter committed
282

283
284
285
286
287
288
      opoo "Received non-XML output from #{Formatter.identifier(command.first)}:"
      $stderr.puts garbage.strip
    end
    private :warn_plist_garbage
  end
end
289
290
291
292

# Make `system_command` available everywhere.
# FIXME: Include this explicitly only where it is needed.
include SystemCommand::Mixin # rubocop:disable Style/MixinUsage