Skip unavailable sandbox sysctls

- Check `/proc/sys` entries before calling `sysctl` so kernels
  that omit keys do not block the Bubblewrap probe.
- Skip unwritable proc sysctl files, including read-only mounts.
- Keep attempted `sysctl` writes best effort and surface failed
  `bwrap` probe output for debugging.
This commit is contained in:
Mike McQuaid
2026-07-09 08:04:09 +01:00
parent 3d58a45b3f
commit 756be71a6b
4 changed files with 136 additions and 59 deletions
+19 -5
View File
@@ -37,16 +37,30 @@ homebrew-setup-sandbox() {
# These settings mirror SANDBOX_SYSCTL_SETTINGS in
# Library/Homebrew/extend/os/linux/sandbox.rb; keep both in sync.
if [[ $(sysctl -n "kernel.unprivileged_userns_clone" || echo 0) != "1" ]]
local proc_sys_root="${HOMEBREW_PROC_SYS:-/proc/sys}"
local sysctl_value
local unprivileged_userns_clone_sysctl="${proc_sys_root}/kernel/unprivileged_userns_clone"
if [[ -e "${unprivileged_userns_clone_sysctl}" ]] &&
sysctl_value="$(sysctl -n "kernel.unprivileged_userns_clone")" &&
[[ "${sysctl_value}" != "1" ]] &&
[[ -w "${unprivileged_userns_clone_sysctl}" ]]
then
sysctl -w kernel.unprivileged_userns_clone=1
sysctl -w kernel.unprivileged_userns_clone=1 || true
fi
if [[ $(sysctl -n "user.max_user_namespaces" || echo 0) -lt 28633 ]]
local max_user_namespaces_sysctl="${proc_sys_root}/user/max_user_namespaces"
if [[ -e "${max_user_namespaces_sysctl}" ]] &&
sysctl_value="$(sysctl -n "user.max_user_namespaces")" &&
[[ "${sysctl_value}" -lt 28633 ]] &&
[[ -w "${max_user_namespaces_sysctl}" ]]
then
sysctl -w user.max_user_namespaces=28633
sysctl -w user.max_user_namespaces=28633 || true
fi
if [[ $(sysctl -n "kernel.apparmor_restrict_unprivileged_userns" || echo 0) != "0" ]]
local apparmor_restrict_unprivileged_userns_sysctl="${proc_sys_root}/kernel/apparmor_restrict_unprivileged_userns"
if [[ -e "${apparmor_restrict_unprivileged_userns_sysctl}" ]] &&
sysctl_value="$(sysctl -n "kernel.apparmor_restrict_unprivileged_userns")" &&
[[ "${sysctl_value}" != "0" ]] &&
[[ -w "${apparmor_restrict_unprivileged_userns_sysctl}" ]]
then
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
fi
+12 -6
View File
@@ -3,6 +3,7 @@
require "fileutils"
require "env_config"
require "system_command"
require "utils/popen"
require "utils/github/actions"
@@ -121,6 +122,7 @@ module OS
module ClassMethods
extend T::Helpers
include SystemCommand::Mixin
include Utils::Output::Mixin
requires_ancestor { T.class_of(::Sandbox) }
@@ -317,12 +319,16 @@ module OS
sig { params(bubblewrap: ::Pathname).returns(T::Boolean) }
def bubblewrap_sandbox_available?(bubblewrap)
system(
bubblewrap.to_s,
*BUBBLEWRAP_TEST_ARGS,
out: File::NULL,
err: File::NULL,
) == true
result = system_command(
bubblewrap,
args: BUBBLEWRAP_TEST_ARGS,
print_stderr: false,
)
return true if result.success?
opoo "bubblewrap test probe failed"
$stderr.print result.merged_output
false
end
end
@@ -1,6 +1,7 @@
# typed: true
# frozen_string_literal: true
require "fileutils"
require "open3"
require "cmd/shared_examples/args_parse"
@@ -8,15 +9,27 @@ require "cmd/setup-sandbox"
RSpec.describe Homebrew::Cmd::SetupSandbox do
let(:setup_sandbox_script) { HOMEBREW_LIBRARY_PATH/"cmd/setup-sandbox.sh" }
let(:proc_sys_root) { mktmpdir }
it_behaves_like "parseable arguments"
def run_setup_sandbox_shell(script, env = {})
Bundler.with_unbundled_env do
Open3.capture3({ "GITHUB_ACTIONS" => nil, "HOMEBREW_LINUX" => "1" }.merge(env), "/bin/bash", "-c", script)
Open3.capture3(
{ "GITHUB_ACTIONS" => nil, "HOMEBREW_LINUX" => "1", "HOMEBREW_PROC_SYS" => proc_sys_root.to_s }
.merge(env),
"/bin/bash", "-c", script
)
end
end
def touch_proc_sys(path)
file = proc_sys_root/path
FileUtils.mkdir_p(file.dirname)
FileUtils.touch(file)
file
end
it "does nothing on non-Linux systems" do
stdout, _stderr, status = run_setup_sandbox_shell(<<~SH, "HOMEBREW_LINUX" => nil)
source "#{setup_sandbox_script}"
@@ -29,6 +42,9 @@ RSpec.describe Homebrew::Cmd::SetupSandbox do
end
it "applies the sandbox sysctl settings when they are unset" do
touch_proc_sys "kernel/unprivileged_userns_clone"
touch_proc_sys "user/max_user_namespaces"
stdout, _stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl() { [[ "$1" == "-n" ]] && { echo 0; return; }; printf 'sysctl %s\\n' "$*"; }
@@ -43,6 +59,10 @@ RSpec.describe Homebrew::Cmd::SetupSandbox do
end
it "leaves already-configured sysctls unchanged" do
touch_proc_sys "kernel/unprivileged_userns_clone"
touch_proc_sys "user/max_user_namespaces"
touch_proc_sys "kernel/apparmor_restrict_unprivileged_userns"
stdout, _stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl() {
@@ -64,6 +84,53 @@ RSpec.describe Homebrew::Cmd::SetupSandbox do
expect(stdout).to be_empty
end
it "skips missing sysctls and read-only sysctl writes" do
touch_proc_sys("user/max_user_namespaces").chmod(0444)
stdout, stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl_log="#{proc_sys_root}/sysctl.log"
sysctl() {
printf '%s\\n' "$*" >> "$sysctl_log"
if [[ "$1" == "-n" && "$2" == "user.max_user_namespaces" ]]
then
echo 1
return
fi
printf 'unexpected sysctl %s\\n' "$*" >&2
return 1
}
homebrew-setup-sandbox
cat "$sysctl_log"
SH
expect(status.success?).to be true
expect(stdout).to eq("-n user.max_user_namespaces\n")
expect(stderr).to be_empty
end
it "does not hide sysctl write errors" do
touch_proc_sys "user/max_user_namespaces"
stdout, stderr, status = run_setup_sandbox_shell <<~SH
source "#{setup_sandbox_script}"
sysctl() {
if [[ "$1" == "-n" ]]
then
echo 1
return
fi
echo 'sysctl: setting key "user.max_user_namespaces", ignoring: Read-only file system' >&2
return 1
}
homebrew-setup-sandbox
SH
expect(status.success?).to be true
expect(stdout).to be_empty
expect(stderr).to eq("sysctl: setting key \"user.max_user_namespaces\", ignoring: Read-only file system\n")
end
it "installs Bubblewrap on GitHub Actions when it is missing" do
stdout, _stderr, status = run_setup_sandbox_shell(<<~SH, "GITHUB_ACTIONS" => "true")
source "#{setup_sandbox_script}"
+37 -47
View File
@@ -60,9 +60,10 @@ RSpec.describe Sandbox, :needs_linux do
let(:bubblewrap) { bubblewrap_dir/"bwrap" }
let(:fallback_bubblewrap_dir) { mktmpdir }
let(:fallback_bubblewrap) { fallback_bubblewrap_dir/"bwrap" }
let(:bubblewrap_test_args) do
let(:successful_result) { instance_double(SystemCommand::Result, success?: true) }
let(:failed_result) { instance_double(SystemCommand::Result, success?: false, merged_output: "") }
let(:bubblewrap_probe_args) do
[
bubblewrap.to_s,
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
@@ -71,8 +72,14 @@ RSpec.describe Sandbox, :needs_linux do
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true",
{ err: :out }
"true"
]
end
let(:bubblewrap_test_args) do
[
bubblewrap.to_s,
*bubblewrap_probe_args,
{ err: :out },
]
end
@@ -97,20 +104,11 @@ RSpec.describe Sandbox, :needs_linux do
end
it "probes unprivileged namespace support once" do
expect(sandbox_class).to receive(:system).once.with(
bubblewrap.to_s,
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true",
out: File::NULL,
err: File::NULL
).and_return(true)
expect(sandbox_class).to receive(:system_command).once.with(
bubblewrap,
args: bubblewrap_probe_args,
print_stderr: false,
).and_return(successful_result)
expect(sandbox_class.available?).to be(true)
expect(sandbox_class.state).to eq(:available)
@@ -122,34 +120,16 @@ RSpec.describe Sandbox, :needs_linux do
FileUtils.chmod "+x", fallback_bubblewrap
sandbox_class.test_executable_candidate_paths = PATH.new(bubblewrap_dir, fallback_bubblewrap_dir)
expect(sandbox_class).to receive(:system).with(
bubblewrap.to_s,
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true",
out: File::NULL,
err: File::NULL
).and_return(false)
expect(sandbox_class).to receive(:system).with(
fallback_bubblewrap.to_s,
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"true",
out: File::NULL,
err: File::NULL
).and_return(true)
expect(sandbox_class).to receive(:system_command).with(
bubblewrap,
args: bubblewrap_probe_args,
print_stderr: false,
).and_return(failed_result)
expect(sandbox_class).to receive(:system_command).with(
fallback_bubblewrap,
args: bubblewrap_probe_args,
print_stderr: false,
).and_return(successful_result)
expect(sandbox_class.available?).to be(true)
end
@@ -164,13 +144,23 @@ RSpec.describe Sandbox, :needs_linux do
end
it "reports bubblewrap sandbox probe failures" do
allow(sandbox_class).to receive(:system).and_return(false)
allow(sandbox_class).to receive(:system_command).and_return(failed_result)
expect(sandbox_class.available?).to be(false)
expect(sandbox_class.state).to eq(:unavailable)
expect(sandbox_class.failure_reason).to include("cannot create a rootless sandbox")
end
it "prints bubblewrap sandbox probe failure output" do
expect(sandbox_class).to receive(:system_command)
.and_return(instance_double(SystemCommand::Result, success?: false,
merged_output: "bwrap stdout\nbwrap stderr\n"))
expect(sandbox_class).to receive(:opoo).with("bubblewrap test probe failed")
expect { sandbox_class.available? }
.to output("bwrap stdout\nbwrap stderr\n").to_stderr
end
it "does not treat generic bubblewrap sandbox probe failures as nested" do
FileUtils.touch fallback_bubblewrap
FileUtils.chmod "+x", fallback_bubblewrap