chromium/mojo/public/tools/mojom/mojom/generate/compatibility_checker.py

# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from mojom.generate import pack
from mojom.generate import module as mojom
from functools import singledispatchmethod


class CompatibilityError(Exception):
  pass


class BackwardCompatibilityChecker:
  """Used for memoization while recursively checking two type definitions for
  backward-compatibility."""

  def __init__(self):
    self._cache = {}

  def IsBackwardCompatible(self, new_kind: mojom.Kind, old_kind: mojom.Kind):
    key = (new_kind, old_kind)
    result = self._cache.get(key)
    if result is None:
      # Ensure that the type of two types are compatible before doing more work.
      if isinstance(new_kind, type(old_kind)):
        # Assume they're compatible at first to effectively ignore recursive
        # checks between these types, e.g. if both kinds are a struct or union
        # that references itself in a field.
        self._cache[key] = True
        result = self._CheckCompat(new_kind, old_kind)
        self._cache[key] = result
      else:
        self._cache[key] = False
    return result

  # Each type should register their own compatibility check under this
  # dispatcher. Assume that both new and old are the same type when the
  # type specific compatibility checker is invoked.
  @singledispatchmethod
  def _CheckCompat(self, new, old):
    raise NotImplementedError("unknown types: (%s, %s)" %
                              (repr(new), repr(old)))

  @_CheckCompat.register(mojom.Kind)
  def _(self, new: mojom.Kind, old: mojom.Kind):
    return new == old

  @_CheckCompat.register(mojom.Field)
  def _(self, new: mojom.Field, old: mojom.Field):
    if (new.min_version or 0) != (old.min_version or 0):
      return False

    return self.IsBackwardCompatible(new.kind, old.kind)

  @_CheckCompat.register(mojom.Struct)
  def _(self, new: mojom.Struct, old: mojom.Struct):
    """This struct is backward-compatible with the older_struct if it has an
    identical field layout or is semantically compatible.
    A struct is semantically compatible if and only if all the following
    conditions hold:
      - Any newly added field is tagged with a [MinVersion] attribute specifying
        a version number greater than all previously used [MinVersion]
        attributes within the struct.
      - All fields present in the old struct remain present in the new struct,
        with the same ordinal position, same optional or non-optional status,
        same (or backward-compatible) type and where applicable, the same
        [MinVersion] attribute value.
      - All [MinVersion] attributes must be non-decreasing in ordinal order.
      - All reference-typed (string, array, map, struct, or union) fields tagged
        with a [MinVersion] greater than zero must be optional.
    """

    # The generator will ensure that ordinal ordering is correct.
    new_fields = [
        p.field for p in pack.PackedStruct(new).packed_fields_in_ordinal_order
    ]
    old_fields = [
        p.field for p in pack.PackedStruct(old).packed_fields_in_ordinal_order
    ]
    # The fields are in ordinal order, so new fields must be at the end.
    added_fields = new_fields[len(old_fields):]

    if len(new_fields) < len(old_fields):
      # At least one field was removed, which is not OK.
      raise CompatibilityError(
          'Removing struct fields from struct %s is not allowed.' %
          (new.mojom_name))

    for pair in zip(new_fields, old_fields):
      (new_field, old_field) = pair
      if not self.IsBackwardCompatible(new_field, old_field):
        # Type or min-version mismatch between old and new versions of the same
        # ordinal field.
        raise CompatibilityError(
            'Struct %s field with ordinal value %d have different type'
            ' or min version, old name %s, new name %s.' %
            (new.mojom_name, new_field.ordinal, old_field.mojom_name,
             new_field.mojom_name))

    old_version = 0
    if len(old_fields):
      old_version = max([f.min_version or 0 for f in old_fields])

    # The new version ratchets up with each field. Because versions can only
    # increase with ordinal order, we should end up with the max [MinVersion]
    # in the end.
    new_version = 0
    for new_field in added_fields:
      field_version = new_field.min_version or 0
      if field_version <= old_version:
        # A new field is being added to an existing version, which is not OK.
        raise CompatibilityError(
            'Adding new fields to an existing MinVersion is not allowed'
            ' for struct %s' % (new.mojom_name))
      if field_version < new_version:
        # The [MinVersion] of a field cannot be lower than the [MinVersion] of
        # a field with lower ordinal value.
        raise CompatibilityError(
            'MinVersion of struct %s field %s cannot be lower than MinVersion'
            ' of preceding fields' % (new.mojom_name, new_field))
      if mojom.IsReferenceKind(
          new_field.kind) and not mojom.IsNullableKind(new_field.kind):
        # New fields whose type can be nullable MUST be nullable.
        raise CompatibilityError('New struct %s field %s must be nullable' %
                                 (new.mojom_name, new_field))
      new_version = field_version

    return True

  @_CheckCompat.register(mojom.Union)
  def _(self, new: mojom.Union, old: mojom.Union):
    """This union is backward-compatible with old union if and only if
    all of the following conditions hold:
      - Any newly added field is tagged with a [MinVersion] attribute specifying
        a version number greater than all previously used [MinVersion]
        attributes within the union.
      - All fields present in old union remain present in the new union,
        with the same ordinal value, same optional or non-optional status,
        same (or backward-compatible) type, and where applicable, the same
        [MinVersion] attribute value.
    """

    def buildOrdinalFieldMap(union):
      fields_by_ordinal = {}
      for field in union.fields:
        if field.ordinal in fields_by_ordinal:
          raise CompatibilityError(
              'Multiple fields with ordinal %s in union %s.' %
              (field.ordinal, union.mojom_name))
        fields_by_ordinal[field.ordinal] = field
      return fields_by_ordinal

    new_fields = buildOrdinalFieldMap(new)
    old_fields = buildOrdinalFieldMap(old)
    if len(new_fields) < len(old_fields):
      # At least one field was removed, which is not OK.
      return False

    max_old_min_version = 0
    for ordinal, old_field in old_fields.items():
      new_field = new_fields.get(ordinal)
      if not new_field:
        # A field was removed, which is not OK.
        return False
      if not self.IsBackwardCompatible(new_field, old_field):
        # An field changed its type or MinVersion, which is not OK.
        return False
      old_min_version = old_field.min_version or 0
      if old_min_version > max_old_min_version:
        max_old_min_version = old_min_version

    new_ordinals = set(new_fields.keys()) - set(old_fields.keys())
    for ordinal in new_ordinals:
      if (new_fields[ordinal].min_version or 0) <= max_old_min_version:
        # New fields must use a MinVersion greater than any old fields.
        return False

    return True

  @_CheckCompat.register(mojom.Array)
  def _(self, new: mojom.Array, old: mojom.Array):
    return new.length == old.length and self.IsBackwardCompatible(
        new.kind, old.kind)

  @_CheckCompat.register(mojom.Map)
  def _(self, new: mojom.Map, old: mojom.Map):
    return self.IsBackwardCompatible(
        new.key_kind, old.key_kind) and self.IsBackwardCompatible(
            new.value_kind, old.value_kind)

  @_CheckCompat.register(mojom.PendingRemote)
  def _(self, new: mojom.PendingRemote, old: mojom.PendingRemote):
    return self.IsBackwardCompatible(new.kind, old.kind)

  @_CheckCompat.register(mojom.PendingReceiver)
  def _(self, new: mojom.PendingReceiver, old: mojom.PendingReceiver):
    return self.IsBackwardCompatible(new.kind, old.kind)

  @_CheckCompat.register(mojom.PendingAssociatedRemote)
  def _(self, new: mojom.PendingAssociatedRemote,
        old: mojom.PendingAssociatedRemote):
    return self.IsBackwardCompatible(new.kind, old.kind)

  @_CheckCompat.register(mojom.PendingAssociatedReceiver)
  def _(self, new: mojom.PendingAssociatedReceiver,
        old: mojom.PendingAssociatedReceiver):
    return self.IsBackwardCompatible(new.kind, old.kind)

  @_CheckCompat.register(mojom.Interface)
  def _(self, new: mojom.Interface, old: mojom.Interface):
    """This interface is backward-compatible with old interface if and
    only if all of the following conditions hold:
      - All defined methods in the old interface (when identified by ordinal)
        have backward-compatible definitions in this interface. For each method
        this means:
          - The parameter list is backward-compatible, according to backward-
            compatibility rules for structs, where each parameter is essentially
            a struct field.
          - If the old method definition does not specify a reply message, the
            new method definition must not specify a reply message.
          - If the old method definition specifies a reply message, the new
            method definition must also specify a reply message with a parameter
            list that is backward-compatible according to backward-compatibility
            rules for structs.
      - All newly introduced methods in this interface have a [MinVersion]
        attribute specifying a version greater than any method in
        the old interface.
    """

    def buildOrdinalMethodMap(interface):
      methods_by_ordinal = {}
      for method in interface.methods:
        if method.ordinal in methods_by_ordinal:
          raise CompatibilityError(
              'Multiple methods with ordinal %s in interface %s.' %
              (method.ordinal, interface.mojom_name))
        methods_by_ordinal[method.ordinal] = method
      return methods_by_ordinal

    new_methods = buildOrdinalMethodMap(new)
    old_methods = buildOrdinalMethodMap(old)
    max_old_min_version = 0
    for ordinal, old_method in old_methods.items():
      new_method = new_methods.get(ordinal)
      if not new_method:
        # A method was removed, which is not OK.
        return False

      if not self.IsBackwardCompatible(new_method.param_struct,
                                       old_method.param_struct):
        # The parameter list is not backward-compatible, which is not OK.
        return False

      if old_method.response_param_struct is None:
        if new_method.response_param_struct is not None:
          # A reply was added to a message which didn't have one before, and
          # this is not OK.
          return False
      else:
        if new_method.response_param_struct is None:
          # A reply was removed from a message, which is not OK.
          return False
        if not self.IsBackwardCompatible(new_method.response_param_struct,
                                         old_method.response_param_struct):
          # The new message's reply is not backward-compatible with the old
          # message's reply, which is not OK.
          return False

      if (old_method.min_version or 0) > max_old_min_version:
        max_old_min_version = old_method.min_version

    # All the old methods are compatible with their new counterparts. Now verify
    # that newly added methods are properly versioned.
    new_ordinals = set(new_methods.keys()) - set(old_methods.keys())
    for ordinal in new_ordinals:
      new_method = new_methods[ordinal]
      if (new_method.min_version or 0) <= max_old_min_version:
        # A method was added to an existing version, which is not OK.
        return False

    return True

  @_CheckCompat.register(mojom.Enum)
  def _(self, new: mojom.Enum, old: mojom.Enum):
    """This enum is backward-compatible with old enum if and only if one
    of the following conditions holds:
        - Neither enum is [Extensible] and both have the exact same set of valid
          numeric values. Field names and aliases for the same numeric value do
          not affect compatibility.
        - old is [Extensible], and for every version defined by
          the old enum, this enum has the exact same set of valid numeric
          values.
    """

    def buildVersionFieldMap(enum):
      fields_by_min_version = {}
      for field in enum.fields:
        if field.min_version not in fields_by_min_version:
          fields_by_min_version[field.min_version] = set()
        fields_by_min_version[field.min_version].add(field.numeric_value)
      return fields_by_min_version

    old_fields = buildVersionFieldMap(old)
    new_fields = buildVersionFieldMap(new)

    if new_fields.keys() != old_fields.keys() and not old.extensible:
      raise CompatibilityError("Non-extensible enum cannot be modified")

    for min_version, valid_values in old_fields.items():
      if min_version not in new_fields:
        raise CompatibilityError('New values added to an extensible enum '
                                 'did not specify MinVersion: %s' % new_fields)

      if (new_fields[min_version] != valid_values):
        if (len(new_fields[min_version]) < len(valid_values)):
          raise CompatibilityError(
              'Removing values for an existing MinVersion %s '
              'is not allowed' % min_version)

        raise CompatibilityError(
            'New values don\'t match old values '
            'for an existing MinVersion %s, '
            'please specify MinVersion equal to "Next version" '
            'in the enum description '
            'for the following values:\n%s' %
            (min_version, new_fields[min_version].difference(valid_values)))
    return True