blob: 4074e5adfa1344b4b4415ecb9e711ef713a0917f [file] [log] [blame] [edit]
#! /usr/bin/env python3
# Copyright 2022 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import functools
import os
import re
import subprocess
import sys
from collections import deque
from string import Template
def main():
"""Finds and replaces v1 GN fuzzing templates with corresponding v2 templates.
Given a list of directories, this script will make two passes:
1). On the first pass, it will identify the build files in those directories that have fuzzing
related targets and imports, and collect any details needed to generate fuzzer components.
2). On the second pass, it will generate build files with v1 fuzzing imports and targets
replaced by v2 ones. If the "dry run" flag was not specified, it will write these build
files back and also write the corresponding fuzzer component manifest source files.
"""
parser = argparse.ArgumentParser(
description='Convert v1 GN fuzzing targets to v2.')
parser.add_argument(
'-d', '--dry-run', action='store_true', help='skip writing changes')
parser.add_argument(
'roots',
nargs='+',
metavar='dirs',
help='directories to search for targets')
args = parser.parse_args()
for root in args.roots:
if not os.path.isdir(root):
print('error: invalid argument: ' + root)
parser.print_help()
return -1
# Scan for BUILD.gn files with v1 fuzzing imports and targets.
converter = FuzzingTemplateConverter()
buildfiles = []
imports = {}
n = 0
for root in args.roots:
for dirpath, dirs, files in os.walk(root):
if 'out' in dirs:
dirs.remove('out')
dirs = [d for d in dirs if not d.startswith('.')]
for filename in files:
if filename != 'BUILD.gn':
continue
buildfile = os.path.join(dirpath, filename)
if converter.on_buildfile(buildfile):
buildfiles.append(buildfile)
imports[buildfile] = converter.imports
n += 1
print('')
print(f'[+] Found {n} fuzzing-related BUILD.gn files.')
# Replace v1 fuzzing imports and targets in affected BUILD.gn files.
print('')
converter.mode = 'dry-run' if args.dry_run else 'modify'
i = 1
for buildfile in buildfiles:
print(f'[{i}/{n}] {buildfile}')
converter.imports = imports[buildfile]
converter.on_buildfile(buildfile)
if not args.dry_run:
subprocess.check_output(
['fx', 'gn', 'format', buildfile],
stderr=subprocess.STDOUT,
encoding="utf8")
i += 1
print('')
print('[+] Done!')
return 0
class FuzzingTemplateConverter(object):
"""Processes BUILD.gn files to find and replace v1 fuzzing imports and targets with v2 ones.
The converter is given a sequence of BUILD.gn files to process, depending on its currently
configured `mode`. When mode is 'scan', it collects fuzzer component details. When it is 'dry-run'
or 'modify', it generates GN output for fuzzer executables, components, and packages. Furthermore,
when it is 'modify', it writes this GN output back to the build files and generates CML files.
Attributes:
mode: String indicating one of 'scan', 'dry-run' or 'modify', as set by the caller.
imports: Set of strings indicating any gni files to be imported by the current build file.
components: Map of fuzzer labels to scopes defining component "deps" and manifest "args".
input_lines: List of strings representing the original lines of the current build file.
output_lines: List of strings representing the replacement lines for the current build file.
dirpath: String representing the directory of the current build file.
"""
def __init__(self):
self.mode = 'scan'
self.imports = set()
self.components = {}
self.input_lines = None
self.output_lines = []
self.dirpath = None
def on_buildfile(self, pathname):
"""Take mode-specific actions on the BUILD.gn file given by `pathname`.
Reads the file line by line and identifies fuzzing-related imports and targets. Since the
correct v2 replacements for v1 imports and targets may depend on details later in a build file
or in a different build file altogether, callers will typically make two passes:
* If the mode is 'scan', it collects information about fuzzing imports and targets
* Otherwise, it generates output for each build file with v1 fuzzing imports and targets
replaced by v2. If the mode is 'modify', it writes these to the build file.
Returns whether the file has fuzzing related targets.
"""
with open(pathname, 'r') as file:
lines = [line.rstrip() for line in file.readlines()]
self.input_lines = deque(lines)
self.output_lines = []
self.dirpath = os.path.dirname(pathname)
if self.mode == 'scan':
self.imports = set()
modified = False
while self.input_lines:
line = self.input_lines.popleft()
if (self.on_fuzzing_imports(line) or self.on_fuzzer(line) or
self.on_cpp_fuzzer(line) or self.on_rustc_fuzzer(line) or
self.on_fidl_protocol_fuzzer(line) or
self.on_fuzzer_package(line)):
modified = True
else:
self.output_lines.append(line)
if self.mode == 'modify':
with open(pathname, 'w') as buildfile:
for line in self.output_lines:
print(line, file=buildfile)
return modified
def on_fuzzing_imports(self, line):
"""Take mode-specific actions on a GN imports in the given `line`.
Does nothing if the given `line` is not a fuzzing-related GN import. If it is, then:
If the current mode is 'scan', this will update the converter's list of needed imports.
Otherwise, it adds all needed imports to the converter's output, and clears its imports.
Returns whether the line is a fuzzing-related GN import.
"""
m = re.match(r'\s*import\("(.*)"\)', line)
if not m:
return False
if m[1] == '//build/fuzzing/fuzzer.gni' or m[
1] == '//build/fuzzing/fuzzer_package.gni':
if self.mode == 'scan':
self.imports.add('//build/fuzz.gni')
else:
self.output_lines.extend(
[f'import("{gni}")' for gni in self.imports])
self.imports = set()
return True
return False
def on_fuzzer(self, line, target_type='fuzzer'):
"""Take mode-specific actions on a fuzzer target in the given `line`.
Does nothing if the given `line` is not a fuzzer target. If it is, then:
If the current mode is 'scan', this will determine component related details for the fuzzer.
Otherwise, it replaces v1 fuzzing targets with v2 and adds them to the converter's output.
Returns whether the line is the start of a fuzzer target with the given `target_type`.
"""
target_name, found = self.read_target(target_type, line)
if not found:
return False
fuzzer_label = source_absolute(self.dirpath, ':' + target_name)
output_name = target_name
args = []
deps = []
fuzzer_scope = []
while self.target_scope:
line = self.target_scope.popleft()
invoker_output_name, found = read_string_variable(
'output_name', line)
if found:
output_name = invoker_output_name
corpus, found = read_string_variable('corpus', line)
if found:
if self.mode == 'scan':
corpus_label = source_absolute(self.dirpath, corpus)
deps.append(corpus_label)
args.append('data/' + corpus_label.replace('//', ''))
continue
dictionary, found = read_string_variable('dictionary', line)
if found:
if self.mode == 'scan':
deps.append(fuzzer_label + '-dictionary')
self.imports.add('//build/dist/resource.gni')
args.append('data/' + os.path.basename(dictionary))
else:
dictionary_scope = []
dictionary_scope += make_string_list(
'sources', [dictionary])
dictionary_scope += make_string_list(
'outputs', [r'data/{{source_file_part}}'])
self.write_target(
'resource', target_name + '-dictionary',
dictionary_scope)
continue
options, found = self.read_string_list('options', line)
if found:
args.extend(options)
continue
fuzzer_scope.append(line)
if self.mode == 'scan':
self.components[fuzzer_label] = {
"args": [f'test/{output_name}'] + args,
"deps": deps,
}
elif target_type == 'fuzzer':
self.write_target(
'fuchsia_library_fuzzer', target_name, fuzzer_scope)
else:
self.write_target(target_type, target_name, fuzzer_scope)
return True
def on_cpp_fuzzer(self, line):
"""Like `on_fuzzer`, but for the `cpp_fuzzer` template that aliases `fuzzer`."""
return self.on_fuzzer(line, target_type='cpp_fuzzer')
def on_rustc_fuzzer(self, line):
"""Records component details for `rustc_fuzzers`."""
return self.on_fuzzer(line, target_type='rustc_fuzzer')
def on_fidl_protocol_fuzzer(self, line):
"""Records component details for `fidl_protocol_fuzzer`."""
if self.mode == 'scan':
return self.on_fuzzer(line, target_type='fidl_protocol_fuzzer')
def on_fuzzer_package(self, line):
"""Take mode-specific actions on a `fuzzer_package` target in the given `line`.
If the given `line` is a `fuzzer_package` target, and the current mode is not 'scan', it adds
v2 fuzzer components and a v2 fuzzer package to the converter's output. Additionally, if the
mode is 'modify', it will create the component manifest source files for those components.
Returns whether the line is the start of a `fuzzer_package` target.
"""
target_name, found = self.read_target('fuzzer_package', line)
if not found:
return False
if self.mode == 'scan':
return True
package_scope = []
fuzzer_labels = []
fuzz_host = False
while self.target_scope:
line = self.target_scope.popleft()
found = False
for lang in ['cpp', 'rust']:
labels, found = self.read_string_list(lang + '_fuzzers', line)
if found:
break
if found:
fuzzer_labels += labels
package_scope += make_string_list(
lang + '_fuzzer_components', [
':' + component_name(fuzzer_label)
for fuzzer_label in labels
])
continue
value, found = read_bool_variable('fuzz_host', line)
if found:
fuzz_host = value
continue
package_scope.append(line)
# Create the fuzzer components.
for fuzzer_label in fuzzer_labels:
component = self.components[source_absolute(
self.dirpath, fuzzer_label)]
manifest = manifest_path(self.dirpath, fuzzer_label)
create_manifest(
self.dirpath, manifest, component['args'],
self.mode != 'modify')
component_scope = []
component_scope.append(f'manifest = "{manifest}"')
component_deps = [fuzzer_label]
component_deps.extend(
[
source_relative(self.dirpath, dep)
for dep in component['deps']
])
component_scope += make_string_list('deps', component_deps)
self.write_target(
'fuchsia_fuzzer_component', component_name(fuzzer_label),
component_scope)
# Create the fuzzer package and a host fuzzer group, if requested.
if fuzz_host:
group_scope = ['testonly = true']
group_scope += make_string_list('deps', fuzzer_labels)
self.output_lines.append('if (is_fuchsia) {')
self.write_target(
'fuchsia_fuzzer_package', target_name, package_scope)
self.output_lines.append('} else {')
self.write_target('group', target_name, group_scope)
self.output_lines.append('}')
else:
self.write_target(
'fuchsia_fuzzer_package', target_name, package_scope)
return True
def read_target(self, target_type, line):
"""Attempts to parse a GN target of the given `target_type` starting in the given `line`.
If the given `line` is a `target_type` target, reads lines from the converter's input up to the
closing curly brace into the converter's target scope.
Returns a tuple of the target name and whether the line is the start of a `target_type` target.
"""
m = re.match(rf'\s*{target_type}\("([^\$]*)"\) {{', line)
if not m:
return (None, False)
target_name = m[1]
target_scope = deque([])
depth = 1
while self.input_lines:
line = self.input_lines.popleft()
depth += line.count('{') - line.count('}')
if depth <= 0:
break
target_scope.append(line)
# Consume any following blank lines
while self.input_lines and self.input_lines[0] == '':
self.input_lines.popleft()
self.target_scope = target_scope
return (target_name, True)
def read_string_list(self, name, line):
"""Attempts to parse a GN list of the given `name` starting in the given `line`.
If the given `line` is a `name`d list, reads lines from the converter's target scope up to the
closing square bracket. Each line representing a string value is added to a list of items.
Returns a tuple of the items and whether the line is the start of a `name`d list.
"""
m = re.match(rf'\s*{name} \+?= \[\]', line)
if m:
return ([], True)
m = re.match(rf'\s*{name} \+?= \[ "(.*)" \]', line)
if m:
return ([m[1]], True)
m = re.match(rf'\s*{name} \+?= \[', line)
if not m:
return (None, False)
items = []
depth = 1
while self.target_scope and depth > 0:
line = self.target_scope.popleft()
depth += line.count('[') - line.count(']')
m = re.match(r'\s*"(.*)",', line)
if m:
items.append(m[1])
return (items, True)
def write_target(self, target_type, target_name, target_scope):
"""Adds lines for a GN target to the converter's output."""
self.output_lines.append(f'{target_type}("{target_name}") {{')
self.output_lines.extend(target_scope)
self.output_lines.append('}')
self.output_lines.append('')
def read_bool_variable(name, line):
"""Attempts to parse `line` as a GN boolean variable of the given `name`.
Returns a tuple of the value and whether the line is a `name`d boolean value.
"""
m = re.match(rf'\s*{name} = (.*)', line)
return (m[1] == 'true', True) if m else (None, False)
def read_string_variable(name, line):
"""Attempts to parse `line` as a GN string variable of the given `name`.
Returns a tuple of the value and whether the line is a `name`d string value.
"""
m = re.match(rf'\s*{name} = "(.*)"', line)
return (m[1], True) if m else (None, False)
def make_string_list(name, items):
"""Converts a list of items in to a list of lines needed to specify the list in GN.
Example:
make_string_list('things', []) => [ 'things = []' ]
make_string_list('things', [ 'foo' ]) => [ 'things = [ "foo" ]' ]
make_string_list('things', [ 'foo', 'bar', 'baz' ]) =>
[ 'things = [', '"foo",', '"bar",', '"baz",', ']' ]
"""
if len(items) == 0:
return [f'{name} = []']
if len(items) == 1:
return [f'{name} = [ "{items[0]}" ]']
string_list = [f'{name} = [']
string_list.extend([f'"{item}",' for item in items])
string_list.append(']')
return string_list
@functools.cache
def source_root(dirpath):
"""Find the absolute path to the GN source root.
Example:
source_root('/path/to/fuchsia/examples/fuzzer') => '/path/to/fuchsia'
Raises a `ValueError` if the .gn file cannot be found in the current or any parent directory.
"""
root = os.path.realpath(dirpath)
while not os.path.isfile(os.path.join(root, '.gn')):
if root == '/':
raise ValueError(
f'"{dirpath}" does not appear to be part of a GN project')
root = os.path.dirname(root)
return root
def source_absolute(dirpath, relative_label):
"""Converts a relative GN label to be absolute.
Example:
source_absolute('~/fuchsia/examples/fuzzer', 'cpp:crash_fuzzer') =>
'//examples/fuzzers/cpp:crash_fuzzer'
"""
if relative_label.startswith('//'):
return relative_label
absolute_label = '//' + os.path.relpath(dirpath, source_root(dirpath))
parts = relative_label.split(':')
if parts[0]:
absolute_label = os.path.join(absolute_label, parts[0])
if len(parts) > 1:
absolute_label += f':{parts[1]}'
return absolute_label
def source_relative(dirpath, absolute_label):
"""Converts an absolute GN label to be relative.
Example:
source_relative('~/fuchsia/examples/fuzzer', '//examples/fuzzers/cpp:crash_fuzzer') =>
'cpp:crash_fuzzer'
"""
if not absolute_label.startswith('//'):
return absolute_label
parts = absolute_label.split(':')
abspath = os.path.join(source_root(dirpath), parts[0][2:])
relative_label = os.path.relpath(abspath, dirpath)
if len(parts) > 1:
relative_label += f':{parts[1]}'
return relative_label
def component_name(fuzzer_label):
"""Generates a component name from a GN label.
Example:
component_name('cpp:crash_fuzzer') => 'crash-fuzzer-component'
"""
pos = fuzzer_label.rfind(':')
if pos == -1:
name = fuzzer_label
else:
name = fuzzer_label[(pos + 1):]
return name.replace('_', '-') + '-component'
def manifest_path(dirpath, relative_label):
"""Generates a path to a component manifest source file from a relative GN label.
Example:
manifest_path('~/fuchsia/examples/fuzzer', 'cpp:crash_fuzzer') =>
'~/fuchsia/examples/fuzzer/cpp/meta/crash_fuzzer.cml'
"""
srcpath = '//' + os.path.relpath(dirpath, source_root(dirpath))
absolute_label = source_absolute(dirpath, relative_label)
label_info = absolute_label.split(':')
label_info_dir = os.path.normpath(label_info[0])
label_info_name = label_info[1] if len(
label_info) > 1 else os.path.basename(label_info_dir)
srcpath = (os.path.relpath(label_info_dir, srcpath))
path = os.path.normpath(
os.path.join(srcpath, 'meta', f'{label_info_name}.cml'))
return path
def create_manifest(dirpath, relpath, args, dry_run):
"""Creates a fuzzer component manifest source file.
The generated manifest will simply include libFuzzer's default shard, and augment it with the
specific programs arguments needed by the runner executable.
Raises a `ValueError` if the requested manifest already exists.
"""
pathname = os.path.join(dirpath, relpath)
os.makedirs(os.path.dirname(pathname), exist_ok=True)
if os.path.isfile(pathname):
raise ValueError(f'manifest already exists: {pathname}')
lines = [' args: [']
if len(args) == 1:
lines[0] = lines[0] + f' "{args[0]}" ],'
else:
lines.extend([f' "{arg}",' for arg in args])
lines.append(' ],')
substitutions = {'args': '\n'.join(lines)}
script_dir = os.path.dirname(os.path.realpath(__file__))
with open(os.path.join(script_dir, 'fuchsia_fuzzer_component_template.txt'),
'r') as txt:
template = Template(txt.read())
output = template.substitute(substitutions)
if not dry_run:
with open(pathname, 'w+') as cml:
print(output, end='', file=cml)
if __name__ == '__main__':
sys.exit(main())