Files
brew/Library/Homebrew/test/sandbox_landlock_spec.rb
T
Mike McQuaid 377e4f740b Remove Bubblewrap, use Landlock for Linux sandbox
- Landlock needs no separate executable, installation or `sysctl`
  configuration, so use it as the only Linux sandbox implementation
  rather than an opt-in behind `$HOMEBREW_SANDBOX_LINUX_LANDLOCK`.
- Delete `Sandbox::Bubblewrap`, the `brew setup-sandbox` command and
  the implicit `bubblewrap` dependency, none of which Landlock needs.
- Remove the Bubblewrap-era `Sandbox` API (`ensure_sandbox_installed!`,
  `configure!`, `configuration_commands`, `sandbox_install_command`)
  and its call sites now that no backend needs installing or
  configuring.
- Simplify `brew doctor`'s `check_linux_sandbox` to report the
  Landlock failure reason with the `$HOMEBREW_NO_SANDBOX_LINUX`
  workaround.
2026-08-04 11:55:32 +01:00

420 lines
17 KiB
Ruby

# typed: true
# frozen_string_literal: true
require "sandbox"
require "extend/os/linux/sandbox/landlock"
RSpec.describe Sandbox::Landlock do
subject(:landlock) { described_class.new(sandbox.profile) }
let(:sandbox) { Sandbox.new }
around do |example|
described_class.reset_state!
example.run
ensure
described_class.reset_state!
end
describe "::available?" do
it "declares its weaker write-isolation contract" do
expect(described_class.full_write_isolation?).to be(false)
end
it "reports the supported Landlock ABI" do
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(4)
expect(described_class).to be_available
expect(described_class.state).to eq(:available)
expect(described_class.abi_version).to eq(4)
expect(described_class.failure_reason).to be_nil
end
it "returns false for a kernel without Landlock support" do
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(-1)
allow(described_class).to receive(:last_error).and_return(Errno::ENOSYS::Errno)
expect(described_class).not_to be_available
expect(described_class.state).to eq(:unsupported)
expect(described_class.failure_reason).to include("not supported by this Linux kernel")
end
it "returns false when Landlock is disabled by the kernel configuration" do
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(-1)
allow(described_class).to receive(:last_error).and_return(Errno::EOPNOTSUPP::Errno)
expect(described_class).not_to be_available
expect(described_class.state).to eq(:disabled)
expect(described_class.failure_reason).to include("disabled by this Linux kernel")
end
it "returns false when Linux sandboxing is disabled" do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(false)
expect(described_class).not_to be_available
expect(described_class.state).to eq(:config_disabled)
end
it "returns false when Fiddle is unavailable" do
allow(described_class).to receive(:require).with("fiddle").and_raise(LoadError)
expect(described_class).not_to be_available
expect(described_class.state).to eq(:missing_fiddle)
expect(described_class.failure_reason).to include("Fiddle")
end
it "is available for an ABI without truncate restrictions" do
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(2)
expect(described_class).to be_available
end
it "returns false for an ABI that always denies cross-directory renames" do
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(1)
expect(described_class).not_to be_available
expect(described_class.state).to eq(:unsupported_abi)
expect(described_class.abi_version).to eq(1)
expect(described_class.failure_reason).to eq("Landlock ABI 2 or later is required; found ABI 1.")
end
end
describe "::kernel_abi_version" do
it "reports the kernel ABI even when Linux sandboxing is disabled" do
allow(Homebrew::EnvConfig).to receive(:sandbox_linux?).and_return(false)
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(6)
expect(described_class.kernel_abi_version).to eq(6)
end
it "returns nil for a kernel without Landlock support" do
allow(described_class).to receive(:landlock_create_ruleset).with(nil, 0, 1).and_return(-1)
expect(described_class.kernel_abi_version).to be_nil
end
it "returns nil when Fiddle function setup fails" do
require "fiddle"
allow(described_class).to receive(:landlock_create_ruleset).and_raise(Fiddle::DLError)
expect(described_class.kernel_abi_version).to be_nil
end
end
describe "#apply!" do
let(:writable_dir) { mktmpdir }
let(:tmpdir) { mktmpdir }
before do
allow(File).to receive(:exist?).and_call_original
allow(File).to receive(:exist?).with("/dev/full").and_return(false)
allow(File).to receive(:exist?).with("/dev/mqueue").and_return(false)
allow(File).to receive(:exist?).with("/dev/ptmx").and_return(true)
allow(File).to receive(:exist?).with("/dev/pts").and_return(true)
allow(File).to receive(:exist?).with("/dev/shm").and_return(false)
allow(File).to receive(:exist?).with("/dev/tty").and_return(false)
end
it "rejects an ABI without cross-directory rename restrictions" do
allow(described_class).to receive_messages(abi_version: 1,
failure_reason: "Landlock ABI 2 or later is required; found ABI 1.")
expect(described_class).not_to receive(:landlock_create_ruleset)
expect { landlock.apply! }
.to raise_error(RuntimeError, "Landlock ABI 2 or later is required; found ABI 1.")
end
it "restricts writes and network access using the supported ABI" do
sandbox.allow_write_path writable_dir
sandbox.deny_all_network
landlock.command(["true"], tmpdir.to_s)
allow(described_class).to receive(:abi_version).and_return(10)
expect(described_class).to receive(:landlock_create_ruleset) do |attributes, size, flags|
expect(attributes.unpack("Q3")).to eq([131_058, 15, 1])
expect(size).to eq(24)
expect(flags).to eq(0)
17
end
allow(landlock).to receive(:open_path).with(writable_dir.to_s).and_return(18)
expect(landlock).to receive(:open_path).with(File::NULL).and_return(19)
allow(landlock).to receive(:open_path).with(tmpdir.to_s).and_return(20)
expect(landlock).to receive(:open_path).with("#{tmpdir}/socket").and_return(21)
expect(landlock).to receive(:open_path).with("/dev/ptmx").and_return(22)
expect(landlock).to receive(:open_path).with("/dev/pts").and_return(23)
path_rules = []
expect(described_class).to receive(:landlock_add_rule).exactly(6).times do |ruleset_fd, type, attributes, flags|
expect(ruleset_fd).to eq(17)
expect(type).to eq(1)
expect(attributes.bytesize).to eq(12)
path_rules << attributes.unpack("Ql")
expect(flags).to eq(0)
0
end
expect(described_class).to receive(:set_no_new_privileges).and_return(0)
expect(described_class).to receive(:landlock_restrict_self).with(17, 0).and_return(0)
expect(landlock).to receive(:close_file_descriptor).with(22).ordered
expect(landlock).to receive(:close_file_descriptor).with(23).ordered
expect(landlock).to receive(:close_file_descriptor).with(21).ordered
expect(landlock).to receive(:close_file_descriptor).with(18).ordered
expect(landlock).to receive(:close_file_descriptor).with(19).ordered
expect(landlock).to receive(:close_file_descriptor).with(20).ordered
expect(landlock).to receive(:close_file_descriptor).with(17).ordered
landlock.apply!
expect(path_rules).to eq([
[32_770, 22], [32_770, 23], [65_536, 21], [32_754, 18], [16_386, 19], [32_754, 20]
])
end
it "handles reads, directory listings, and execution outside denied hierarchies" do
readable_dir = mktmpdir
readable_file = readable_dir/"file"
readable_file.write("content")
denied_dir = mktmpdir
sandbox.deny_read_path denied_dir
allow(landlock).to receive(:readable_paths).with([denied_dir])
.and_return([readable_dir.to_s, readable_file.to_s])
landlock.command(["true"], tmpdir.to_s)
allow(described_class).to receive(:abi_version).and_return(10)
expect(described_class).to receive(:landlock_create_ruleset) do |attributes, size, flags|
expect(attributes.unpack("Q")).to eq([65_535])
expect(size).to eq(8)
expect(flags).to eq(0)
17
end
expect(landlock).to receive(:open_path).with(readable_dir.to_s).and_return(18)
expect(landlock).to receive(:open_path).with(readable_file.to_s).and_return(19)
expect(landlock).to receive(:open_path).with(File::NULL).and_return(20)
expect(landlock).to receive(:open_path).with(tmpdir.to_s).and_return(21)
expect(landlock).to receive(:open_path).with("/dev/ptmx").and_return(22)
expect(landlock).to receive(:open_path).with("/dev/pts").and_return(23)
path_rules = []
expect(described_class).to receive(:landlock_add_rule).exactly(6).times do |ruleset_fd, type, attributes, flags|
expect(ruleset_fd).to eq(17)
expect(type).to eq(1)
path_rules << attributes.unpack("Ql")
expect(flags).to eq(0)
0
end
expect(described_class).to receive(:set_no_new_privileges).and_return(0)
expect(described_class).to receive(:landlock_restrict_self).with(17, 0).and_return(0)
allow(landlock).to receive(:close_file_descriptor)
landlock.apply!
expect(path_rules).to eq([
[32_774, 22], [32_774, 23], [13, 18], [5, 19], [16_386, 20], [32_754, 21]
])
end
it "allows pseudo-terminal device access" do
landlock.command(["true"], tmpdir.to_s)
allow(described_class).to receive_messages(abi_version: 10, landlock_create_ruleset: 17,
landlock_add_rule: 0, set_no_new_privileges: 0,
landlock_restrict_self: 0)
allow(landlock).to receive(:open_path).and_return(18)
allow(landlock).to receive(:close_file_descriptor)
expect(landlock).to receive(:open_path).with("/dev/ptmx").and_return(19)
expect(landlock).to receive(:open_path).with("/dev/pts").and_return(20)
expect(described_class).to receive(:landlock_add_rule)
.with(17, 1, [32_770, 19].pack("Ql"), 0).and_return(0)
expect(described_class).to receive(:landlock_add_rule)
.with(17, 1, [32_770, 20].pack("Ql"), 0).and_return(0)
landlock.apply!
end
it "allows standard device and POSIX IPC paths" do
denied_dir = mktmpdir
sandbox.deny_read_path denied_dir
allow(landlock).to receive(:readable_paths).with([denied_dir]).and_return([])
landlock.command(["true"], tmpdir.to_s)
allow(File).to receive(:exist?).with("/dev/full").and_return(true)
allow(File).to receive(:exist?).with("/dev/mqueue").and_return(true)
allow(File).to receive(:exist?).with("/dev/shm").and_return(true)
allow(File).to receive(:exist?).with("/dev/tty").and_return(true)
allow(described_class).to receive_messages(abi_version: 10, landlock_create_ruleset: 17,
landlock_add_rule: 0, set_no_new_privileges: 0,
landlock_restrict_self: 0)
allow(landlock).to receive(:open_path).and_return(18)
allow(landlock).to receive(:close_file_descriptor)
{
"/dev/full" => [16_386, 19],
"/dev/mqueue" => [32_758, 20],
"/dev/shm" => [32_754, 21],
"/dev/tty" => [32_774, 22],
}.each do |path, (access, file_descriptor)|
expect(landlock).to receive(:open_path).with(path).and_return(file_descriptor)
expect(described_class).to receive(:landlock_add_rule)
.with(17, 1, [access, file_descriptor].pack("Ql"), 0).and_return(0)
end
landlock.apply!
end
it "skips unavailable device and IPC paths" do
landlock.command(["true"], tmpdir.to_s)
allow(File).to receive(:exist?).with("/dev/full").and_return(false)
allow(File).to receive(:exist?).with("/dev/mqueue").and_return(false)
allow(File).to receive(:exist?).with("/dev/ptmx").and_return(false)
allow(File).to receive(:exist?).with("/dev/pts").and_return(false)
allow(File).to receive(:exist?).with("/dev/shm").and_return(false)
allow(File).to receive(:exist?).with("/dev/tty").and_return(false)
allow(described_class).to receive_messages(abi_version: 10, landlock_create_ruleset: 17,
landlock_add_rule: 0, set_no_new_privileges: 0,
landlock_restrict_self: 0)
allow(landlock).to receive(:open_path).and_return(18)
allow(landlock).to receive(:close_file_descriptor)
expect(landlock).not_to receive(:open_path).with("/dev/full")
expect(landlock).not_to receive(:open_path).with("/dev/mqueue")
expect(landlock).not_to receive(:open_path).with("/dev/ptmx")
expect(landlock).not_to receive(:open_path).with("/dev/pts")
expect(landlock).not_to receive(:open_path).with("/dev/shm")
expect(landlock).not_to receive(:open_path).with("/dev/tty")
landlock.apply!
end
context "with an older ABI" do
before do
allow(landlock).to receive(:open_path).and_return(18)
allow(described_class).to receive_messages(abi_version: 7, landlock_create_ruleset: 17, landlock_add_rule: 0,
set_no_new_privileges: 0, landlock_restrict_self: 0)
allow(landlock).to receive(:close_file_descriptor)
end
it "warns when applying incomplete network denial" do
sandbox.deny_all_network
landlock.command(["true"], tmpdir.to_s)
expect { landlock.apply! }
.to output(/Landlock ABI 10 or later is required to deny all network access; found ABI 7/).to_stderr
end
it "does not warn before applying network denial" do
sandbox.deny_all_network
expect { landlock.command(["true"], tmpdir.to_s) }.not_to output.to_stderr
end
it "does not warn without network denial" do
landlock.command(["true"], tmpdir.to_s)
expect { landlock.apply! }.not_to output.to_stderr
end
it "uses the supported network access rights" do
sandbox.deny_all_network
landlock.command(["true"], tmpdir.to_s)
attributes, = landlock.ruleset_attributes(7)
expect(attributes.unpack("Q3")).to eq([65_522, 3, 1])
end
end
context "with the oldest supported ABI" do
before do
allow(landlock).to receive(:open_path).and_return(18)
allow(described_class).to receive_messages(abi_version: 2, landlock_create_ruleset: 17, landlock_add_rule: 0,
set_no_new_privileges: 0, landlock_restrict_self: 0)
allow(landlock).to receive(:close_file_descriptor)
end
it "does not warn without network denial" do
landlock.command(["true"], tmpdir.to_s)
expect { landlock.apply! }.not_to output.to_stderr
end
it "warns that network access cannot be restricted" do
sandbox.deny_all_network
landlock.command(["true"], tmpdir.to_s)
expect { landlock.apply! }
.to output(/found ABI 2\. This kernel cannot restrict network access/).to_stderr
end
it "omits unsupported access rights from device path rules" do
allow(File).to receive(:exist?).with("/dev/full").and_return(true)
landlock.command(["true"], tmpdir.to_s)
expect(landlock).to receive(:open_path).with("/dev/full").and_return(19)
expect(described_class).to receive(:landlock_add_rule)
.with(17, 1, [2, 19].pack("Ql"), 0).and_return(0)
landlock.apply!
end
it "handles only the supported filesystem access rights" do
sandbox.deny_all_network
landlock.command(["true"], tmpdir.to_s)
attributes, = landlock.ruleset_attributes(2)
expect(attributes.unpack("Q*")).to eq([16_370])
end
end
end
describe "#command" do
it "prepares missing writable directories and removes them after running" do
writable_dir = mktmpdir/"created"
sandbox.allow_write_path writable_dir
expect(landlock.command(["true"], mktmpdir.to_s)).to eq(["true"])
expect(writable_dir).to be_a_directory
landlock.run { nil }
expect(writable_dir).not_to exist
end
it "rejects regex path filters" do
sandbox.allow_write path: "^/tmp/homebrew-[^/]+$", type: :regex
expect { landlock.command(["true"], mktmpdir.to_s) }
.to raise_error(ArgumentError, /Linux sandbox does not support regex path filters/)
end
it "allows reading paths outside denied hierarchies" do
root = mktmpdir
readable_dir = root/"readable"
denied_dir = root/"denied"
readable_dir.mkpath
denied_dir.mkpath
allow(landlock).to receive(:root_path).and_return(root)
expect(landlock.readable_paths([denied_dir])).to eq([readable_dir.to_s])
end
it "does not allow a symlink alias into a denied hierarchy" do
root = mktmpdir
denied_dir = root/"denied"
denied_dir.mkpath
alias_path = root/"alias"
alias_path.make_symlink(denied_dir)
allow(landlock).to receive(:root_path).and_return(root)
expect(landlock.readable_paths([denied_dir])).to be_empty
end
it "skips dangling symlinks" do
root = mktmpdir
denied_dir = root/"denied"
denied_dir.mkpath
dangling_path = root/"dangling"
dangling_path.make_symlink(root/"missing")
allow(landlock).to receive(:root_path).and_return(root)
expect(landlock.readable_paths([denied_dir])).to be_empty
end
end
end