#!/usr/bin/env python3
from __future__ import print_function
import argparse
import collections
import copy
import json
import os
import sys
import spec_validator
import util
def expand_pattern(expansion_pattern, test_expansion_schema):
expansion = {}
for artifact_key in expansion_pattern:
artifact_value = expansion_pattern[artifact_key]
if artifact_value == '*':
expansion[artifact_key] = test_expansion_schema[artifact_key]
elif isinstance(artifact_value, list):
expansion[artifact_key] = artifact_value
elif isinstance(artifact_value, dict):
# Flattened expansion.
expansion[artifact_key] = []
values_dict = expand_pattern(artifact_value,
test_expansion_schema[artifact_key])
for sub_key in values_dict.keys():
expansion[artifact_key] += values_dict[sub_key]
else:
expansion[artifact_key] = [artifact_value]
return expansion
def permute_expansion(expansion,
artifact_order,
selection={},
artifact_index=0):
assert isinstance(artifact_order, list), "artifact_order should be a list"
if artifact_index >= len(artifact_order):
yield selection
return
artifact_key = artifact_order[artifact_index]
for artifact_value in expansion[artifact_key]:
selection[artifact_key] = artifact_value
for next_selection in permute_expansion(expansion, artifact_order,
selection, artifact_index + 1):
yield next_selection
# Dumps the test config `selection` into a serialized JSON string.
def dump_test_parameters(selection):
return json.dumps(
selection,
indent=2,
separators=(',', ': '),
sort_keys=True,
cls=util.CustomEncoder)
def get_test_filename(spec_directory, spec_json, selection):
'''Returns the filname for the main test HTML file'''
selection_for_filename = copy.deepcopy(selection)
# Use 'unset' rather than 'None' in test filenames.
if selection_for_filename['delivery_value'] is None:
selection_for_filename['delivery_value'] = 'unset'
return os.path.join(
spec_directory,
spec_json['test_file_path_pattern'] % selection_for_filename)
def get_csp_value(value):
'''
Returns actual CSP header values (e.g. "worker-src 'self'") for the
given string used in PolicyDelivery's value (e.g. "worker-src-self").
'''
# script-src
# Test-related scripts like testharness.js and inline scripts containing
# test bodies.
# 'unsafe-inline' is added as a workaround here. This is probably not so
# bad, as it shouldn't intefere non-inline-script requests that we want to
# test.
if value == 'script-src-wildcard':
return "script-src * 'unsafe-inline'"
if value == 'script-src-self':
return "script-src 'self' 'unsafe-inline'"
# Workaround for "script-src 'none'" would be more complicated, because
# - "script-src 'none' 'unsafe-inline'" is handled somehow differently from
# "script-src 'none'", i.e.
# https://w3c.github.io/webappsec-csp/#match-url-to-source-list Step 3
# handles the latter but not the former.
# - We need nonce- or path-based additional values to allow same-origin
# test scripts like testharness.js.
# Therefore, we disable 'script-src-none' tests for now in
# `/content-security-policy/spec.src.json`.
if value == 'script-src-none':
return "script-src 'none'"
# worker-src
if value == 'worker-src-wildcard':
return 'worker-src *'
if value == 'worker-src-self':
return "worker-src 'self'"
if value == 'worker-src-none':
return "worker-src 'none'"
raise Exception('Invalid delivery_value: %s' % value)
def handle_deliveries(policy_deliveries):
'''
Generate elements and HTTP headers for the given list of
PolicyDelivery.
TODO(hiroshige): Merge duplicated code here, scope/document.py, etc.
'''
meta = ''
headers = {}
for delivery in policy_deliveries:
if delivery.value is None:
continue
if delivery.key == 'referrerPolicy':
if delivery.delivery_type == 'meta':
meta += \
'' % delivery.value
elif delivery.delivery_type == 'http-rp':
headers['Referrer-Policy'] = delivery.value
# TODO(kristijanburnik): Limit to WPT origins.
headers['Access-Control-Allow-Origin'] = '*'
else:
raise Exception(
'Invalid delivery_type: %s' % delivery.delivery_type)
elif delivery.key == 'mixedContent':
assert (delivery.value == 'opt-in')
if delivery.delivery_type == 'meta':
meta += ''
elif delivery.delivery_type == 'http-rp':
headers['Content-Security-Policy'] = 'block-all-mixed-content'
else:
raise Exception(
'Invalid delivery_type: %s' % delivery.delivery_type)
elif delivery.key == 'contentSecurityPolicy':
csp_value = get_csp_value(delivery.value)
if delivery.delivery_type == 'meta':
meta += ''
elif delivery.delivery_type == 'http-rp':
headers['Content-Security-Policy'] = csp_value
else:
raise Exception(
'Invalid delivery_type: %s' % delivery.delivery_type)
elif delivery.key == 'upgradeInsecureRequests':
# https://w3c.github.io/webappsec-upgrade-insecure-requests/#delivery
assert (delivery.value == 'upgrade')
if delivery.delivery_type == 'meta':
meta += ''
elif delivery.delivery_type == 'http-rp':
headers[
'Content-Security-Policy'] = 'upgrade-insecure-requests'
else:
raise Exception(
'Invalid delivery_type: %s' % delivery.delivery_type)
else:
raise Exception('Invalid delivery_key: %s' % delivery.key)
return {"meta": meta, "headers": headers}
def generate_selection(spec_json, selection):
'''
Returns a scenario object (with a top-level source_context_list entry,
which will be removed in generate_test_file() later).
'''
target_policy_delivery = util.PolicyDelivery(selection['delivery_type'],
selection['delivery_key'],
selection['delivery_value'])
del selection['delivery_type']
del selection['delivery_key']
del selection['delivery_value']
# Parse source context list and policy deliveries of source contexts.
# `util.ShouldSkip()` exceptions are raised if e.g. unsuppported
# combinations of source contexts and policy deliveries are used.
source_context_list_scheme = spec_json['source_context_list_schema'][
selection['source_context_list']]
selection['source_context_list'] = [
util.SourceContext.from_json(source_context, target_policy_delivery,
spec_json['source_context_schema'])
for source_context in source_context_list_scheme['sourceContextList']
]
# Check if the subresource is supported by the innermost source context.
innermost_source_context = selection['source_context_list'][-1]
supported_subresource = spec_json['source_context_schema'][
'supported_subresource'][innermost_source_context.source_context_type]
if supported_subresource != '*':
if selection['subresource'] not in supported_subresource:
raise util.ShouldSkip()
# Parse subresource policy deliveries.
selection[
'subresource_policy_deliveries'] = util.PolicyDelivery.list_from_json(
source_context_list_scheme['subresourcePolicyDeliveries'],
target_policy_delivery, spec_json['subresource_schema']
['supported_delivery_type'][selection['subresource']])
# Generate per-scenario test description.
selection['test_description'] = spec_json[
'test_description_template'] % selection
return selection
def generate_test_file(spec_directory, test_helper_filenames,
test_html_template_basename, test_filename, scenarios):
'''
Generates a test HTML file (and possibly its associated .headers file)
from `scenarios`.
'''
# Scenarios for the same file should have the same `source_context_list`,
# including the top-level one.
# Note: currently, non-top-level source contexts aren't necessarily required
# to be the same, but we set this requirement as it will be useful e.g. when
# we e.g. reuse a worker among multiple scenarios.
for scenario in scenarios:
assert (scenario['source_context_list'] == scenarios[0]
['source_context_list'])
# We process the top source context below, and do not include it in
# the JSON objects (i.e. `scenarios`) in generated HTML files.
top_source_context = scenarios[0]['source_context_list'].pop(0)
assert (top_source_context.source_context_type == 'top')
for scenario in scenarios[1:]:
assert (scenario['source_context_list'].pop(0) == top_source_context)
parameters = {}
# Sort scenarios, to avoid unnecessary diffs due to different orders in
# `scenarios`.
serialized_scenarios = sorted(
[dump_test_parameters(scenario) for scenario in scenarios])
parameters['scenarios'] = ",\n".join(serialized_scenarios).replace(
"\n", "\n" + " " * 10)
test_directory = os.path.dirname(test_filename)
parameters['helper_js'] = ""
for test_helper_filename in test_helper_filenames:
parameters['helper_js'] += ' \n' % (
os.path.relpath(test_helper_filename, test_directory))
parameters['sanity_checker_js'] = os.path.relpath(
os.path.join(spec_directory, 'generic', 'sanity-checker.js'),
test_directory)
parameters['spec_json_js'] = os.path.relpath(
os.path.join(spec_directory, 'generic', 'spec_json.js'),
test_directory)
test_headers_filename = test_filename + ".headers"
test_html_template = util.get_template(test_html_template_basename)
disclaimer_template = util.get_template('disclaimer.template')
html_template_filename = os.path.join(util.template_directory,
test_html_template_basename)
generated_disclaimer = disclaimer_template \
% {'generating_script_filename': os.path.relpath(sys.argv[0],
util.test_root_directory),
'spec_directory': os.path.relpath(spec_directory,
util.test_root_directory)}
# Adjust the template for the test invoking JS. Indent it to look nice.
parameters['generated_disclaimer'] = generated_disclaimer.rstrip()
# Directory for the test files.
try:
os.makedirs(test_directory)
except:
pass
delivery = handle_deliveries(top_source_context.policy_deliveries)
if len(delivery['headers']) > 0:
with open(test_headers_filename, "w") as f:
for header in delivery['headers']:
f.write('%s: %s\n' % (header, delivery['headers'][header]))
parameters['meta_delivery_method'] = delivery['meta']
# Obey the lint and pretty format.
if len(parameters['meta_delivery_method']) > 0:
parameters['meta_delivery_method'] = "\n " + \
parameters['meta_delivery_method']
# Write out the generated HTML file.
util.write_file(test_filename, test_html_template % parameters)
def generate_test_source_files(spec_directory, test_helper_filenames,
spec_json, target):
test_expansion_schema = spec_json['test_expansion_schema']
specification = spec_json['specification']
if target == "debug":
spec_json_js_template = util.get_template('spec_json.js.template')
util.write_file(
os.path.join(spec_directory, "generic", "spec_json.js"),
spec_json_js_template % {'spec_json': json.dumps(spec_json)})
util.write_file(
os.path.join(spec_directory, "generic",
"debug-output.spec.src.json"),
json.dumps(spec_json, indent=2, separators=(',', ': ')))
# Choose a debug/release template depending on the target.
html_template = "test.%s.html.template" % target
artifact_order = test_expansion_schema.keys()
artifact_order.remove('expansion')
excluded_selection_pattern = ''
for key in artifact_order:
excluded_selection_pattern += '%(' + key + ')s/'
# Create list of excluded tests.
exclusion_dict = set()
for excluded_pattern in spec_json['excluded_tests']:
excluded_expansion = \
expand_pattern(excluded_pattern, test_expansion_schema)
for excluded_selection in permute_expansion(excluded_expansion,
artifact_order):
excluded_selection['delivery_key'] = spec_json['delivery_key']
exclusion_dict.add(excluded_selection_pattern % excluded_selection)
# `scenarios[filename]` represents the list of scenario objects to be
# generated into `filename`.
scenarios = {}
for spec in specification:
# Used to make entries with expansion="override" override preceding
# entries with the same |selection_path|.
output_dict = {}
for expansion_pattern in spec['test_expansion']:
expansion = expand_pattern(expansion_pattern,
test_expansion_schema)
for selection in permute_expansion(expansion, artifact_order):
selection['delivery_key'] = spec_json['delivery_key']
selection_path = spec_json['selection_pattern'] % selection
if selection_path in output_dict:
if expansion_pattern['expansion'] != 'override':
print("Error: expansion is default in:")
print(dump_test_parameters(selection))
print("but overrides:")
print(dump_test_parameters(
output_dict[selection_path]))
sys.exit(1)
output_dict[selection_path] = copy.deepcopy(selection)
for selection_path in output_dict:
selection = output_dict[selection_path]
if (excluded_selection_pattern % selection) in exclusion_dict:
print('Excluding selection:', selection_path)
continue
try:
test_filename = get_test_filename(spec_directory, spec_json,
selection)
scenario = generate_selection(spec_json, selection)
scenarios[test_filename] = scenarios.get(test_filename,
[]) + [scenario]
except util.ShouldSkip:
continue
for filename in scenarios:
generate_test_file(spec_directory, test_helper_filenames,
html_template, filename, scenarios[filename])
def merge_json(base, child):
for key in child:
if key not in base:
base[key] = child[key]
continue
# `base[key]` and `child[key]` both exists.
if isinstance(base[key], list) and isinstance(child[key], list):
base[key].extend(child[key])
elif isinstance(base[key], dict) and isinstance(child[key], dict):
merge_json(base[key], child[key])
else:
base[key] = child[key]
def main():
parser = argparse.ArgumentParser(
description='Test suite generator utility')
parser.add_argument(
'-t',
'--target',
type=str,
choices=("release", "debug"),
default="release",
help='Sets the appropriate template for generating tests')
parser.add_argument(
'-s',
'--spec',
type=str,
default=os.getcwd(),
help='Specify a file used for describing and generating the tests')
# TODO(kristijanburnik): Add option for the spec_json file.
args = parser.parse_args()
spec_directory = os.path.abspath(args.spec)
# Read `spec.src.json` files, starting from `spec_directory`, and
# continuing to parent directories as long as `spec.src.json` exists.
spec_filenames = []
test_helper_filenames = []
spec_src_directory = spec_directory
while len(spec_src_directory) >= len(util.test_root_directory):
spec_filename = os.path.join(spec_src_directory, "spec.src.json")
if not os.path.exists(spec_filename):
break
spec_filenames.append(spec_filename)
test_filename = os.path.join(spec_src_directory, 'generic',
'test-case.sub.js')
assert (os.path.exists(test_filename))
test_helper_filenames.append(test_filename)
spec_src_directory = os.path.abspath(
os.path.join(spec_src_directory, ".."))
spec_filenames = list(reversed(spec_filenames))
test_helper_filenames = list(reversed(test_helper_filenames))
if len(spec_filenames) == 0:
print('Error: No spec.src.json is found at %s.' % spec_directory)
return
# Load the default spec JSON file, ...
default_spec_filename = os.path.join(util.script_directory,
'spec.src.json')
spec_json = collections.OrderedDict()
if os.path.exists(default_spec_filename):
spec_json = util.load_spec_json(default_spec_filename)
# ... and then make spec JSON files in subdirectories override the default.
for spec_filename in spec_filenames:
child_spec_json = util.load_spec_json(spec_filename)
merge_json(spec_json, child_spec_json)
spec_validator.assert_valid_spec_json(spec_json)
generate_test_source_files(spec_directory, test_helper_filenames,
spec_json, args.target)
if __name__ == '__main__':
main()