src/olympia/devhub/utils.py (250 lines of code) (raw):

import uuid from django.conf import settings from django.db import transaction from django.forms import ValidationError from django.urls import reverse from django.utils.translation import gettext import waffle from celery import chain, chord from django_statsd.clients import statsd import olympia.core.logger from olympia import amo, core from olympia.amo.templatetags.jinja_helpers import absolutify from olympia.amo.urlresolvers import linkify_and_clean from olympia.files.models import File, FileUpload from olympia.files.tasks import repack_fileupload from olympia.files.utils import parse_addon, parse_xpi from olympia.scanners.tasks import call_mad_api, run_customs, run_yara from olympia.versions.models import Version from olympia.versions.utils import process_color_value from . import tasks log = olympia.core.logger.getLogger('z.devhub') def process_validation(validation, file_hash=None, channel=amo.CHANNEL_LISTED): """Process validation results into the format expected by the web frontend, including transforming certain fields into HTML, mangling compatibility messages, and limiting the number of messages displayed.""" validation = fix_addons_linter_output(validation, channel=channel) # Set an ending tier if we don't have one (which probably means # we're dealing with mock validation results or the addons-linter). validation.setdefault('ending_tier', 0) if not validation['ending_tier'] and validation['messages']: validation['ending_tier'] = max( msg.get('tier', -1) for msg in validation['messages'] ) limit_validation_results(validation) htmlify_validation(validation) return validation def limit_validation_results(validation): """Limit the number of messages displayed in a set of validation results, and if truncation has occurred, add a new message explaining so.""" messages = validation['messages'] lim = settings.VALIDATOR_MESSAGE_LIMIT if lim and len(messages) > lim: # Sort messages by severity first so that the most important messages # are the one we keep. TYPES = {'error': 0, 'warning': 2, 'notice': 3} def message_key(message): return TYPES.get(message.get('type')) messages.sort(key=message_key) leftover_count = len(messages) - lim del messages[lim:] # The type of the truncation message should be the type of the most # severe message in the results. if validation['errors']: msg_type = 'error' elif validation['warnings']: msg_type = 'warning' else: msg_type = 'notice' compat_type = ( msg_type if any(msg.get('compatibility_type') for msg in messages) else None ) message = ( gettext( 'Validation generated too many errors/warnings so %s ' 'messages were truncated. After addressing the visible ' "messages, you'll be able to see the others." ) % leftover_count ) messages.insert( 0, { 'tier': 1, 'type': msg_type, # To respect the message structure, see bug 1139674. 'id': ['validation', 'messages', 'truncated'], 'message': message, 'description': [], 'compatibility_type': compat_type, }, ) def htmlify_validation(validation): """Process the `message` and `description` fields into safe HTML, with URLs turned into links.""" for msg in validation['messages']: msg['message'] = linkify_and_clean(msg['message']) if 'description' in msg: # Description may be returned as a single string, or list of # strings. Turn it into lists for simplicity on the client side. if not isinstance(msg['description'], (list, tuple)): msg['description'] = [msg['description']] msg['description'] = [ linkify_and_clean(text) for text in msg['description'] ] def fix_addons_linter_output(validation, channel): """Make sure the output from the addons-linter is the same as amo-validator for backwards compatibility reasons.""" if 'messages' in validation: # addons-linter doesn't contain this, return the original validation # untouched return validation def _merged_messages(): for type_ in ('errors', 'notices', 'warnings'): for msg in validation[type_]: # FIXME: Remove `uid` once addons-linter generates it msg['uid'] = uuid.uuid4().hex msg['type'] = msg.pop('_type') msg['id'] = [msg.pop('code')] # We don't have the concept of tiers for the addons-linter # currently msg['tier'] = 1 yield msg identified_files = { name: {'path': path} for name, path in validation['metadata'].get('jsLibs', {}).items() } # Essential metadata. metadata = { 'listed': channel == amo.CHANNEL_LISTED, 'identified_files': identified_files, } # Add metadata already set by the linter. metadata.update(validation.get('metadata', {})) return { 'success': not validation['errors'], 'compatibility_summary': { 'warnings': 0, 'errors': 0, 'notices': 0, }, 'notices': validation['summary']['notices'], 'warnings': validation['summary']['warnings'], 'errors': validation['summary']['errors'], 'messages': list(_merged_messages()), 'metadata': metadata, 'ending_tier': 5, } class InvalidAddonType(ValidationError): pass class Validator: """ Class which handles creating and running validation tasks for File and FileUpload instances. """ def __init__(self, file_, *, addon=None, theme_specific=False, final_task=None): self.addon = addon self.file = None self.prev_file = None if isinstance(file_, FileUpload): channel = file_.channel is_mozilla_signed = False # We're dealing with a bare file upload. Try to extract the # metadata that we need to match it against a previous upload # from the file itself. try: addon_data = parse_addon(file_, minimal=True) is_mozilla_signed = addon_data.get('is_mozilla_signed_extension', False) # If trying to upload a non-theme in the theme specific flow, # raise an error immediately and don't validate. We don't care # about the opposite: if a developer tries to upload a theme # using the "non-theme" flow, that works. if theme_specific and addon_data['type'] != amo.ADDON_STATICTHEME: channel_text = amo.CHANNEL_CHOICES_API[channel] raise InvalidAddonType( gettext( 'This add-on is not a theme. ' 'Use the <a href="{link}">Submit a New Add-on</a> ' 'page to submit extensions.' ).format( link=absolutify( reverse('devhub.submit.upload', args=[channel_text]) ) ), ) except InvalidAddonType: log.error( 'Tried to validate non-theme upload %s using theme specific flow', file_.uuid, ) raise except ValidationError as form_error: log.info( 'could not parse addon for upload {}: {}'.format( file_.pk, form_error ) ) addon_data = None else: file_.update(version=addon_data.get('version')) assert not file_.validation validation_tasks = self.create_file_upload_tasks( upload_pk=file_.pk, is_mozilla_signed=is_mozilla_signed ) elif isinstance(file_, File): channel = file_.version.channel is_mozilla_signed = file_.is_mozilla_signed_extension self.file = file_ self.addon = self.file.version.addon addon_data = {'guid': self.addon.guid, 'version': self.file.version.version} validation_tasks = [ tasks.create_initial_validation_results.si(), tasks.validate_file.s(file_.pk), tasks.handle_file_validation_result.s(file_.pk), ] else: raise ValueError if final_task: validation_tasks.append(final_task) self.task = chain(*validation_tasks) def get_task(self): """Return task chain to execute to trigger validation.""" return self.task def create_file_upload_tasks(self, upload_pk, is_mozilla_signed): """ This method creates the validation chain used during the submission process, combining tasks in parallel (chord) with tasks chained together (where the output is used as input of the next task). """ tasks_in_parallel = [tasks.forward_linter_results.s(upload_pk)] if waffle.switch_is_active('enable-yara'): tasks_in_parallel.append(run_yara.s(upload_pk)) if waffle.switch_is_active('enable-customs'): tasks_in_parallel.append(run_customs.s(upload_pk)) return [ tasks.create_initial_validation_results.si(), repack_fileupload.s(upload_pk), tasks.validate_upload.s(upload_pk), tasks.check_for_api_keys_in_file.s(upload_pk), chord(tasks_in_parallel, call_mad_api.s(upload_pk)), tasks.handle_upload_validation_result.s(upload_pk, is_mozilla_signed), ] def extract_theme_properties(addon, channel): version = addon.find_latest_version(channel) if not version: return {} try: parsed_data = parse_xpi( version.file.file.path, addon=addon, user=core.get_user() ) except (ValidationError, ValueError) as exc: log.debug('Error parsing xpi', exc_info=exc) # If we can't parse the existing manifest safely return. return {} theme_props = parsed_data.get('theme', {}) # pre-process colors to deprecated colors; strip spaces. theme_props['colors'] = dict( process_color_value(prop, color) for prop, color in theme_props.get('colors', {}).items() ) # upgrade manifest from deprecated headerURL to theme_frame if 'headerURL' in theme_props.get('images', {}): url = theme_props['images'].pop('headerURL') theme_props['images']['theme_frame'] = url return theme_props def wizard_unsupported_properties(data, wizard_fields): # collect any 'theme' level unsupported properties unsupported = [key for key in data.keys() if key not in ['colors', 'images']] # and any unsupported 'colors' properties unsupported += [key for key in data.get('colors', {}) if key not in wizard_fields] # and finally any 'images' properties (wizard only supports the background) unsupported += [key for key in data.get('images', {}) if key != 'theme_frame'] return unsupported @transaction.atomic def create_version_for_upload(*, addon, upload, channel, client_info=None): fileupload_exists = addon.fileupload_set.filter( created__gt=upload.created, version=upload.version ).exists() version_exists = Version.unfiltered.filter( addon=addon, version=upload.version ).exists() if fileupload_exists or version_exists: log.info( 'Skipping Version creation for {upload_uuid} that would ' ' cause duplicate version'.format(upload_uuid=upload.uuid) ) return None else: log.info( 'Creating version for {upload_uuid} that passed validation'.format( upload_uuid=upload.uuid ) ) # Note: if we somehow managed to get here with an invalid add-on, # parse_addon() will raise ValidationError and the task will fail # loudly in sentry. parsed_data = parse_addon(upload, addon=addon, user=upload.user) new_addon = not Version.unfiltered.filter(addon=addon).exists() version = Version.from_upload( upload, addon, channel, selected_apps=[amo.FIREFOX.id], parsed_data=parsed_data, client_info=client_info, ) channel_name = amo.CHANNEL_CHOICES_API[channel] # This function is only called via the signing api flow statsd.incr( f'signing.submission.{"addon" if new_addon else "version"}.{channel_name}' ) # The add-on's status will be STATUS_NULL when its first version is created # because the version has no files when it gets added and it gets flagged as # invalid. Addon.update_status will set the status to NOMINATATED. addon.update_status() return version