
With sudoers one may override default sudo user. This mostly works provided the admin configured the replacement appropriately. However there are exceptions that absolutely must be run by root such as /usr/sbin/installer and, under certain circumstances, /bin/launchctl.
380 lines
9.5 KiB
Ruby
380 lines
9.5 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
|
|
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 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.any(T::Boolean, String),
|
|
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 :print_stdout?, :print_stderr?, :must_succeed?
|
|
|
|
sig { returns(T::Boolean) }
|
|
def sudo?
|
|
@sudo != false && @sudo != ""
|
|
end
|
|
|
|
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
|
|
user_flags = @sudo.is_a?(String) ? ["-u", @sudo] : []
|
|
askpass_flags = ENV.key?("SUDO_ASKPASS") ? ["-A"] : []
|
|
["/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_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", 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
|
|
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
|
|
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
|