2017-12-03 11:37:18 +01:00

361 lines
12 KiB
Ruby

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_https_availability
check_download
check_single_pre_postflight
check_single_uninstall_zap
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_single_pre_postflight
odebug "Auditing preflight and postflight stanzas"
if cask.artifacts.count { |k| k.is_a?(Hbc::Artifact::PreflightBlock) && k.directives.key?(:preflight) } > 1
add_warning "only a single preflight stanza is allowed"
end
return unless cask.artifacts.count { |k| k.is_a?(Hbc::Artifact::PostflightBlock) && k.directives.key?(:postflight) } > 1
add_warning "only a single postflight stanza is allowed"
end
def check_single_uninstall_zap
odebug "Auditing single uninstall_* and zap stanzas"
if cask.artifacts.count { |k| k.is_a?(Hbc::Artifact::Uninstall) } > 1
add_warning "only a single uninstall stanza is allowed"
end
if cask.artifacts.count { |k| k.is_a?(Hbc::Artifact::PreflightBlock) && k.directives.key?(:uninstall_preflight) } > 1
add_warning "only a single uninstall_preflight stanza is allowed"
end
if cask.artifacts.count { |k| k.is_a?(Hbc::Artifact::PostflightBlock) && k.directives.key?(:uninstall_postflight) } > 1
add_warning "only a single uninstall_postflight stanza is allowed"
end
return unless cask.artifacts.count { |k| k.is_a?(Hbc::Artifact::Zap) } > 1
add_warning "only a single zap stanza is allowed"
end
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?
begin
previous_cask = CaskLoader.load(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)"
rescue CaskError => e
add_warning "Skipped version and checksum comparison. Reading previous version failed: #{e}"
end
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"
curl_executable, *args = curl_args(
"--compressed", "--location", "--fail",
"--write-out", "%{http_code}",
"--output", "/dev/null",
cask.appcast,
user_agent: :fake
)
result = @command.run(curl_executable, args: args, 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 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.select { |a| a.is_a?(Hbc::Artifact::Artifact) }.each do |artifact|
unless artifact.target.absolute?
add_error "target must be absolute path for #{artifact.class.english_name} #{artifact.source}"
end
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_https_availability
check_url_for_https_availability(cask.homepage) unless cask.url.to_s.empty?
check_url_for_https_availability(cask.appcast) unless cask.appcast.to_s.empty?
check_url_for_https_availability(cask.homepage) unless cask.homepage.to_s.empty?
end
def check_url_for_https_availability(url_to_check)
if schema_http?(url_to_check)
result, effective_url = access_url(url_to_check.sub(/^http:/, 'https:'))
if schema_https?(effective_url) && result == 1
add_error "Change #{url_to_check} to #{url_to_check.sub(/^http:/, 'https:')}"
else
result, effective_url = access_url(url_to_check)
if result == 0
add_error "URL is not reachable #{url_to_check}"
end
end
else
result, effective_url = access_url(url_to_check)
if result == 1 && schema_https?(effective_url)
return
else
result, effective_url = access_url(url_to_check.sub(/^https:/, 'http:'))
if result == 1 && schema_http?(effective_url)
add_error "Change #{url_to_check} to #{url_to_check.sub(/^https:/, 'http:')}"
else
add_error "URL is not reachable #{url_to_check}"
end
end
end
end
def access_url(url_to_access)
# return values:
# 1, effective URL : URL reachable, no schema change
# 0, nil : URL unreachable
# -1, effective URL : URL reachable, but schema changed
curl_executable, *args = curl_args(
"--compressed", "--location", "--fail",
"--write-out", "%{http_code} %{url_effective}",
"--output", "/dev/null", "--head",
url_to_access,
user_agent: :fake
)
result = @command.run(curl_executable, args: args, print_stderr: false)
if result.success?
http_code, url_effective = result.stdout.chomp.split(' ')
odebug "input: #{url_to_access} effective: #{url_effective} code: #{http_code}"
# Fail if return code not 2XX or 3XX
return 0, nil if http_code.to_i < 200 && http_code.to_i > 300
# Fail if URL schema changed
# ([4] is either http[s]:// or http[:]// )
return -1, url_effective if url_to_access[4] != url_effective[4]
return 1, url_effective
else
return 0, nil
end
end
def schema_http?(url)
url[/^http:/] ? 1 : nil
end
def schema_https?(url)
url[/^https:/] ? 1 : nil
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