aboutsummaryrefslogtreecommitdiffstats
path: root/Library/Homebrew/cask/lib/hbc/audit.rb
blob: cee1fe8070453f46c02462459b3cd67820a7d218 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
require "hbc/checkable"
require "hbc/download"
require "digest"
require "utils/git"

module Hbc
  class Audit
    include Checkable

    attr_reader :cask, :commit_range, :download

    def initialize(cask, download: false, check_token_conflicts: false, commit_range: nil, command: SystemCommand)
      @cask = cask
      @download = download
      @commit_range = commit_range
      @check_token_conflicts = check_token_conflicts
      @command = command
    end

    def check_token_conflicts?
      @check_token_conflicts
    end

    def run!
      check_required_stanzas
      check_version_and_checksum
      check_version
      check_sha256
      check_appcast
      check_url
      check_generic_artifacts
      check_token_conflicts
      check_download
      self
    rescue StandardError => e
      odebug "#{e.message}\n#{e.backtrace.join("\n")}"
      add_error "exception while auditing #{cask}: #{e.message}"
      self
    end

    def success?
      !(errors? || warnings?)
    end

    def summary_header
      "audit for #{cask}"
    end

    private

    def check_required_stanzas
      odebug "Auditing required stanzas"
      [:version, :sha256, :url, :homepage].each do |sym|
        add_error "a #{sym} stanza is required" unless cask.send(sym)
      end
      add_error "at least one name stanza is required" if cask.name.empty?
      # TODO: specific DSL knowledge should not be spread around in various files like this
      # TODO: nested_container should not still be a pseudo-artifact at this point
      installable_artifacts = cask.artifacts.reject { |k| [:uninstall, :zap, :nested_container].include?(k) }
      add_error "at least one activatable artifact stanza is required" if installable_artifacts.empty?
    end

    def check_version_and_checksum
      return if @cask.sourcefile_path.nil?

      tap = Tap.select { |t| t.cask_file?(@cask.sourcefile_path) }.first
      return if tap.nil?

      return if commit_range.nil?
      previous_cask_contents = Git.last_revision_of_file(tap.path, @cask.sourcefile_path, before_commit: commit_range)
      return if previous_cask_contents.empty?

      previous_cask = CaskLoader.load_from_string(previous_cask_contents)

      return unless previous_cask.version == cask.version
      return if previous_cask.sha256 == cask.sha256

      add_error "only sha256 changed (see: https://github.com/caskroom/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/sha256.md)"
    end

    def check_version
      return unless cask.version
      check_no_string_version_latest
      check_no_file_separator_in_version
    end

    def check_no_string_version_latest
      odebug "Verifying version :latest does not appear as a string ('latest')"
      return unless cask.version.raw_version == "latest"
      add_error "you should use version :latest instead of version 'latest'"
    end

    def check_no_file_separator_in_version
      odebug "Verifying version does not contain '#{File::SEPARATOR}'"
      return unless cask.version.raw_version.is_a?(String)
      return unless cask.version.raw_version.include?(File::SEPARATOR)
      add_error "version should not contain '#{File::SEPARATOR}'"
    end

    def check_sha256
      return unless cask.sha256
      check_sha256_no_check_if_latest
      check_sha256_actually_256
      check_sha256_invalid
    end

    def check_sha256_no_check_if_latest
      odebug "Verifying sha256 :no_check with version :latest"
      return unless cask.version.latest? && cask.sha256 != :no_check
      add_error "you should use sha256 :no_check when version is :latest"
    end

    def check_sha256_actually_256(sha256: cask.sha256, stanza: "sha256")
      odebug "Verifying #{stanza} string is a legal SHA-256 digest"
      return unless sha256.is_a?(String)
      return if sha256.length == 64 && sha256[/^[0-9a-f]+$/i]
      add_error "#{stanza} string must be of 64 hexadecimal characters"
    end

    def check_sha256_invalid(sha256: cask.sha256, stanza: "sha256")
      odebug "Verifying #{stanza} is not a known invalid value"
      empty_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
      return unless sha256 == empty_sha256
      add_error "cannot use the sha256 for an empty string in #{stanza}: #{empty_sha256}"
    end

    def check_appcast
      return unless cask.appcast
      odebug "Auditing appcast"
      check_appcast_has_checkpoint
      return unless cask.appcast.checkpoint
      check_sha256_actually_256(sha256: cask.appcast.checkpoint, stanza: "appcast :checkpoint")
      check_sha256_invalid(sha256: cask.appcast.checkpoint, stanza: "appcast :checkpoint")
      return unless download
      check_appcast_http_code
      check_appcast_checkpoint_accuracy
    end

    def check_appcast_has_checkpoint
      odebug "Verifying appcast has :checkpoint key"
      add_error "a checkpoint sha256 is required for appcast" unless cask.appcast.checkpoint
    end

    def check_appcast_http_code
      odebug "Verifying appcast returns 200 HTTP response code"
      result = @command.run("/usr/bin/curl", args: ["--compressed", "--location", "--user-agent", URL::FAKE_USER_AGENT, "--output", "/dev/null", "--write-out", "%{http_code}", cask.appcast], print_stderr: false)
      if result.success?
        http_code = result.stdout.chomp
        add_warning "unexpected HTTP response code retrieving appcast: #{http_code}" unless http_code == "200"
      else
        add_warning "error retrieving appcast: #{result.stderr}"
      end
    end

    def check_appcast_checkpoint_accuracy
      odebug "Verifying appcast checkpoint is accurate"
      result = cask.appcast.calculate_checkpoint

      actual_checkpoint = result[:checkpoint]

      if actual_checkpoint.nil?
        add_warning "error retrieving appcast: #{result[:command_result].stderr}"
      else
        expected = cask.appcast.checkpoint
        add_warning <<-EOS.undent unless expected == actual_checkpoint
          appcast checkpoint mismatch
          Expected: #{expected}
          Actual: #{actual_checkpoint}
        EOS
      end
    end

    def check_url
      return unless cask.url
      check_download_url_format
    end

    def check_download_url_format
      odebug "Auditing URL format"
      if bad_sourceforge_url?
        add_warning "SourceForge URL format incorrect. See https://github.com/caskroom/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#sourceforgeosdn-urls"
      elsif bad_osdn_url?
        add_warning "OSDN URL format incorrect. See https://github.com/caskroom/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#sourceforgeosdn-urls"
      end
    end

    def bad_url_format?(regex, valid_formats_array)
      return false unless cask.url.to_s =~ regex
      valid_formats_array.none? { |format| cask.url.to_s =~ format }
    end

    def bad_sourceforge_url?
      bad_url_format?(/sourceforge/,
                      [
                        %r{\Ahttps://sourceforge\.net/projects/[^/]+/files/latest/download\Z},
                        %r{\Ahttps://downloads\.sourceforge\.net/(?!(project|sourceforge)\/)},
                        # special cases: cannot find canonical format URL
                        %r{\Ahttps?://brushviewer\.sourceforge\.net/brushviewql\.zip\Z},
                        %r{\Ahttps?://doublecommand\.sourceforge\.net/files/},
                        %r{\Ahttps?://excalibur\.sourceforge\.net/get\.php\?id=},
                      ])
    end

    def bad_osdn_url?
      bad_url_format?(/osd/, [%r{\Ahttps?://([^/]+.)?dl\.osdn\.jp/}])
    end

    def check_generic_artifacts
      cask.artifacts[:artifact].each do |source, target_hash|
        unless target_hash.is_a?(Hash) && target_hash[:target]
          add_error "target required for generic artifact #{source}"
          next
        end
        add_error "target must be absolute path for generic artifact #{source}" unless Pathname.new(target_hash[:target]).absolute?
      end
    end

    def check_token_conflicts
      return unless check_token_conflicts?
      return unless core_formula_names.include?(cask.token)
      add_warning "possible duplicate, cask token conflicts with Homebrew core formula: #{core_formula_url}"
    end

    def core_tap
      @core_tap ||= CoreTap.instance
    end

    def core_formula_names
      core_tap.formula_names
    end

    def core_formula_url
      "#{core_tap.default_remote}/blob/master/Formula/#{cask.token}.rb"
    end

    def check_download
      return unless download && cask.url
      odebug "Auditing download"
      downloaded_path = download.perform
      Verify.all(cask, downloaded_path)
    rescue => e
      add_error "download not possible: #{e.message}"
    end
  end
end