From a18dc37375559228e73879e2c3051c63e3d7d98c Mon Sep 17 00:00:00 2001 From: Carlo Cabrera Date: Tue, 28 Jul 2026 17:47:25 +0800 Subject: [PATCH 1/4] tap: require `trust` in `Tap#to_hash` `brew tap-info --json=v1 --installed` crashed with `uninitialized constant Homebrew::Trust` because `Tap#to_hash` referenced `Homebrew::Trust` without requiring it. It only worked because `require "api"` transitively loaded `trust` via `download_queue`, `resource`, `livecheck` and `cask/cask`. That chain went away when `api.rb` started requiring `download_queue` only under `HOMEBREW_SORBET_RUNTIME`. `trust.rb` requires `tap`, so require it lazily like `Tap#apply_redirected_remote!` already does rather than at the top of the file. The existing `tap-info` integration test runs the exact failing command but passed regardless: `brew tests` sets `HOMEBREW_SORBET_RUNTIME`, which restores the `download_queue` require and hides the missing one. Run its subprocess without the Sorbet runtime so it exercises the same require graph as a real `brew tap-info`. Setting `HOMEBREW_NO_GITHUB_API` stops `Tap#private?` querying the API, which is the only network access on this path, so the test no longer needs `:needs_network` and now runs in the `tests (no Sorbet)` job too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VH4v2Gfo9kSezLs92eT8KF --- Library/Homebrew/tap.rb | 2 ++ Library/Homebrew/test/cmd/tap-info_spec.rb | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/tap.rb b/Library/Homebrew/tap.rb index 14f3faa6a8..dc66b3b637 100644 --- a/Library/Homebrew/tap.rb +++ b/Library/Homebrew/tap.rb @@ -1162,6 +1162,8 @@ class Tap sig { returns(T::Hash[String, T.untyped]) } def to_hash + require "trust" + hash = { "name" => name, "user" => user, diff --git a/Library/Homebrew/test/cmd/tap-info_spec.rb b/Library/Homebrew/test/cmd/tap-info_spec.rb index b8072a2482..4642665068 100644 --- a/Library/Homebrew/test/cmd/tap-info_spec.rb +++ b/Library/Homebrew/test/cmd/tap-info_spec.rb @@ -7,10 +7,18 @@ require "cmd/tap-info" RSpec.describe Homebrew::Cmd::TapInfo do it_behaves_like "parseable arguments" - it "gets information for a given Tap", :integration_test, :needs_network do + it "gets information for a given Tap", :integration_test do setup_test_tap - expect { brew "tap-info", "--json=v1", "--installed" } + # Run without the Sorbet runtime so this exercises the same `require` graph as + # a real `brew tap-info`, which loads fewer files. + brew_env = { + "HOMEBREW_SORBET_RUNTIME" => nil, + "HOMEBREW_SORBET_RECURSIVE" => nil, + "HOMEBREW_NO_GITHUB_API" => "1", + } + + expect { brew "tap-info", "--json=v1", "--installed", brew_env } .to output(%r{https://github\.com/Homebrew/homebrew-foo}).to_stdout .and not_to_output.to_stderr .and be_a_success From f453ee9b9883c6ec0f51402e778a57ec0f7ac06c Mon Sep 17 00:00:00 2001 From: Carlo Cabrera Date: Tue, 28 Jul 2026 18:25:37 +0800 Subject: [PATCH 2/4] test/os/mac/keg: require `macho` `Keg#codesign_patched_binary signs patched binaries using ruby-macho` sets an expectation on `MachO` before calling the method that requires it, so it only passes when something earlier in the same process has already loaded `macho`. Which examples run first depends on the RSpec seed and how `parallel_tests` groups files, so this fails intermittently: $ brew tests --only=os/mac/keg:45 Failure/Error: expect(MachO).to receive(:codesign!).with(file) uninitialized constant MachO `MachOShim` used to require `macho` at the top of `os/mac/mach.rb`, which made the constant reliably available; it now requires it inside `#initialize` and `#macho` instead. Require it from the spec rather than undoing that, since the production code paths do require it before use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VH4v2Gfo9kSezLs92eT8KF --- Library/Homebrew/test/os/mac/keg_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/Library/Homebrew/test/os/mac/keg_spec.rb b/Library/Homebrew/test/os/mac/keg_spec.rb index ba2eb163ca..eb31b00808 100644 --- a/Library/Homebrew/test/os/mac/keg_spec.rb +++ b/Library/Homebrew/test/os/mac/keg_spec.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "keg" +require "macho" RSpec.describe Keg do subject(:keg) { described_class.new(keg_path) } From 059785dc91c704b41203af20a2a878bea8592bc5 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 28 Jul 2026 12:03:52 +0100 Subject: [PATCH 3/4] Preserve locale during brew startup - Validate the locale charmap instead of trusting its name. - Reuse filtered Linux locale settings to avoid `locale -a`. --- Library/Homebrew/test/bash_spec.rb | 53 ++++++++++++++++++++++++++++++ Library/Homebrew/utils/os.sh | 26 +++++++++------ bin/brew | 2 ++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/Library/Homebrew/test/bash_spec.rb b/Library/Homebrew/test/bash_spec.rb index 18194f8c6a..49e12ffebd 100644 --- a/Library/Homebrew/test/bash_spec.rb +++ b/Library/Homebrew/test/bash_spec.rb @@ -39,6 +39,59 @@ RSpec.describe "Bash" do end end + describe "setup-locale" do + it "uses the macOS locale charmap rather than the locale name", :needs_macos do + setup_locale = [ + "/bin/bash", "-c", <<~BASH, "bash", (HOMEBREW_LIBRARY_PATH/"utils/os.sh").to_s + source "$1" + locale() { + [[ "${LC_CTYPE:-${LANG:-}}" == "UTF-8" ]] && printf "UTF-8" || printf "US-ASCII" + } + setup-locale + printf "%s" "${LC_ALL-unset}" + BASH + ] + invalid_stdout, invalid_stderr, invalid_status = Open3.capture3( + { "LANG" => "C.utf8", "LC_CTYPE" => nil, "LC_ALL" => nil }, *setup_locale + ) + valid_stdout, valid_stderr, valid_status = Open3.capture3( + { "LANG" => nil, "LC_CTYPE" => "UTF-8", "LC_ALL" => nil }, *setup_locale + ) + + expect([invalid_stdout, invalid_stderr, invalid_status.success?, + valid_stdout, valid_stderr, valid_status.success?]) + .to eq(["en_US.UTF-8", "", true, "unset", "", true]) + end + + it "restores filtered Linux locale variables and removes their copies" do + stdout, stderr, status = Open3.capture3( + { "LANG" => nil, "LC_CTYPE" => nil, "LC_ALL" => nil }, + "/bin/bash", "-c", <<~'BASH', "bash", (HOMEBREW_LIBRARY_PATH/"utils/os.sh").to_s + source "$1" + HOMEBREW_MACOS= + HOMEBREW_LANG=C + HOMEBREW_LC_CTYPE=C + HOMEBREW_LC_ALL=C.UTF-8 + locale() { + if [[ "$1" == "charmap" ]] + then + [[ "${LC_ALL:-}" == "C.UTF-8" ]] && printf "UTF-8" || printf "US-ASCII" + else + printf "locale -a called\n" >&2 + printf "C.UTF-8\n" + fi + } + setup-locale + printf "%s\n" "${LANG-unset}" "${LC_CTYPE-unset}" "${LC_ALL-unset}" \ + "${HOMEBREW_LANG-unset}" "${HOMEBREW_LC_CTYPE-unset}" "${HOMEBREW_LC_ALL-unset}" + BASH + ) + + expect([stdout, stderr, status.success?]) + .to eq(["C\nC\nC.UTF-8\nunset\nunset\nunset\n", "", true]) + end + end + describe "every `.sh` file" do it "has valid Bash syntax" do Pathname.glob("#{HOMEBREW_LIBRARY_PATH}/**/*.sh").each do |path| diff --git a/Library/Homebrew/utils/os.sh b/Library/Homebrew/utils/os.sh index b675e16ca2..08c804d112 100644 --- a/Library/Homebrew/utils/os.sh +++ b/Library/Homebrew/utils/os.sh @@ -99,24 +99,30 @@ then fi # Force UTF-8 to avoid encoding issues for users with broken locale settings. -# The locale is checked by name to avoid forking `locale charmap`. When -# bin/brew has filtered the environment no locale variables survive so a UTF-8 -# one must be set. +# Validate the active locale's charmap rather than trusting its name before +# selecting a usable fallback. setup-locale() { - local utf8_locale_regex='\.([Uu][Tt][Ff]-?8)(@|$)' local locales c_utf_regex en_us_regex utf_regex - if [[ "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" =~ ${utf8_locale_regex} ]] + if [[ -z "${HOMEBREW_MACOS}" ]] then - # The locale is already UTF-8. - : - elif [[ -n "${HOMEBREW_MACOS}" ]] + [[ -z "${HOMEBREW_LANG:-}" ]] || export LANG="${HOMEBREW_LANG}" + [[ -z "${HOMEBREW_LC_CTYPE:-}" ]] || export LC_CTYPE="${HOMEBREW_LC_CTYPE}" + [[ -z "${HOMEBREW_LC_ALL:-}" ]] || export LC_ALL="${HOMEBREW_LC_ALL}" + fi + unset HOMEBREW_LANG HOMEBREW_LC_CTYPE HOMEBREW_LC_ALL + + if [[ -n "${HOMEBREW_MACOS}" ]] then - export LC_ALL="en_US.UTF-8" + if [[ -z "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" ]] || [[ "$(locale charmap)" != "UTF-8" ]] + then + export LC_ALL="en_US.UTF-8" + fi else if ! command -v locale >/dev/null then export LC_ALL=C - else + elif [[ "$(locale charmap)" != "UTF-8" ]] + then locales="$(locale -a)" c_utf_regex='\bC\.(utf8|UTF-8)\b' en_us_regex='\ben_US\.(utf8|UTF-8)\b' diff --git a/bin/brew b/bin/brew index b9ef19d19c..46b6b39c3e 100755 --- a/bin/brew +++ b/bin/brew @@ -235,6 +235,8 @@ USED_BY_HOMEBREW_VARS=( GOBIN GOPATH LANG + LC_ALL + LC_CTYPE NODENV_ROOT PATH PYENV_ROOT From a2e882527d5c30038de284486fbee445912b3807 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 28 Jul 2026 13:39:03 +0100 Subject: [PATCH 4/4] Support Landlock ABI 2 on Linux kernel 6.1 - Amazon Fargate's kernel 6.1 only provides Landlock ABI 2, which the previous `MINIMUM_ABI` of 3 rejected outright. - ABI 2 can still permit cross-directory renames via `ACCESS_FS_REFER`; it only lacks `ACCESS_FS_TRUNCATE`, noted on `MINIMUM_TRUNCATE_ABI` as truncation has no profile rule that could gate a warning. - Mask device path rules with `handled_access_fs` so the `/dev/full` truncate bit no longer makes `landlock_add_rule(2)` fail with `EINVAL` on ABI 2. - Warn that network access cannot be restricted at all below ABI 4 rather than implying partial restrictions were applied. --- .../extend/os/linux/sandbox/landlock.rb | 34 +++++++--- .../Homebrew/test/sandbox_landlock_spec.rb | 63 ++++++++++++++++--- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/Library/Homebrew/extend/os/linux/sandbox/landlock.rb b/Library/Homebrew/extend/os/linux/sandbox/landlock.rb index 79e86d1508..f6bf46a4f1 100644 --- a/Library/Homebrew/extend/os/linux/sandbox/landlock.rb +++ b/Library/Homebrew/extend/os/linux/sandbox/landlock.rb @@ -54,10 +54,19 @@ class Sandbox ACCESS_NET_BIND_UDP = 0x04 ACCESS_NET_CONNECT_SEND_UDP = 0x08 SCOPE_ABSTRACT_UNIX_SOCKET = 0x01 - # ABI 3 is the first version that can restrict both cross-directory - # renames and truncation: + # ABI 2 is the first version that can permit cross-directory renames and + # links, which earlier ABIs always deny for sandboxed processes: # https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#previous-limitations - MINIMUM_ABI = 3 + MINIMUM_ABI = 2 + # File truncation cannot be restricted before ABI 3, letting sandboxed + # processes truncate files outside allowed write paths. Every profile + # needs write isolation, so unlike `@deny_all_network` there is no + # profile-specific check that could gate a warning: + # https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#previous-limitations + MINIMUM_TRUNCATE_ABI = 3 + # TCP bind and connect restrictions were added in ABI 4: + # https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#network-flags + MINIMUM_NETWORK_ABI = 4 # UDP controls required to block all network access were added in ABI 10: # https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#network-flags MINIMUM_FULL_NETWORK_ABI = 10 @@ -80,8 +89,8 @@ class Sandbox :ACCESS_FS_IOCTL_DEV, :ACCESS_NET_BIND_TCP, :ACCESS_NET_CONNECT_TCP, :ACCESS_NET_BIND_UDP, :ACCESS_NET_CONNECT_SEND_UDP, :SCOPE_ABSTRACT_UNIX_SOCKET, :MINIMUM_ABI, - :MINIMUM_FULL_NETWORK_ABI, :WRITE_ACCESS_FS, :FILE_WRITE_ACCESS_FS, :FILE_READ_ACCESS_FS, - :READ_ACCESS_FS + :MINIMUM_TRUNCATE_ABI, :MINIMUM_NETWORK_ABI, :MINIMUM_FULL_NETWORK_ABI, + :WRITE_ACCESS_FS, :FILE_WRITE_ACCESS_FS, :FILE_READ_ACCESS_FS, :READ_ACCESS_FS class << self # Landlock cannot restrict chmod, chown, extended attributes or timestamp @@ -321,8 +330,13 @@ class Sandbox end if @deny_all_network && abi < MINIMUM_FULL_NETWORK_ABI + network_restrictions = if abi >= MINIMUM_NETWORK_ABI + "Applying the network restrictions supported by this kernel." + else + "This kernel cannot restrict network access." + end opoo "Landlock ABI #{MINIMUM_FULL_NETWORK_ABI} or later is required to deny all network access; " \ - "found ABI #{abi}. Applying the network restrictions supported by this kernel." + "found ABI #{abi}. #{network_restrictions}" end attributes, handled_access_fs, allowed_write_access_fs = ruleset_attributes(abi) @@ -357,7 +371,7 @@ class Sandbox device_path_rules.each do |path, allowed_access| next unless File.exist?(path) - add_path_rule(ruleset_fd, path, allowed_access) + add_path_rule(ruleset_fd, path, allowed_access & handled_access_fs) end error_pipe_path = @error_pipe_path @@ -389,8 +403,8 @@ class Sandbox sig { params(abi: Integer).returns([String, Integer, Integer]) } def ruleset_attributes(abi) allowed_access_fs = WRITE_ACCESS_FS - allowed_access_fs |= ACCESS_FS_REFER if abi >= 2 - allowed_access_fs |= ACCESS_FS_TRUNCATE if abi >= 3 + allowed_access_fs |= ACCESS_FS_REFER if abi >= MINIMUM_ABI + allowed_access_fs |= ACCESS_FS_TRUNCATE if abi >= MINIMUM_TRUNCATE_ABI handled_access_fs = allowed_access_fs # IOCTL_DEV is available from ABI 5 and deliberately remains absent from # allowed path rules, denying device ioctls opened inside the sandbox: @@ -402,7 +416,7 @@ class Sandbox # Optional ruleset fields are appended as `__u64` members, so only pass # the prefix needed for features supported by the running kernel. attributes = [handled_access_fs] - if @deny_all_network && abi >= 4 + if @deny_all_network && abi >= MINIMUM_NETWORK_ABI handled_access_net = ACCESS_NET_BIND_TCP | ACCESS_NET_CONNECT_TCP handled_access_net |= ACCESS_NET_BIND_UDP | ACCESS_NET_CONNECT_SEND_UDP if abi >= MINIMUM_FULL_NETWORK_ABI attributes << handled_access_net diff --git a/Library/Homebrew/test/sandbox_landlock_spec.rb b/Library/Homebrew/test/sandbox_landlock_spec.rb index 15830622e6..304b2952c3 100644 --- a/Library/Homebrew/test/sandbox_landlock_spec.rb +++ b/Library/Homebrew/test/sandbox_landlock_spec.rb @@ -63,13 +63,19 @@ RSpec.describe Sandbox::Landlock do expect(described_class.failure_reason).to include("Fiddle") end - it "returns false for an ABI without truncate restrictions" do + 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(2) - expect(described_class.failure_reason).to eq("Landlock ABI 3 or later is required; found ABI 2.") + 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 it "only raises when explicitly configuring unavailable Landlock" do @@ -116,13 +122,13 @@ RSpec.describe Sandbox::Landlock do allow(File).to receive(:exist?).with("/dev/tty").and_return(false) end - it "rejects an ABI without complete write restrictions" do - allow(described_class).to receive_messages(abi_version: 2, - failure_reason: "Landlock ABI 3 or later is required; found ABI 2.") + 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 3 or later is required; found ABI 2.") + .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 @@ -319,6 +325,49 @@ RSpec.describe Sandbox::Landlock do 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