#!/usr/bin/env python3
# Copyright 2015 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import os

import copy
import feature_compiler
import unittest


class FeatureCompilerTest(unittest.TestCase):
  """Test the FeatureCompiler. Note that we test that the expected features are
  generated more thoroughly in features_generation_unittest.cc. And, of course,
  this is most exhaustively tested through Chrome's compilation process (if a
  feature fails to parse, the compile fails).
  These tests primarily focus on catching errors during parsing.
  """

  def _parseFeature(self, value):
    """Parses a feature from the given value and returns the result."""
    f = feature_compiler.Feature('alpha')
    f.Parse(value, {})
    return f

  def _createTestFeatureCompiler(self, feature_class):
    return feature_compiler.FeatureCompiler(
      'chrome_root',
      [],
      feature_class,
      'provider_class',
      'out_root',
      'gen',
      'out_base_filename',
    )

  def _hasError(self, f, error):
    """Asserts that |error| is present somewhere in the given feature's
    errors."""
    errors = f.GetErrors()
    self.assertTrue(errors)
    self.assertNotEqual(-1, str(errors).find(error), str(errors))

  def setUp(self):
    feature_compiler.ENABLE_ASSERTIONS = False

  def testFeature(self):
    # Test some basic feature parsing for a sanity check.
    f = self._parseFeature(
      {
        'blocklist': [
          'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
          '10FEDCBA9876543210FEDCBA9876543210FEDCBA',
        ],
        'channel': 'stable',
        'command_line_switch': 'switch',
        'component_extensions_auto_granted': False,
        'contexts': [
          'privileged_extension',
          'privileged_web_page',
        ],
        'default_parent': True,
        'dependencies': ['dependency1', 'dependency2'],
        'developer_mode_only': True,
        'disallow_for_service_workers': True,
        'extension_types': ['extension'],
        'location': 'component',
        'internal': True,
        'matches': ['*://*/*'],
        'max_manifest_version': 1,
        'requires_delegated_availability_check': True,
        'noparent': True,
        'platforms': ['mac', 'win'],
        'session_types': ['kiosk', 'regular'],
        'allowlist': [
          '0123456789ABCDEF0123456789ABCDEF01234567',
          '76543210FEDCBA9876543210FEDCBA9876543210',
        ],
        'required_buildflags': ['use_cups'],
      }
    )
    self.assertFalse(f.GetErrors())

  def testInvalidAll(self):
    f = self._parseFeature(
      {
        'channel': 'stable',
        'dependencies': 'all',
      }
    )
    self._hasError(f, 'Illegal value: "all"')

  def testInvalidChannel(self):
    f = self._parseFeature(
      {'contexts': ['privileged_extension'], 'channel': 'invalid_channel'}
    )
    self._hasError(f, 'Illegal value: "invalid_channel"')

  def testUnknownKeyError(self):
    f = self._parseFeature(
      {
        'contexts': ['privileged_extension'],
        'channel': 'stable',
        'unknownkey': 'unknownvalue',
      }
    )
    self._hasError(f, 'Unrecognized key')

  def testUnknownEnumValue(self):
    f = self._parseFeature(
      {
        'contexts': ['privileged_extension', 'unknown_context'],
        'channel': 'stable',
      }
    )
    self._hasError(f, 'Illegal value: "unknown_context"')

  def testImproperType(self):
    f = self._parseFeature({'min_manifest_version': '1'})
    self._hasError(f, 'Illegal value: "1"')

  def testImproperSubType(self):
    f = self._parseFeature({'dependencies': [1, 2, 3]})
    self._hasError(f, 'Illegal value: "1"')

  def testImproperValue(self):
    f = self._parseFeature({'noparent': False})
    self._hasError(f, 'Illegal value: "False"')

  def testEmptyList(self):
    f = self._parseFeature({'extension_types': []})
    self._hasError(f, 'List must specify at least one element.')

  def testEmptyListWithAllowEmpty(self):
    # `dependencies` is the only key that allows an empty list.
    f = self._parseFeature({'dependencies': []})
    self.assertFalse(f.GetErrors())

  def testApiFeaturesNeedContexts(self):
    f = self._parseFeature(
      {'extension_types': ['extension'], 'channel': 'trunk'}
    )
    f.Validate('APIFeature', {})
    self._hasError(f, 'APIFeatures must specify the contexts property')

  def testAPIFeaturesCanSpecifyEmptyContexts(self):
    f = self._parseFeature(
      {'extension_types': ['extension'], 'channel': 'trunk', 'contexts': []}
    )
    f.Validate('APIFeature', {})
    self.assertFalse(f.GetErrors())

  def testManifestFeaturesNeedExtensionTypes(self):
    f = self._parseFeature({'dependencies': 'alpha', 'channel': 'beta'})
    f.Validate('ManifestFeature', {})
    self._hasError(
      f, 'ManifestFeatures must specify at least one extension type'
    )

  def testManifestFeaturesCantHaveContexts(self):
    f = self._parseFeature(
      {
        'dependencies': 'alpha',
        'channel': 'beta',
        'extension_types': ['extension'],
        'contexts': ['privileged_extension'],
      }
    )
    f.Validate('ManifestFeature', {})
    self._hasError(f, 'ManifestFeatures do not support contexts')

  def testPermissionFeaturesNeedExtensionTypes(self):
    f = self._parseFeature({'dependencies': 'alpha', 'channel': 'beta'})
    f.Validate('PermissionFeature', {})
    self._hasError(
      f, 'PermissionFeatures must specify at least one extension type'
    )

  def testPermissionFeaturesCantHaveContexts(self):
    f = self._parseFeature(
      {
        'dependencies': 'alpha',
        'channel': 'beta',
        'extension_types': ['extension'],
        'contexts': ['privileged_extension'],
      }
    )
    f.Validate('PermissionFeature', {})
    self._hasError(f, 'PermissionFeatures do not support contexts')

  def testAllPermissionsNeedChannelOrDependencies(self):
    api_feature = self._parseFeature({'contexts': ['privileged_extension']})
    api_feature.Validate('APIFeature', {})
    self._hasError(
      api_feature, 'Features must specify either a channel or dependencies'
    )
    permission_feature = self._parseFeature({'extension_types': ['extension']})
    permission_feature.Validate('PermissionFeature', {})
    self._hasError(
      permission_feature,
      'Features must specify either a channel or dependencies',
    )
    manifest_feature = self._parseFeature({'extension_types': ['extension']})
    manifest_feature.Validate('ManifestFeature', {})
    self._hasError(
      manifest_feature, 'Features must specify either a channel or dependencies'
    )
    channel_feature = self._parseFeature(
      {'contexts': ['privileged_extension'], 'channel': 'trunk'}
    )
    channel_feature.Validate('APIFeature', {})
    self.assertFalse(channel_feature.GetErrors())
    dependency_feature = self._parseFeature(
      {'contexts': ['privileged_extension'], 'dependencies': ['alpha']}
    )
    dependency_feature.Validate('APIFeature', {})
    self.assertFalse(dependency_feature.GetErrors())

  def testBothAliasAndSource(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'alias': 'feature_alpha',
        'source': 'feature_alpha',
      }
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self._hasError(feature, 'Features cannot specify both alias and source.')

  def testAliasOnNonApiFeature(self):
    compiler = self._createTestFeatureCompiler('PermissionFeature')
    compiler._json = {
      'feature_alpha': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'alias': 'feature_beta',
      },
      'feature_beta': [
        {
          'channel': 'beta',
          'contexts': ['privileged_extension'],
          'source': 'feature_alpha',
        },
        {'channel': 'dev', 'context': ['privileged_extension']},
      ],
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self._hasError(feature, 'PermissionFeatures do not support alias.')

    feature = compiler._features.get('feature_beta')
    self.assertTrue(feature)
    self._hasError(feature, 'PermissionFeatures do not support source.')

  def testAliasFeature(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'alias': 'feature_beta',
      },
      'feature_beta': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'source': 'feature_alpha',
      },
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())

    feature = compiler._features.get('feature_beta')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())

  def testMultipleAliasesInComplexFeature(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': [
        {
          'channel': 'beta',
          'contexts': ['privileged_extension'],
          'alias': 'feature_beta',
        },
        {
          'contexts': ['privileged_extension'],
          'channel': 'beta',
          'alias': 'feature_beta',
        },
      ]
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self._hasError(
      feature,
      'Error parsing feature "feature_alpha" at key '
      + '"alias": Key can be set at most once per feature.',
    )

  def testAliasReferenceInComplexFeature(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': [
        {
          'channel': 'beta',
          'contexts': ['privileged_extension'],
          'alias': 'feature_beta',
        },
        {
          'contexts': ['privileged_extension'],
          'channel': 'beta',
        },
      ],
      'feature_beta': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'source': 'feature_alpha',
      },
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())

    feature = compiler._features.get('feature_beta')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())

  def testSourceMissingReference(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'alias': 'feature_beta',
      },
      'feature_beta': {
        'contexts': ['privileged_extension'],
        'channel': 'beta',
        'source': 'does_not_exist',
      },
    }
    compiler.Compile()

    feature = compiler._features.get('feature_beta')
    self.assertTrue(feature)
    self._hasError(
      feature,
      'A feature source property should reference a '
      + 'feature whose alias property references it back.',
    )

  def testAliasMissingReferenceInComplexFeature(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': [
        {
          'channel': 'beta',
          'contexts': ['privileged_extension'],
          'alias': 'feature_beta',
        },
        {'contexts': ['privileged_extension'], 'channel': 'beta'},
      ]
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self._hasError(
      feature,
      'A feature alias property should reference a '
      + 'feature whose source property references it back.',
    )

  def testAliasReferenceMissingSourceInComplexFeature(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': {
        'contexts': ['privileged_extension'],
        'channel': 'beta',
      },
      'feature_beta': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'alias': 'feature_alpha',
      },
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())

    feature = compiler._features.get('feature_beta')
    self.assertTrue(feature)
    self._hasError(
      feature,
      'A feature alias property should reference a '
      + 'feature whose source property references it back.',
    )

  def testComplexParentWithoutDefaultParent(self):
    c = feature_compiler.FeatureCompiler(
      None, None, 'APIFeature', None, None, None, None
    )
    c._CompileFeature(
      'bookmarks',
      [
        {
          'contexts': ['privileged_extension'],
        },
        {
          'channel': 'stable',
          'contexts': ['webui'],
        },
      ],
    )

    with self.assertRaisesRegex(
      AssertionError, 'No default parent found for bookmarks'
    ):
      c._CompileFeature('bookmarks.export', {"allowlist": ["asdf"]})

  def testComplexFeatureWithSinglePropertyBlock(self):
    compiler = self._createTestFeatureCompiler('APIFeature')

    error = (
      'Error parsing feature "feature_alpha": A complex feature '
      'definition is only needed when there are multiple objects '
      'specifying different groups of properties for feature '
      'availability. You can reduce it down to a single object on the '
      'feature key instead of a list.'
    )
    with self.assertRaisesRegex(AssertionError, error):
      compiler._CompileFeature(
        'feature_alpha',
        [
          {
            'contexts': ['privileged_extension'],
            'channel': 'stable',
          }
        ],
      )

  def testRealIdsDisallowedInAllowlist(self):
    fake_id = 'a' * 32
    f = self._parseFeature(
      {
        'allowlist': [fake_id],
        'extension_types': ['extension'],
        'channel': 'beta',
      }
    )
    f.Validate('PermissionFeature', {})
    self._hasError(
      f,
      'list should only have hex-encoded SHA-1 or SHA-256 '
      'hashes of extension ids',
    )

  def testHostedAppsCantUseAllowlistedFeatures_SimpleFeature(self):
    f = self._parseFeature(
      {
        'extension_types': ['extension', 'hosted_app'],
        'allowlist': ['0123456789ABCDEF0123456789ABCDEF01234567'],
        'channel': 'beta',
      }
    )
    f.Validate('PermissionFeature', {})
    self._hasError(f, 'Hosted apps are not allowed to use restricted features')

  def testHostedAppsCantUseAllowlistedFeatures_ComplexFeature(self):
    c = feature_compiler.FeatureCompiler(
      None, None, 'PermissionFeature', None, None, None, None
    )
    c._CompileFeature(
      'invalid_feature',
      [
        {
          'extension_types': ['extension'],
          'channel': 'beta',
        },
        {
          'channel': 'beta',
          'extension_types': ['hosted_app'],
          'allowlist': ['0123456789ABCDEF0123456789ABCDEF01234567'],
        },
      ],
    )
    c._CompileFeature(
      'valid_feature',
      [
        {
          'extension_types': ['extension'],
          'channel': 'beta',
          'allowlist': ['0123456789ABCDEF0123456789ABCDEF01234567'],
        },
        {
          'channel': 'beta',
          'extension_types': ['hosted_app'],
        },
      ],
    )

    valid_feature = c._features.get('valid_feature')
    self.assertTrue(valid_feature)
    self.assertFalse(valid_feature.GetErrors())

    invalid_feature = c._features.get('invalid_feature')
    self.assertTrue(invalid_feature)
    self._hasError(
      invalid_feature, 'Hosted apps are not allowed to use restricted features'
    )

  def testHostedAppsCantUseAllowlistedFeatures_ChildFeature(self):
    c = feature_compiler.FeatureCompiler(
      None, None, 'PermissionFeature', None, None, None, None
    )
    c._CompileFeature(
      'parent',
      {
        'extension_types': ['hosted_app'],
        'channel': 'beta',
      },
    )

    c._CompileFeature(
      'parent.child',
      {'allowlist': ['0123456789ABCDEF0123456789ABCDEF01234567']},
    )
    feature = c._features.get('parent.child')
    self.assertTrue(feature)
    self._hasError(
      feature, 'Hosted apps are not allowed to use restricted features'
    )

  def testEmptyContextsDisallowed(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': {
        'channel': 'beta',
        'contexts': [],
        'extension_types': ['extension'],
      }
    }
    compiler.Compile()

    feature = compiler._features.get('feature_alpha')
    self.assertTrue(feature)
    self._hasError(
      feature, 'An empty contexts list is not allowed for this feature.'
    )

  def testEmptyContextsAllowed(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'empty_contexts': {
        'channel': 'beta',
        'contexts': [],
        'extension_types': ['extension'],
      }
    }
    compiler.Compile()

    feature = compiler._features.get('empty_contexts')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())

  def testFeatureHiddenBehindBuildflag(self):
    compiler = self._createTestFeatureCompiler('APIFeature')

    compiler._json = {
      'feature_cups': {
        'channel': 'beta',
        'command_line_switch': 'enable-cups',
        'contexts': ['privileged_extension'],
        'extension_types': ['extension'],
        'feature_flag': 'ApiCups',
        'matches': ['https://example.com/*'],
        'required_buildflags': ['use_cups'],
      }
    }
    compiler.Compile()
    cc_code = compiler.Render()

    # The code below is formatted correctly!
    self.assertEqual(
      cc_code.Render(),
      '''  {
    #if BUILDFLAG(USE_CUPS)
    SimpleFeature* feature = new SimpleFeature();
    feature->set_name(StaticStringView("feature_cups"));
    feature->set_channel(version_info::Channel::BETA);
    feature->set_command_line_switch(StaticCString("enable-cups"));
    static constexpr auto kContexts =
        std::to_array<mojom::ContextType>(
            {mojom::ContextType::kPrivilegedExtension});
    feature->set_contexts(StaticSpan(kContexts));
    static constexpr auto kExtensionTypes =
        std::to_array<Manifest::Type>(
            {Manifest::Type::kExtension});
    feature->set_extension_types(StaticSpan(kExtensionTypes));
    feature->set_feature_flag(StaticCString("ApiCups"));
    static constexpr auto kMatches =
        std::to_array<std::string_view>(
            {"https://example.com/*"});
    feature->set_matches(StaticSpan(kMatches));
    provider->AddFeature("feature_cups", feature);
    #endif
  }''',
    )

  def testFeatureIdentityStringsUseStaticStorage(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'feature_alpha': {
        'alias': 'feature_beta',
        'contexts': ['privileged_extension'],
      },
      'feature_beta': {
        'contexts': ['privileged_extension'],
        'source': 'feature_alpha',
      },
    }
    compiler.Compile()
    cc_code = compiler.Render().Render()

    # The code below is formatted correctly!
    self.assertEqual(
      cc_code,
      '''  {
    SimpleFeature* feature = new SimpleFeature();
    feature->set_name(StaticStringView("feature_alpha"));
    feature->set_alias(StaticCString("feature_beta"));
    static constexpr auto kContexts =
        std::to_array<mojom::ContextType>(
            {mojom::ContextType::kPrivilegedExtension});
    feature->set_contexts(StaticSpan(kContexts));
    provider->AddFeature("feature_alpha", feature);
  }
  {
    SimpleFeature* feature = new SimpleFeature();
    feature->set_name(StaticStringView("feature_beta"));
    static constexpr auto kContexts =
        std::to_array<mojom::ContextType>(
            {mojom::ContextType::kPrivilegedExtension});
    feature->set_contexts(StaticSpan(kContexts));
    feature->set_source(StaticCString("feature_alpha"));
    provider->AddFeature("feature_beta", feature);
  }''',
    )

  def testFeatureWithEmptyMatches(self):
    compiler = self._createTestFeatureCompiler('APIFeature')
    compiler._json = {
      'empty_matches': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'extension_types': ['extension'],
        'matches': [],
      }
    }
    compiler.Compile()
    cc_code = compiler.Render().Render()

    self.assertNotIn('set_matches', cc_code)
    self.assertNotIn('kMatches', cc_code)

  def testEmptyDependenciesAreSkipped(self):
    compiler = self._createTestFeatureCompiler('APIFeature')

    compiler._json = {
      'empty_dependencies': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'dependencies': [],
      }
    }
    compiler.Compile()
    cc_code = compiler.Render().Render()

    # An empty list needs no setter call; the member already defaults to an
    # empty span, and a zero-length array cannot form a span.
    self.assertNotIn('set_dependencies', cc_code)
    self.assertNotIn('kDependencies', cc_code)

  def testEmptyContextsStillCallsSetter(self):
    compiler = self._createTestFeatureCompiler('APIFeature')

    compiler._json = {'empty_contexts': {'channel': 'beta', 'contexts': []}}
    compiler.Compile()
    cc_code = compiler.Render().Render()

    # Empty contexts means unavailable in every context, not unrestricted.
    self.assertIn(
      'feature->set_contexts(StaticSpan<mojom::ContextType>());', cc_code
    )
    self.assertNotIn('kContexts', cc_code)

  def testEnumListEmitsStaticArray(self):
    compiler = self._createTestFeatureCompiler('APIFeature')

    compiler._json = {
      'enum_lists': {
        'channel': 'beta',
        'contexts': ['privileged_extension'],
        'extension_types': ['extension'],
        'platforms': ['chromeos'],
        'session_types': ['regular'],
      }
    }
    compiler.Compile()

    # The code below is formatted correctly!
    self.assertEqual(
      compiler.Render().Render(),
      '''  {
    SimpleFeature* feature = new SimpleFeature();
    feature->set_name(StaticStringView("enum_lists"));
    feature->set_channel(version_info::Channel::BETA);
    static constexpr auto kContexts =
        std::to_array<mojom::ContextType>(
            {mojom::ContextType::kPrivilegedExtension});
    feature->set_contexts(StaticSpan(kContexts));
    static constexpr auto kExtensionTypes =
        std::to_array<Manifest::Type>(
            {Manifest::Type::kExtension});
    feature->set_extension_types(StaticSpan(kExtensionTypes));
    static constexpr auto kPlatforms =
        std::to_array<Feature::Platform>(
            {Feature::CHROMEOS_PLATFORM});
    feature->set_platforms(StaticSpan(kPlatforms));
    static constexpr auto kSessionTypes =
        std::to_array<mojom::FeatureSessionType>(
            {mojom::FeatureSessionType::kRegular});
    feature->set_session_types(StaticSpan(kSessionTypes));
    provider->AddFeature("enum_lists", feature);
  }''',
    )

  def testEmptyNonContextEnumListsSkipSetters(self):
    # Call the emitter directly because the grammar rejects empty lists for
    # these keys before code generation.
    cc_code = feature_compiler.GetCodeForFeatureValues(
      {'extension_types': '{}', 'platforms': '{}', 'session_types': '{}'}
    ).Render()

    self.assertNotIn('set_extension_types', cc_code)
    self.assertNotIn('set_platforms', cc_code)
    self.assertNotIn('set_session_types', cc_code)

  def testOverrideFeature(self):
    current_directory = os.path.dirname(os.path.abspath(__file__))
    source_files = ['test/_test_api_features.json']
    compiler = feature_compiler.FeatureCompiler(
      current_directory,
      source_files,
      'APIFeature',
      'provider_class',
      'out_root',
      'gen',
      'out_base_filename',
    )

    compiler.Load()
    compiler.Compile()

    # The original _test_api_features.json file defines this feature as
    # available on beta, but it's overridden by the
    # _test_api_features.override.json file to be available only on trunk. The
    # override takes precedence in the compiled feature.
    feature = compiler._features.get('feature')
    self.assertTrue(feature)
    self.assertFalse(feature.GetErrors())
    self.assertEqual(
      'version_info::Channel::UNKNOWN', feature.GetValue('channel')
    )


if __name__ == '__main__':
  unittest.main()
