openSUSE/open-build-service

View on GitHub
src/api/app/models/patchinfo.rb

Summary

Maintainability
B
6 hrs
Test Coverage
B
89%
# a Patchinfo lives in a Project, but is not a package - it represents a special file
# in a update package

# if you wonder it's not a module, read http://blog.codeclimate.com/blog/2012/11/14/why-ruby-class-methods-resist-refactoring
class Patchinfo
  include ValidationHelper
  include ActiveModel::Model

  class PatchinfoFileExists < APIError; end

  class IncompletePatchinfo < APIError; end

  class ReleasetargetNotFound < APIError
    setup 404
  end

  class TrackerNotFound < APIError
    setup 404
  end

  # FIXME: Layout and colors belong to CSS
  RATING_COLORS = {
    'low' => 'green',
    'moderate' => 'olive',
    'important' => 'red',
    'critical' => 'maroon'
  }.freeze

  RATINGS = RATING_COLORS.keys.freeze

  CATEGORY_COLORS = {
    'recommended' => 'green',
    'security' => 'maroon',
    'optional' => 'olive',
    'feature' => '',
    'ptf' => ''
  }.freeze

  # '' is a valid category
  CATEGORIES = (CATEGORY_COLORS.keys << '').freeze

  attr_reader :document
  attr_writer :data
  attr_accessor :summary, :description, :packager, :category, :rating, :name,
                :binaries, :version, :message, :retracted, :relogin_needed,
                :reboot_needed, :zypp_restart_needed, :block, :block_reason, :issues,
                :issueid, :issuetracker, :issueurl, :issuesum

  validates :summary, length: { minimum: 10 }
  validates :description, length: { minimum: 50 }
  validates :packager, presence: true
  validate :issue_tracker_existence

  def hashed
    Xmlhash.parse(document.to_xml)
  end

  # patchinfo has two roles
  def initialize(attributes = {})
    super

    @data ||= '<patchinfo/>'
    @document = Nokogiri::XML(@data, &:strict)
  end

  def is_repository_matching?(repo, rt)
    return false if repo.project.name != rt['project']

    return false if rt['repository'] && (repo.name != rt['repository'])

    true
  end

  # check if we can find the releasetarget (xmlhash) in the project
  def check_releasetarget!(rt)
    @project.repositories.each do |r|
      r.release_targets.each do |prt|
        return if is_repository_matching?(prt.target_repository, rt)
      end
    end
    raise ReleasetargetNotFound, "Release target '#{rt['project']}/#{rt['repository']}' is not defined " \
                                 "in this project '#{@project.name}'. Please ask your OBS administrator to add it."
  end

  def verify_data(project, raw_post)
    @project = project
    data = Xmlhash.parse(raw_post)
    # check the packager field
    User.find_by_login!(data['packager']) if data['packager']
    # valid tracker?
    data.elements('issue').each do |i|
      tracker = IssueTracker.find_by_name(i['tracker'])
      raise TrackerNotFound, "Tracker #{i['tracker']} is not registered in this OBS instance" unless tracker

      issue = Issue.new(name: i['id'], issue_tracker: tracker)
      raise Issue::InvalidName, issue.errors.full_messages.to_sentence unless issue.valid?
    end
    # are releasetargets specified ? validate that this project is actually defining them.
    data.elements('releasetarget') { |r| check_releasetarget!(r) }
  end

  def add_issue_to_patchinfo(issue)
    tracker = issue.issue_tracker
    return if @patchinfo.document.xpath("issue[(@id='#{issue.name}' and @tracker='#{tracker.name}')]").present?

    @patchinfo.document.root.add_child("<issue id='#{issue.name}' tracker='#{tracker.name}'/>")
    @patchinfo.document.at_css('category').content = 'security' if tracker.kind == 'cve'
  end

  def fetch_issue_for_package(package)
    # create diff per package
    return if package.is_patchinfo?

    package.package_issues.each do |i|
      add_issue_to_patchinfo(i.issue) if i.change == 'added'
    end
  end

  def update_patchinfo(project, patchinfo, opts = {})
    project.check_write_access!
    @patchinfo = patchinfo

    opts[:enfore_issue_update] ||= false

    # collect bugnumbers from diff
    project.packages.each { |p| fetch_issue_for_package(p) }

    # update informations of empty issues
    patchinfo.document.css('issue').each do |i|
      next if i.content.present? || i['name'].blank?

      issue = Issue.find_or_create_by_name_and_tracker(i['name'], i['tracker'])
      next unless issue

      # enforce update from issue server
      issue.fetch_updates if opts[:enfore_issue_update]
      i.text = issue.summary
    end
    patchinfo
  end

  def patchinfo_node(project)
    xml = Nokogiri::XML('<patchinfo/>').root
    if project.is_maintenance_incident?
      # this is a maintenance incident project, the sub project name is the maintenance ID
      xml.set_attribute('incident', @pkg.project.name.gsub(/.*:/, ''))
    end
    xml.add_child('<category>recommended</category>')
    xml.add_child('<rating>low</rating>')
    xml
  end

  def create_patchinfo_from_request(project, req)
    project.check_write_access!
    @prj = project

    # create patchinfo package
    create_patchinfo_package('patchinfo')

    # create patchinfo XML file
    xml = patchinfo_node(project)

    description = req.description || ''
    xml.add_child("<packager>#{CGI.escapeHTML(req.creator)}</packager>")
    xml.add_child("<summary>#{CGI.escapeHTML(description.split(/\n|\r\n/)[0] || '')}</summary>") # first line only
    xml.add_child("<description>#{CGI.escapeHTML(description)}</description>")

    xml = update_patchinfo(project, xml, enfore_issue_update: true)
    Backend::Api::Sources::Package.write_patchinfo(@pkg.project.name, @pkg.name, User.session!.login, xml.to_xml,
                                                   "generated by request id #{req.number} accept call")
    @pkg.sources_changed
  end

  def create_patchinfo_package(pkg_name)
    Package.transaction do
      @pkg = @prj.packages.new(name: pkg_name, title: 'Patchinfo', description: 'Collected packages for update')
      @pkg.add_flag('build', 'enable', nil, nil)
      @pkg.add_flag('publish', 'enable', nil, nil) unless @prj.flags.find_by_flag_and_status('access', 'disable')
      @pkg.add_flag('useforbuild', 'disable', nil, nil)
      @pkg.store
    end
  end

  def require_package_for_patchinfo(project, pkg_name, force)
    pkg_name ||= 'patchinfo'
    valid_package_name!(pkg_name)

    # create patchinfo package
    unless Package.exists_by_project_and_name(project, pkg_name)
      @prj = Project.get_by_name(project)
      create_patchinfo_package(pkg_name)
      return
    end

    @pkg = Package.get_by_project_and_name(project, pkg_name)
    return if force

    if @pkg.is_patchinfo?
      raise PatchinfoFileExists, "createpatchinfo command: the patchinfo #{pkg_name} exists already. " \
                                 'Either use force=1 re-create the _patchinfo or use updatepatchinfo for updating.'
    else
      raise PackageAlreadyExists, "createpatchinfo command: the package #{pkg_name} exists already, " \
                                  'but is  no patchinfo. Please create a new package instead.'
    end
  end

  def create_patchinfo(project, pkg_name, opts = {})
    require_package_for_patchinfo(project, pkg_name, opts[:force])

    # create patchinfo XML file
    xml = patchinfo_node(@pkg.project)
    xml.add_child("<packager>#{CGI.escapeHTML(User.session!.login)}</packager>")
    if opts[:comment].present?
      xml.add_child("<summary>#{CGI.escapeHTML(opts[:comment])}</summary>")
    else
      xml.add_child('<summary/>')
    end
    xml.add_child('<description/>')
    xml = update_patchinfo(@pkg.project, xml)
    if CONFIG['global_write_through']
      Backend::Api::Sources::Package.write_patchinfo(@pkg.project.name, @pkg.name, User.session!.login, xml.to_xml,
                                                     'generated by createpatchinfo call')
    end
    @pkg.sources_changed
    { targetproject: @pkg.project.name, targetpackage: @pkg.name }
  end

  def cmd_update_patchinfo(project, package, message = 'updated via updatepatchinfo call')
    pkg = Package.get_by_project_and_name(project, package)

    # get existing file
    xml = pkg.patchinfo
    xml = update_patchinfo(pkg.project, xml)

    Backend::Api::Sources::Package.write_patchinfo(pkg.project.name, pkg.name, User.session!.login, xml.document.to_xml, message)
    pkg.sources_changed
  end

  def read_patchinfo_xmlhash(pkg)
    xml = Xmlhash.parse(pkg.source_file('_patchinfo'))
    # patch old data to stay compatible
    xml.elements('issue') do |i|
      i['id'].gsub!(/^(CVE|cve)-/, '') if i['tracker'] == 'cve'
    end
    xml
  end

  def fetch_release_targets(pkg)
    data = read_patchinfo_xmlhash(pkg)
    # validate _patchinfo for completeness
    raise IncompletePatchinfo, 'The _patchinfo file is not parseble' if data.empty?

    %w[rating category summary].each do |field|
      raise IncompletePatchinfo, "The _patchinfo has no #{field} set" if data[field].blank?
    end
    # a patchinfo may limit the targets
    data.elements('releasetarget')
  end

  def issues_by_tracker
    issues_by_tracker = {}
    issues.each do |issue|
      issues_by_tracker[issue.value('tracker')] ||= []
      issues_by_tracker[issue.value('tracker')] << issue
    end
    issues_by_tracker
  end

  def load_from_xml(patchinfo_xml)
    self.binaries = []
    patchinfo_xml.elements('binary').each do |binaries|
      self.binaries << binaries
    end
    self.packager = patchinfo_xml.value('packager')
    self.version = patchinfo_xml['version']

    self.issues = []
    patchinfo_xml.elements('issue') do |issue_element|
      if issue_element['_content'].blank?
        # old uploaded patchinfos could have broken tracker-names like "bnc "
        # instead of "bnc". Catch these.
        begin
          summary = IssueTracker::IssueSummary.new(issue_element['tracker'], issue_element['id'])
          issue_element['_content'] = summary.issue_summary if summary.belongs_bug_to_tracker?
        rescue Backend::NotFoundError
          issue_element['_content'] = 'PLEASE CHECK THE FORMAT OF THE ISSUE'
        end
      end

      issues << [
        issue_element['id'],
        issue_element['tracker'],
        IssueTracker.find_by_name(issue_element['tracker']).try(:show_url_for, issue_element['id']).to_s,
        issue_element['_content']
      ]
    end
    self.category = patchinfo_xml.value('category')
    self.rating = patchinfo_xml.value('rating')
    self.summary = patchinfo_xml.value('summary')
    self.name = patchinfo_xml.value('name')

    self.description = patchinfo_xml.value('description')
    self.message = patchinfo_xml.value('message')
    self.relogin_needed = !patchinfo_xml.value('relogin_needed').nil?
    self.reboot_needed = !patchinfo_xml.value('reboot_needed').nil?
    self.retracted = !patchinfo_xml.value('retracted').nil?
    self.zypp_restart_needed = !patchinfo_xml.value('zypp_restart_needed').nil?
    if patchinfo_xml.value('stopped')
      self.block = true
      self.block_reason = patchinfo_xml.value('stopped')
    end

    self
  end

  def to_xml(project, package)
    self.issues = []
    issueid.to_a.each_with_index do |new_issue, index|
      issues << [
        new_issue,
        issuetracker[index],
        IssueTracker.find_by_name(issuetracker[index]).try(:show_url_for, new_issue).to_s,
        issuesum[index]
      ]
    end
    node = Builder::XmlMarkup.new(indent: 2)
    attrs = {
      incident: project.name.gsub(/.*:/, '')
    }
    attrs[:version] = version if version.present?
    node.patchinfo(attrs) do
      binaries.to_a.each { |binary| node.binary(binary) }
      node.name(name) if name.present?
      node.packager(packager)
      issues.to_a.each do |issue|
        # people tend to enter entire cve strings instead of just the name
        issue[0].gsub!(/^(CVE|cve)-/, '') if issue[1] == 'cve'
        node.issue(issue[3], tracker: issue[1], id: issue[0])
      end
      node.category(category.try(:strip))
      node.rating(rating.try(:strip))
      node.summary(summary.try(:strip))
      node.description(description.gsub("\r\n", "\n"))
      file = package.patchinfo
      file.hashed.elements('package') do |pkg|
        node.package(pkg['_content'])
      end
      file.hashed.elements('releasetarget') do |release_target|
        attributes = { project: release_target['project'] }
        attributes[:repository] = release_target['repository'] if release_target['repository']
        node.releasetarget(attributes)
      end
      node.message message.gsub("\r\n", "\n") if message.present?
      node.reboot_needed if reboot_needed == '1'
      node.relogin_needed if relogin_needed == '1'
      node.retracted if retracted == '1'
      node.zypp_restart_needed if zypp_restart_needed == '1'
      node.stopped block_reason if block == '1'
    end
  end

  private

  def issue_tracker_existence
    return if issuetracker.blank?

    unknown_issue_trackers = issuetracker.uniq - IssueTracker.where(name: issuetracker).pluck(:name)
    return if unknown_issue_trackers.empty?

    errors.add(:base, "Unknown Issue trackers: #{unknown_issue_trackers.to_sentence}")
  end
end