Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 61 additions & 12 deletions Library/Homebrew/test/utils/pypi_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@
let(:package_from_non_pypi_url) { described_class.new(non_pypi_package_url, is_url: true) }
let(:other_package) { described_class.new("virtualenv==20.2.0") }

it "uses the sandboxed resolver for direct distribution URLs" do
allow(Formula).to receive(:[]).with("python").and_return(instance_double(Formula, ensure_installed!: nil))
allow(Utils).to receive(:popen_read).and_raise("unsandboxed metadata")
allow(PyPI).to receive(:pip_output).with([
Utils::Path.formula_opt_libexec("python")/"bin/python", "-m", "pip", "install", "-q", "--no-deps",
"--dry-run", "--ignore-installed", "--report", "/dev/stdout", non_pypi_package_url
]).and_return('{"install":[{"metadata":{"name":"example","version":"1.0"}}]}')

expect(package_from_non_pypi_url.name).to eq("example")
end

describe "initialize" do
specify do
expect(described_class.new("foo").name).to eq "foo"
Expand Down Expand Up @@ -154,15 +165,53 @@
end

describe ".pip_report" do
it "captures metadata with a minimal sandbox environment" do
skip Sandbox.failure_reason unless Sandbox.available?
skip "Homebrew is running inside another sandbox" if Sandbox.avoid_nested_sandboxing?

ENV["HOMEBREW_METADATA_TEST"] = "parent value"
expect(described_class.pip_output(["/bin/sh", "-c", 'printf %s "${HOMEBREW_METADATA_TEST:-clean}"']))
.to eq("clean")
end

it "passes proxy settings into the sandbox" do
skip Sandbox.failure_reason unless Sandbox.available?
skip "Homebrew is running inside another sandbox" if Sandbox.avoid_nested_sandboxing?

ENV["HTTPS_PROXY"] = "http://proxy.example:3128"
expect(described_class.pip_output(["/bin/sh", "-c", 'printf %s "${HTTPS_PROXY:-clean}"']))
.to eq("http://proxy.example:3128")
end

it "refuses metadata inspection when the sandbox is unavailable" do
allow(Sandbox).to receive(:available?).and_return(false)
expect { described_class.pip_output(["/bin/echo", "metadata"]) }.to raise_error(RuntimeError, /sandbox/)
end

it "refuses metadata inspection inside another sandbox" do
allow(Sandbox).to receive_messages(available?: true, avoid_nested_sandboxing?: true)
expect { described_class.pip_output(["/bin/echo", "metadata"]) }
.to raise_error(RuntimeError, /another sandbox/)
end

it "warns when the available sandbox cannot isolate metadata changes" do
skip Sandbox.failure_reason unless Sandbox.available?
skip "Homebrew is running inside another sandbox" if Sandbox.avoid_nested_sandboxing?

allow(Sandbox).to receive(:full_write_isolation?).and_return(false)
expect { described_class.pip_output(["/bin/echo", "metadata"]) }
.to output(/cannot restrict file permissions or ownership/).to_stderr
end

it "filters packages uploaded within the last day" do
`true`

expect(Utils).to receive(:popen_read).with(
{ "PIP_REQUIRE_VIRTUALENV" => "false" },
allow(Utils).to receive(:popen_read).and_raise("unsandboxed metadata")
expect(described_class).to receive(:pip_output).with([
Utils::Path.formula_opt_libexec("python")/"bin/python", "-m", "pip", "install", "-q",
"--disable-pip-version-check", "--dry-run", "--ignore-installed",
"--uploaded-prior-to=P1D", "--report=/dev/stdout", "snakemake"
).and_return('{"install":[]}')
], print_stderr: false).and_return('{"install":[]}')

expect(described_class.pip_report([PyPI::Package.new("snakemake")])).to eq([])
end
Expand All @@ -175,13 +224,13 @@
sdist_url = "https://files.pythonhosted.org/packages/snakemake-5.29.0.tar.gz"
allow(main).to receive(:pypi_info).and_return(["snakemake", sdist_url, "a" * 64, "5.29.0"])

expect(Utils).to receive(:popen_read).with(
{ "PIP_REQUIRE_VIRTUALENV" => "false" },
allow(Utils).to receive(:popen_read).and_raise("unsandboxed metadata")
expect(described_class).to receive(:pip_output).with([
Utils::Path.formula_opt_libexec("python")/"bin/python", "-m", "pip", "install", "-q",
"--disable-pip-version-check", "--dry-run", "--ignore-installed",
"--uploaded-prior-to=P1D", "--report=/dev/stdout",
sdist_url, "pyyaml==6.0"
).and_return('{"install":[]}')
], print_stderr: false).and_return('{"install":[]}')

expect(described_class.pip_report([main, dependency], ignore_cooldown_package: main)).to eq([])
end
Expand All @@ -193,13 +242,13 @@
sdist_url = "https://files.pythonhosted.org/packages/snakemake-5.29.0.tar.gz"
allow(main).to receive(:pypi_info).and_return(["snakemake", sdist_url, "a" * 64, "5.29.0"])

expect(Utils).to receive(:popen_read).with(
{ "PIP_REQUIRE_VIRTUALENV" => "false" },
allow(Utils).to receive(:popen_read).and_raise("unsandboxed metadata")
expect(described_class).to receive(:pip_output).with([
Utils::Path.formula_opt_libexec("python")/"bin/python", "-m", "pip", "install", "-q",
"--disable-pip-version-check", "--dry-run", "--ignore-installed",
"--uploaded-prior-to=P1D", "--report=/dev/stdout",
"snakemake[foo] @ #{sdist_url}"
).and_return('{"install":[]}')
], print_stderr: false).and_return('{"install":[]}')

expect(described_class.pip_report([main], ignore_cooldown_package: main)).to eq([])
end
Expand All @@ -210,13 +259,13 @@
main = PyPI::Package.new("snakemake==5.29.0")
allow(main).to receive(:pypi_info).and_return(nil)

expect(Utils).to receive(:popen_read).with(
{ "PIP_REQUIRE_VIRTUALENV" => "false" },
allow(Utils).to receive(:popen_read).and_raise("unsandboxed metadata")
expect(described_class).to receive(:pip_output).with([
Utils::Path.formula_opt_libexec("python")/"bin/python", "-m", "pip", "install", "-q",
"--disable-pip-version-check", "--dry-run", "--ignore-installed",
"--uploaded-prior-to=P1D", "--report=/dev/stdout",
"snakemake==5.29.0"
).and_return('{"install":[]}')
], print_stderr: false).and_return('{"install":[]}')

expect(described_class.pip_report([main], ignore_cooldown_package: main)).to eq([])
end
Expand Down
47 changes: 41 additions & 6 deletions Library/Homebrew/utils/pypi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "utils/ast"
require "utils/path"
require "time"
require "sandbox"

# Helper functions for updating PyPI resources.
module PyPI
Expand Down Expand Up @@ -179,8 +180,9 @@ def basic_metadata
command =
[Utils::Path.formula_opt_libexec(@python_name)/"bin/python", "-m", "pip", "install", "-q", "--no-deps",
"--dry-run", "--ignore-installed", "--report", "/dev/stdout", @package_string]
pip_output = Utils.popen_read({ "PIP_REQUIRE_VIRTUALENV" => "false" }, *command)
unless $CHILD_STATUS.success?
pip_output = begin
PyPI.pip_output(command)
rescue ErrorDuringExecution
raise ArgumentError, <<~EOS
Unable to determine metadata for "#{@package_string}" because of a failure when running
`#{command.join(" ")}`.
Expand Down Expand Up @@ -486,6 +488,40 @@ def self.normalize_python_package(name)
name.gsub(/[-_.]+/, "-").downcase
end

sig { params(command: T::Array[T.any(String, Pathname)], print_stderr: T::Boolean).returns(String) }
def self.pip_output(command, print_stderr: false)
Sandbox.ensure_sandbox_available!
if Sandbox.avoid_nested_sandboxing?
raise "Python metadata inspection needs Homebrew's sandbox, which cannot run inside another sandbox."
end

unless Sandbox.full_write_isolation?
opoo <<~EOS
The sandbox cannot restrict file permissions or ownership.
Python metadata inspection uses the available sandbox protections.
EOS
end

Dir.mktmpdir("homebrew-pypi", HOMEBREW_TEMP) do |directory|
sandbox = Sandbox.new
sandbox.allow_write_path(directory)
sandbox.deny_write_homebrew_repository
sandbox.deny_read_home
Tempfile.create("report", directory) do |report|
proxy_env = ENV.to_h.filter_map do |key, value|
"#{key}=#{value}" if key.match?(/\A(?:https?|all|no)_proxy\z/i)
end
sandbox.run "/usr/bin/env", "-i", "PATH=#{ENV.fetch("PATH")}", *proxy_env, "HOME=#{directory}",
"TMPDIR=#{directory}", "PIP_CACHE_DIR=#{directory}/cache", "PIP_CONFIG_FILE=#{File::NULL}",
"PIP_REQUIRE_VIRTUALENV=false", "/bin/sh", "-c",
"report=$1; shift; exec \"$@\" > \"$report\"#{" 2>/dev/null" unless print_stderr}",
"brew-pypi", report.path, *command, passthrough_stdin: false
report.rewind
report.read
end
end
end

sig {
params(
packages: T::Array[Package], python_name: String, print_stderr: T::Boolean,
Expand Down Expand Up @@ -522,10 +558,9 @@ def self.pip_report(packages, python_name: "python", print_stderr: false, ignore
"--uploaded-prior-to=P#{Homebrew::RELEASE_COOLDOWN_DAYS}D",
"--report=/dev/stdout", *requirements
]
options = {}
options[:err] = :err if print_stderr
pip_output = Utils.popen_read({ "PIP_REQUIRE_VIRTUALENV" => "false" }, *command, **options)
unless $CHILD_STATUS.success?
pip_output = begin
PyPI.pip_output(command, print_stderr:)
rescue ErrorDuringExecution
odie <<~EOS
Unable to determine dependencies for "#{packages.join(" ")}" because of a failure when running
`#{command.join(" ")}`.
Expand Down
Loading