Module: Cask::CI::ZapCheck Private

Extended by:
SystemCommand::Mixin
Defined in:
cask/ci/zap_check.rb

Overview

This module is part of a private API. This module may only be used in the Homebrew/brew repository. Third parties should avoid using this module if possible, as it may be removed or changed without warning.

Exercises a Cask's apps and reports paths missing from its zap stanza.

Constant Summary collapse

FNMATCH_FLAGS =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

T.let((File::FNM_CASEFOLD | File::FNM_EXTGLOB).freeze, Integer)
LAUNCH_TIMEOUT =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

15
SETTLE_SECONDS =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

10
QUIT_TIMEOUT =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

10
GENERATE_ZAP_TIMEOUT =

This constant is part of a private API. This constant may only be used in the Homebrew/brew repository. Third parties should avoid using this constant if possible, as it may be removed or changed without warning.

300

Class Method Summary collapse

Methods included from SystemCommand::Mixin

system_command, system_command!

Class Method Details

.covers?(existing, generated) ⇒ Boolean

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

Returns:

  • (Boolean)


180
181
182
183
184
185
186
# File 'cask/ci/zap_check.rb', line 180

def self.covers?(existing, generated)
  existing == generated ||
    File.fnmatch?(existing, generated, FNMATCH_FLAGS) ||
    # Generated paths may themselves contain wildcards (e.g. collapsed
    # UUIDs), which an existing literal entry can never fnmatch.
    File.fnmatch?(generated, existing, FNMATCH_FLAGS)
end

.descendant?(path, of:) ⇒ Boolean

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

Returns:

  • (Boolean)


189
190
191
# File 'cask/ci/zap_check.rb', line 189

def self.descendant?(path, of:)
  path.downcase.start_with?("#{of.downcase}/")
end

.exercise(apps) ⇒ void

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

This method returns an undefined value.

Parameters:



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'cask/ci/zap_check.rb', line 75

def self.exercise(apps)
  return if apps.empty?

  apps.each do |app|
    # Gatekeeper blocks `open` on quarantined apps and the quarantine
    # attribute has already been verified during `brew install`.
    system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", app.to_s], print_stderr: false
    launch app

    puts "Warning: #{app} was not running after #{LAUNCH_TIMEOUT} seconds." unless wait_until(LAUNCH_TIMEOUT) do
      running?(app)
    end
  end

  sleep SETTLE_SECONDS

  quit apps
end

.generate_zap(cask_path) ⇒ SystemCommand::Result?

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

Returns:



65
66
67
68
69
70
71
72
# File 'cask/ci/zap_check.rb', line 65

def self.generate_zap(cask_path)
  system_command ENV.fetch("HOMEBREW_BREW_FILE"),
                 args:         ["generate-zap", cask_path],
                 print_stderr: false,
                 timeout:      GENERATE_ZAP_TIMEOUT
rescue Timeout::Error
  nil
end

.launch(app) ⇒ void

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

This method returns an undefined value.

Parameters:



95
96
97
98
99
100
101
102
103
104
# File 'cask/ci/zap_check.rb', line 95

def self.launch(app)
  result = system_command "/usr/bin/open", args: ["-g", app.to_s], print_stderr: false,
                                           timeout: LAUNCH_TIMEOUT
  return if result.success?

  puts "Warning: Failed to open #{app} (exit status: #{result.exit_status}):"
  puts result.merged_output
rescue Timeout::Error
  puts "Warning: Timed out opening #{app} after #{LAUNCH_TIMEOUT} seconds."
end

.process_pattern(app) ⇒ String

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

Returns:



148
149
150
# File 'cask/ci/zap_check.rb', line 148

def self.process_pattern(app)
  "^#{Regexp.escape("#{app}/")}"
end

.quit(apps) ⇒ void

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

This method returns an undefined value.

Ask the app to quit the same way uninstall quit: does, which also prompts cfprefsd to write the app's preferences to disk, where brew generate-zap can find them. An app that ignores the request is left running: signalling it instead denies it the chance to undo any system-wide changes it made.

Parameters:



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'cask/ci/zap_check.rb', line 111

def self.quit(apps)
  apps.each do |app|
    script = <<~JAVASCRIPT
      'use strict';

      ObjC.import('stdlib')

      function run(argv) {
        var app = Application(argv[0])

        try {
          app.quit()
        } catch (err) {
          if (app.running()) {
            $.exit(1)
          }
        }

        $.exit(0)
      }
    JAVASCRIPT
    system_command "osascript", args: ["-l", "JavaScript", "-e", script, app.to_s], print_stderr: false

    next if wait_until(QUIT_TIMEOUT) { !running?(app) }

    puts "Warning: #{app} did not quit when asked; leaving it running."
  end

  sleep 3
end

.reject_covered(cask, generated_paths) ⇒ Array<String>

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

Returns:



164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'cask/ci/zap_check.rb', line 164

def self.reject_covered(cask, generated_paths)
  zap_directives = cask.artifacts.grep(::Cask::Artifact::Zap).map(&:directives)
  deletable_paths = zap_directives.flat_map { |directives| directives.values_at(:trash, :delete) }
                                  .flatten.compact.map(&:to_s)
  # `rmdir` only removes empty directories, so it covers a generated path
  # itself but not anything beneath it.
  rmdir_paths = zap_directives.flat_map { |directives| directives[:rmdir] }
                              .flatten.compact.map(&:to_s)

  generated_paths.reject do |generated|
    deletable_paths.any? { |existing| covers?(existing, generated) || descendant?(generated, of: existing) } ||
      rmdir_paths.any? { |existing| covers?(existing, generated) }
  end
end

.report(cask_path, intro, body) ⇒ void

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

This method returns an undefined value.

Parameters:



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'cask/ci/zap_check.rb', line 194

def self.report(cask_path, intro, body)
  puts GitHub::Actions::Annotation.new(:warning, "#{intro}\n#{body}",
                                       file: cask_path, title: "Possible missing zap paths")

  summary_path = ENV.fetch("GITHUB_STEP_SUMMARY", nil)
  return unless summary_path

  File.open(summary_path, "a") do |file|
    file.puts <<~MARKDOWN
      ## Possible missing zap paths

      #{intro}

      ```text
      #{body}
      ```
    MARKDOWN
  end
end

.run(cask_path) ⇒ void

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

This method returns an undefined value.

Parameters:



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'cask/ci/zap_check.rb', line 24

def self.run(cask_path)
  $stdout.sync = true

  cask = ::Cask::CaskLoader.load(cask_path)
  apps = cask.artifacts.grep(::Cask::Artifact::App).map(&:target).select(&:directory?)
  exercise(apps)

  result = generate_zap(cask_path)
  if result.nil?
    report cask_path, "Unable to check `#{cask.token}`:",
           "`brew generate-zap` timed out after #{GENERATE_ZAP_TIMEOUT} seconds."
    return
  end

  unless result.success?
    report cask_path, "Unable to check `#{cask.token}` with `brew generate-zap`:",
           Tty.strip_ansi(result.stderr.presence || result.stdout)
    return
  end

  stanza = Tty.strip_ansi(result.stdout)[/^zap .*/m]
  generated_paths = stanza&.scan(/"([^"]+)"/)&.flatten
  if generated_paths.blank?
    puts "brew generate-zap found no leftover files for #{cask.token}."
    return
  end

  puts "Stanza generated by brew generate-zap:", stanza, ""

  missing_paths = reject_covered(cask, generated_paths)
  if missing_paths.empty?
    puts "All #{generated_paths.count} generated paths are covered by the existing zap stanza."
    return
  end

  report cask_path,
         "`brew generate-zap` found paths not covered by the current `zap` stanza of `#{cask.token}`:",
         missing_paths.join("\n")
end

.running?(app) ⇒ Boolean

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

Returns:

  • (Boolean)


143
144
145
# File 'cask/ci/zap_check.rb', line 143

def self.running?(app)
  system_command("/usr/bin/pgrep", args: ["-f", process_pattern(app)], print_stderr: false).success?
end

.wait_until(timeout, &block) ⇒ Boolean

This method is part of a private API. This method may only be used in the Homebrew/brew repository. Third parties should avoid using this method if possible, as it may be removed or changed without warning.

Parameters:

  • timeout (Integer)
  • block (T.proc.returns(T::Boolean))

Returns:

  • (Boolean)


153
154
155
156
157
158
159
160
161
# File 'cask/ci/zap_check.rb', line 153

def self.wait_until(timeout, &block)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  loop do
    return true if yield
    return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

    sleep 0.5
  end
end