blob: c410442ad75796166271c2dc2f68feddf6ccca4b [file]
#!/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 SHA1 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()