mirror of
https://github.com/Homebrew/brew.git
synced 2025-07-14 16:09:03 +08:00
389 lines
11 KiB
Ruby
389 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "system_command"
|
|
|
|
RSpec.describe SystemCommand do
|
|
describe "#initialize" do
|
|
subject(:command) do
|
|
described_class.new(
|
|
"env",
|
|
args: env_args,
|
|
env:,
|
|
must_succeed: true,
|
|
sudo:,
|
|
sudo_as_root:,
|
|
)
|
|
end
|
|
|
|
let(:env_args) { ["bash", "-c", 'printf "%s" "${A?}" "${B?}" "${C?}"'] }
|
|
let(:env) { { "A" => "1", "B" => "2", "C" => "3" } }
|
|
let(:sudo) { false }
|
|
let(:sudo_as_root) { false }
|
|
|
|
context "when given some environment variables" do
|
|
it("run!.stdout") { expect(command.run!.stdout).to eq("123") }
|
|
|
|
describe "the resulting command line" do
|
|
it "includes the given variables explicitly" do
|
|
expect(Open3)
|
|
.to receive(:popen3)
|
|
.with(
|
|
an_instance_of(Hash), ["/usr/bin/env", "/usr/bin/env"], "A=1", "B=2", "C=3",
|
|
"env", *env_args,
|
|
pgroup: true
|
|
)
|
|
.and_call_original
|
|
|
|
command.run!
|
|
end
|
|
end
|
|
end
|
|
|
|
context "when given an environment variable which is set to nil" do
|
|
let(:env) { { "A" => "1", "B" => "2", "C" => nil } }
|
|
|
|
it "unsets them" do
|
|
expect do
|
|
command.run!
|
|
end.to raise_error(/C: parameter (null or )?not set/)
|
|
end
|
|
end
|
|
|
|
context "when given some environment variables and sudo: true, sudo_as_root: false" do
|
|
let(:sudo) { true }
|
|
let(:sudo_as_root) { false }
|
|
|
|
describe "the resulting command line" do
|
|
it "includes the given variables explicitly" do
|
|
expect(Open3)
|
|
.to receive(:popen3)
|
|
.with(
|
|
an_instance_of(Hash), ["/usr/bin/sudo", "/usr/bin/sudo"], "-E",
|
|
"A=1", "B=2", "C=3", "--", "env", *env_args, pgroup: nil
|
|
)
|
|
.and_wrap_original do |original_popen3, *_, &block|
|
|
original_popen3.call("true", &block)
|
|
end
|
|
|
|
command.run!
|
|
end
|
|
end
|
|
end
|
|
|
|
context "when given some environment variables and sudo: true, sudo_as_root: true" do
|
|
let(:sudo) { true }
|
|
let(:sudo_as_root) { true }
|
|
|
|
describe "the resulting command line" do
|
|
it "includes the given variables explicitly" do
|
|
expect(Open3)
|
|
.to receive(:popen3)
|
|
.with(
|
|
an_instance_of(Hash), ["/usr/bin/sudo", "/usr/bin/sudo"], "-u", "root",
|
|
"-E", "A=1", "B=2", "C=3", "--", "env", *env_args, pgroup: nil
|
|
)
|
|
.and_wrap_original do |original_popen3, *_, &block|
|
|
original_popen3.call("true", &block)
|
|
end
|
|
|
|
command.run!
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context "when the exit code is 0" do
|
|
describe "its result" do
|
|
subject(:result) { described_class.run("true") }
|
|
|
|
it { is_expected.to be_a_success }
|
|
it(:exit_status) { expect(result.exit_status).to eq(0) }
|
|
end
|
|
end
|
|
|
|
context "when the exit code is 1" do
|
|
let(:command) { "false" }
|
|
|
|
context "with a command that must succeed" do
|
|
it "throws an error" do
|
|
expect do
|
|
described_class.run!(command)
|
|
end.to raise_error(ErrorDuringExecution)
|
|
end
|
|
end
|
|
|
|
context "with a command that does not have to succeed" do
|
|
describe "its result" do
|
|
subject(:result) { described_class.run(command) }
|
|
|
|
it { is_expected.not_to be_a_success }
|
|
it(:exit_status) { expect(result.exit_status).to eq(1) }
|
|
end
|
|
end
|
|
end
|
|
|
|
context "when given a pathname" do
|
|
let(:command) { "/bin/ls" }
|
|
let(:path) { Pathname(Dir.mktmpdir) }
|
|
|
|
before do
|
|
FileUtils.touch(path.join("somefile"))
|
|
end
|
|
|
|
describe "its result" do
|
|
subject(:result) { described_class.run(command, args: [path]) }
|
|
|
|
it { is_expected.to be_a_success }
|
|
it(:stdout) { expect(result.stdout).to eq("somefile\n") }
|
|
end
|
|
end
|
|
|
|
context "with both STDOUT and STDERR output from upstream" do
|
|
let(:command) { "/bin/bash" }
|
|
let(:options) do
|
|
{ args: [
|
|
"-c",
|
|
"for i in $(seq 1 2 5); do echo $i; echo $(($i + 1)) >&2; done",
|
|
] }
|
|
end
|
|
|
|
shared_examples "it returns '1 2 3 4 5 6'" do
|
|
describe "its result" do
|
|
subject(:result) { described_class.run(command, **options) }
|
|
|
|
it { is_expected.to be_a_success }
|
|
it(:stdout) { expect(result.stdout).to eq([1, 3, 5, nil].join("\n")) }
|
|
it(:stderr) { expect(result.stderr).to eq([2, 4, 6, nil].join("\n")) }
|
|
end
|
|
end
|
|
|
|
context "with default options" do
|
|
it "echoes only STDERR" do
|
|
expected = [2, 4, 6].map { |i| "#{i}\n" }.join
|
|
expect do
|
|
described_class.run(command, **options)
|
|
end.to output(expected).to_stderr
|
|
end
|
|
|
|
include_examples("it returns '1 2 3 4 5 6'")
|
|
end
|
|
|
|
context "with `print_stdout: true`" do
|
|
before do
|
|
options.merge!(print_stdout: true)
|
|
end
|
|
|
|
it "echoes both STDOUT and STDERR" do
|
|
expect { described_class.run(command, **options) }
|
|
.to output("1\n3\n5\n").to_stdout
|
|
.and output("2\n4\n6\n").to_stderr
|
|
end
|
|
|
|
include_examples("it returns '1 2 3 4 5 6'")
|
|
end
|
|
|
|
context "with `print_stdout: :debug`" do
|
|
before do
|
|
options.merge!(print_stdout: :debug)
|
|
end
|
|
|
|
it "echoes only STDERR output" do
|
|
expect { described_class.run(command, **options) }
|
|
.to output("2\n4\n6\n").to_stderr
|
|
.and not_to_output.to_stdout
|
|
end
|
|
|
|
context "when `debug?` is true" do
|
|
include Context
|
|
|
|
let(:options) do
|
|
{ args: [
|
|
"-c",
|
|
"for i in $(seq 1 2 5); do echo $i; sleep 0.1; echo $(($i + 1)) >&2; sleep 0.1; done",
|
|
] }
|
|
end
|
|
|
|
it "echoes the command and all output to STDERR when `debug?` is true" do
|
|
with_context debug: true do
|
|
expect { described_class.run(command, **options) }
|
|
.to output(/\A.*#{Regexp.escape(command)}.*\n1\n2\n3\n4\n5\n6\n\Z/).to_stderr
|
|
.and not_to_output.to_stdout
|
|
end
|
|
end
|
|
end
|
|
|
|
include_examples("it returns '1 2 3 4 5 6'")
|
|
end
|
|
|
|
context "with `print_stderr: false`" do
|
|
before do
|
|
options.merge!(print_stderr: false)
|
|
end
|
|
|
|
it "echoes nothing" do
|
|
expect do
|
|
described_class.run(command, **options)
|
|
end.not_to output.to_stdout
|
|
end
|
|
|
|
include_examples("it returns '1 2 3 4 5 6'")
|
|
end
|
|
|
|
context "with `print_stdout: true` and `print_stderr: false`" do
|
|
before do
|
|
options.merge!(print_stdout: true, print_stderr: false)
|
|
end
|
|
|
|
it "echoes only STDOUT" do
|
|
expected = [1, 3, 5].map { |i| "#{i}\n" }.join
|
|
expect do
|
|
described_class.run(command, **options)
|
|
end.to output(expected).to_stdout
|
|
end
|
|
|
|
include_examples("it returns '1 2 3 4 5 6'")
|
|
end
|
|
end
|
|
|
|
context "with a very long STDERR output" do
|
|
let(:command) { "/bin/bash" }
|
|
let(:options) do
|
|
{ args: [
|
|
"-c",
|
|
"for i in $(seq 1 2 100000); do echo $i; echo $(($i + 1)) >&2; done",
|
|
] }
|
|
end
|
|
|
|
it "returns without deadlocking", timeout: 30 do
|
|
expect(described_class.run(command, **options)).to be_a_success
|
|
end
|
|
end
|
|
|
|
context "when given an invalid variable name" do
|
|
it "raises an ArgumentError" do
|
|
expect { described_class.run("true", env: { "1ABC" => true }) }
|
|
.to raise_error(ArgumentError, /variable name/)
|
|
end
|
|
end
|
|
|
|
it "looks for executables in a custom PATH" do
|
|
mktmpdir do |path|
|
|
(path/"tool").write <<~SH
|
|
#!/bin/sh
|
|
echo Hello, world!
|
|
SH
|
|
|
|
FileUtils.chmod "+x", path/"tool"
|
|
|
|
expect(described_class.run("tool", env: { "PATH" => path }).stdout).to include "Hello, world!"
|
|
end
|
|
end
|
|
|
|
describe "#run" do
|
|
it "does not raise a `SystemCallError` when the executable does not exist" do
|
|
expect do
|
|
described_class.run("non_existent_executable")
|
|
end.not_to raise_error
|
|
end
|
|
|
|
it 'does not format `stderr` when it starts with \r' do
|
|
expect do
|
|
Class.new.extend(SystemCommand::Mixin).system_command \
|
|
"bash",
|
|
args: [
|
|
"-c",
|
|
'printf "\r%s" "################### 27.6%" 1>&2',
|
|
]
|
|
end.to output(
|
|
"\r################### 27.6%",
|
|
).to_stderr
|
|
end
|
|
|
|
context "when given an executable with spaces and no arguments" do
|
|
let(:executable) { mktmpdir/"App Uninstaller" }
|
|
|
|
before do
|
|
executable.write <<~SH
|
|
#!/usr/bin/env bash
|
|
true
|
|
SH
|
|
|
|
FileUtils.chmod "+x", executable
|
|
end
|
|
|
|
it "does not interpret the executable as a shell line" do
|
|
expect(Class.new.extend(SystemCommand::Mixin).system_command(executable)).to be_a_success
|
|
end
|
|
end
|
|
|
|
context "when given arguments with secrets" do
|
|
it "does not leak the secrets" do
|
|
redacted_msg = /#{Regexp.escape("username:******")}/
|
|
expect do
|
|
described_class.run! "curl",
|
|
args: %w[--user username:hunter2],
|
|
verbose: true,
|
|
secrets: %w[hunter2]
|
|
end.to raise_error.with_message(redacted_msg).and output(redacted_msg).to_stderr
|
|
end
|
|
|
|
it "does not leak the secrets set by environment" do
|
|
redacted_msg = /#{Regexp.escape("username:******")}/
|
|
expect do
|
|
ENV["PASSWORD"] = "hunter2"
|
|
described_class.run! "curl",
|
|
args: %w[--user username:hunter2],
|
|
verbose: true
|
|
end.to raise_error.with_message(redacted_msg).and output(redacted_msg).to_stderr
|
|
end
|
|
end
|
|
|
|
context "when running a process that prints secrets" do
|
|
it "does not leak the secrets" do
|
|
redacted_msg = /#{Regexp.escape("username:******")}/
|
|
expect do
|
|
described_class.run! "echo",
|
|
args: %w[username:hunter2],
|
|
verbose: true,
|
|
print_stdout: true,
|
|
secrets: %w[hunter2]
|
|
end.to output(redacted_msg).to_stdout
|
|
end
|
|
|
|
it "does not leak the secrets set by environment" do
|
|
redacted_msg = /#{Regexp.escape("username:******")}/
|
|
expect do
|
|
ENV["PASSWORD"] = "hunter2"
|
|
described_class.run! "echo",
|
|
args: %w[username:hunter2],
|
|
print_stdout: true,
|
|
verbose: true
|
|
end.to output(redacted_msg).to_stdout
|
|
end
|
|
end
|
|
|
|
context "when a `SIGINT` handler is set in the parent process" do
|
|
it "is not interrupted" do
|
|
start_time = Time.now
|
|
|
|
pid = fork do
|
|
trap("INT") do
|
|
# Ignore SIGINT.
|
|
end
|
|
|
|
described_class.run! "sleep", args: [5]
|
|
|
|
exit!
|
|
end
|
|
|
|
sleep 1
|
|
Process.kill("INT", pid)
|
|
|
|
Process.waitpid(pid)
|
|
|
|
expect(Time.now - start_time).to be >= 5
|
|
end
|
|
end
|
|
end
|
|
end
|