aboutsummaryrefslogtreecommitdiffstats
path: root/Library/Homebrew/cask/lib/hbc/audit.rb
blob: 98f09ffa4378c34cb4120a4981a2ff35bc823f3f (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
require "hbc/checkable"
require "hbc/download"
require "digest"

class Hbc::Audit
  include Hbc::Checkable

  attr_reader :cask, :download

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

  def check_token_conflicts?
    @check_token_conflicts
  end

  def run!
    check_required_stanzas
    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"
    %i{version sha256 url homepage}.each do |sym|
      add_error "a #{sym} stanza is required" unless cask.send(sym)
    end
    add_error "a license stanza is required (:unknown is OK)" unless cask.license
    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
    return unless cask.version
    check_no_string_version_latest
  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_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[%r{^[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", Hbc::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 = @command.run("/usr/bin/curl", args: ["--compressed", "--location", "--user-agent", Hbc::URL::FAKE_USER_AGENT, cask.appcast], print_stderr: false)
    if result.success?
      processed_appcast_text = result.stdout.gsub(%r{<pubDate>[^<]*</pubDate>}, "")
      # This step is necessary to replicate running `sed` from the command line
      processed_appcast_text << "\n" unless processed_appcast_text.end_with?("\n")
      expected = cask.appcast.checkpoint
      actual = Digest::SHA2.hexdigest(processed_appcast_text)
      add_warning <<-EOS.undent unless expected == actual
        appcast checkpoint mismatch
        Expected: #{expected}
        Actual: #{actual}
      EOS
    else
      add_warning "error retrieving appcast: #{result.stderr}"
    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?(%r{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?(%r{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
    Hbc::Verify.all(cask, downloaded_path)
  rescue => e
    add_error "download not possible: #{e.message}"
  end
end