From e32de1d4b21140fb0a4f28a516711c5bea3c50a2 Mon Sep 17 00:00:00 2001 From: Justin Krehel <39449589+krehel@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:30:19 -0400 Subject: [PATCH] cask/audit: detect incorrect app case Occasionally PR's or issues are opened in homebrew/cask due to applications not matching the right case name on case-sensitive filesystems. These are relatively rare since most users run with the standard case-insensitive, but it's still a negative user experience. This audit is intended to catch these cases. --- Library/Homebrew/cask/audit.rb | 49 +++++++++++++ Library/Homebrew/test/cask/audit_spec.rb | 87 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index d48a17d258..df85c24e90 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -686,6 +686,55 @@ module Cask end end + sig { void } + def audit_artifact_case + return if (url = cask.url).nil? + return unless online? + + odebug "Auditing artifact case" + + extract_artifacts(include_manual_installers: true) do |artifacts, tmpdir| + artifacts.each do |artifact| + source = case artifact + when Artifact::Pkg, Artifact::Installer + artifact.path + else + artifact.source + end + + source = if source.to_s.start_with?("#{cask.appdir}/") + Pathname(source.to_s.delete_prefix("#{cask.appdir}/")) + elsif source.absolute? + source.relative_path_from(cask.staged_path) + else + source + end + + components = source.each_filename.to_a + current = tmpdir + on_disk = [] + components.each do |component| + break unless current.directory? + + children = current.children.map { |child| child.basename.to_s } + match = children.find { |name| name == component } || + children.find { |name| name.casecmp?(component) } + break if match.nil? + + on_disk << match + current /= match + end + + next if on_disk.length != components.length + next if on_disk == components + + add_error "Artifact #{source} does not match the case of the extracted " \ + "#{File.join(on_disk)}; this fails on case-sensitive filesystems.", + location: url.location + end + end + end + sig { void } def audit_rosetta return if (url = cask.url).nil? diff --git a/Library/Homebrew/test/cask/audit_spec.rb b/Library/Homebrew/test/cask/audit_spec.rb index e3c1deead6..48be6e86af 100644 --- a/Library/Homebrew/test/cask/audit_spec.rb +++ b/Library/Homebrew/test/cask/audit_spec.rb @@ -1205,6 +1205,93 @@ RSpec.describe Cask::Audit, :cask do end end + describe "artifact case checks" do + let(:online) { true } + let(:only) { ["artifact_case"] } + let(:tmpdir) { mktmpdir } + let(:cask) do + Cask::Cask.new("artifact-case") do + version "1.0" + sha256 :no_check + url "https://brew.sh/artifact-case.zip" + name "Artifact Case" + homepage "https://brew.sh/" + + app "artifact case.app" + end + end + + before do + allow(audit).to receive(:extract_artifacts).and_yield(cask.artifacts, tmpdir) + end + + context "when the case matches" do + before { (tmpdir/"artifact case.app").mkpath } + + it { is_expected.to pass } + end + + context "when the case does not match" do + before { (tmpdir/"Artifact Case.app").mkpath } + + it { is_expected.to error_with(/does not match the case of the extracted/) } + end + + context "when both cases are present on disk" do + # Both spellings cannot be created on a case-insensitive filesystem. + before do + allow(tmpdir).to receive(:children) + .and_return([tmpdir/"Artifact Case.app", tmpdir/"artifact case.app"]) + end + + it { is_expected.to pass } + end + + context "when the artifact is missing" do + it { is_expected.to pass } + end + + context "when a binary in the appdir has the wrong case" do + let(:cask) do + Cask::Cask.new("artifact-case") do + version "1.0" + sha256 :no_check + url "https://brew.sh/artifact-case.zip" + name "Artifact Case" + homepage "https://brew.sh/" + + app "Artifact Case.app" + binary "#{appdir}/Artifact Case.app/Contents/MacOS/artifact" + end + end + + before do + (tmpdir/"Artifact Case.app/Contents/MacOS").mkpath + FileUtils.touch tmpdir/"Artifact Case.app/Contents/MacOS/Artifact" + end + + it { is_expected.to error_with(/does not match the case of the extracted/) } + end + + context "when a manual installer has the wrong case" do + let(:cask) do + Cask::Cask.new("artifact-case") do + version "1.0" + sha256 :no_check + url "https://brew.sh/artifact-case.zip" + name "Artifact Case" + homepage "https://brew.sh/" + + installer manual: "Artifact Case.app" + end + end + + before { (tmpdir/"Artifact Case.APP").mkpath } + + it { is_expected.to error_with(/does not match the case of the extracted/) } + end + end + describe "minimum OS checks" do let(:online) { true } let(:only) { ["min_os"] }