模糊测试之书-四-

模糊测试之书(四)

原文:exploringjs.com/ts/book/index.html

译者:飞龙

协议:CC BY-NC-SA 4.0

变异分析

原文:www.fuzzingbook.org/html/MutationAnalysis.html

在关于覆盖的章节中,我们展示了如何确定程序中哪些部分被程序执行,从而获得一组测试用例在覆盖程序结构方面的有效性感。然而,覆盖率本身可能不是衡量测试有效性的最佳指标,因为即使从未检查结果是否正确,也可能有很高的覆盖率。在本章中,我们介绍了一种评估测试套件有效性的另一种方法:在代码中注入变异人工故障)后,我们检查测试套件是否可以检测这些人工故障。其思想是,如果它未能检测到这种变异,它也会错过真实错误。

先决条件

  • 您需要了解程序是如何执行的。

  • 您应该已经阅读了关于覆盖的章节。

摘要

要使用本章提供的代码,请编写

>>> from fuzzingbook.MutationAnalysis import <identifier> 

然后利用以下功能。

本章介绍了两种在主题程序上运行变异分析的方法。第一种类MuFunctionAnalyzer针对单个函数。给定一个函数gcd和两个测试用例评估,可以对测试用例进行如下变异分析:

>>> for mutant in MuFunctionAnalyzer(gcd, log=True):
>>>     with mutant:
>>>         assert gcd(1, 0) == 1, "Minimal"
>>>         assert gcd(0, 1) == 1, "Mirror"
>>> mutant.pm.score()
->  gcd_1
<-  gcd_1
Detected gcd_1 <class 'UnboundLocalError'> cannot access local variable 'c' where it is not associated with a value

->  gcd_2
<-  gcd_2
Detected gcd_2 <class 'AssertionError'> Mirror

->  gcd_3
<-  gcd_3

->  gcd_4
<-  gcd_4

->  gcd_5
<-  gcd_5

->  gcd_6
<-  gcd_6

->  gcd_7
<-  gcd_7
Detected gcd_7 <class 'AssertionError'> Minimal

0.42857142857142855 

第二种类MuProgramAnalyzer针对具有测试套件的独立程序。给定一个程序gcd,其源代码在gcd_src中提供,测试套件由TestGCD提供,可以如下评估TestGCD的变异分数:

>>> class TestGCD(unittest.TestCase):
>>>     def test_simple(self):
>>>         assert cfg.gcd(1, 0) == 1
>>> 
>>>     def test_mirror(self):
>>>         assert cfg.gcd(0, 1) == 1
>>> for mutant in MuProgramAnalyzer('gcd', gcd_src):
>>>     mutant[test_module].runTest('TestGCD')
>>> mutant.pm.score()
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)

1.0 

因此获得的变异分数是衡量给定测试套件质量比纯覆盖率更好的指标。

为什么结构覆盖率不足以

结构覆盖率测量的一个问题是它未能检查测试套件生成的程序执行是否实际上是正确的。也就是说,一个产生错误输出但测试套件未注意到的执行与产生正确输出的执行在覆盖率中被计为完全相同。事实上,如果删除典型测试用例中的断言,新测试套件的覆盖率不会改变,但新测试套件比原始测试套件要少得多。作为一个例子,考虑这个“测试”:

def ineffective_test_1():
    execute_the_program_as_a_whole()
    assert True 

这里的最终断言将始终通过,无论execute_the_program_as_a_whole()做什么。好吧,如果execute_the_program_as_a_whole()引发异常,测试将失败,但我们也可以绕过这一点:

def ineffective_test_2():
    try:
        execute_the_program_as_a_whole()
    except:
        pass
    assert True 

然而,这些“测试”的问题在于execute_the_program_as_a_whole()可能达到 100%的代码覆盖率(或 100%的任何其他结构覆盖率指标)。然而,这个 100%的数字并不能反映测试发现错误的能力,实际上这个能力是 0%。

这确实不是一种理想的状态。我们如何验证我们的测试实际上是有用的呢?一个替代方案(在第关于覆盖率的章节中暗示)是将错误注入程序中,并评估测试套件捕捉这些注入错误的有效性。然而,这又引入了另一个问题。我们最初如何产生这些错误呢?任何手动工作都可能受到开发者对错误可能发生位置及其影响的先入为主的偏见。此外,编写好的错误可能需要花费大量时间,而收益却非常间接。因此,这种解决方案是不够的。

使用变异分析植入人工故障

变异分析提供了一种评估测试套件有效性的替代方案。变异分析的想法是在程序代码中植入人工故障,称为变异,并检查测试套件是否能发现它们。例如,这种变异可能是在execute_the_program_as_a_whole()函数中的某个地方将+替换为-。当然,上述无效的测试不会检测到这一点,因为它们没有检查任何结果。一个有效的测试将会这样做;并且假设测试在发现人工故障方面越有效,它在发现真实故障方面就越有效。

变异分析带来的洞察是从程序员的视角考虑引入错误的概率。如果假设程序中每个程序元素所获得的关注程度足够相似,那么可以进一步假设程序中的每个标记都有相似的概率被错误地转录。当然,程序员会纠正编译器(或其他静态分析工具)检测到的任何错误。因此,那些不同于原始版本并通过编译阶段的合法标记集合被认为是其可能的变异集合,这些变异代表了程序中的可能故障。然后,测试套件的判断标准是它检测(从而防止)此类变异的能力。检测到的此类变异与所有有效变异产生的比例被用作变异分数。在本章中,我们将看到如何在 Python 程序中实现变异分析。获得的变异分数代表了任何程序分析工具防止错误的能力,并且可以用来评估静态测试套件、测试生成器(如模糊器)、以及静态和符号执行框架。

可能会直观地考虑一个稍微不同的视角。测试套件是一个程序,可以将其视为接受要测试的程序作为输入。评估这样一个程序(测试套件)的最佳方法是什么?我们可以通过对输入程序应用小的突变来模糊测试套件,并验证所讨论的测试套件不会产生意外的行为。测试套件应该只允许原始程序通过;因此,任何未被检测为错误的突变都代表测试套件中的错误。

通过示例展示的结构覆盖率充分性

让我们引入一个更详细的例子来说明覆盖率的问题以及突变分析是如何工作的。下面的triangle()程序根据边长\(a\)\(b\)\(c\)将三角形分类到正确的三角形类别。我们希望验证程序是否正确工作。

def triangle(a, b, c):
    if a == b:
        if b == c:
            return 'Equilateral'
        else:
            return 'Isosceles'
    else:
        if b == c:
            return "Isosceles"
        else:
            if a == c:
                return "Isosceles"
            else:
                return "Scalene" 

这里有一些测试用例来确保程序能正常工作。

def strong_oracle(fn):
    assert fn(1, 1, 1) == 'Equilateral'

    assert fn(1, 2, 1) == 'Isosceles'
    assert fn(2, 2, 1) == 'Isosceles'
    assert fn(1, 2, 2) == 'Isosceles'

    assert fn(1, 2, 3) == 'Scalene' 

运行它们实际上会导致所有测试通过。

strong_oracle(triangle) 

然而,“所有测试通过”的陈述只有在我们知道我们的测试是有效的时才有价值。我们的测试套件的有效性是什么?正如我们在覆盖率章节中看到的,可以使用结构覆盖率技术,如语句覆盖率,来获得测试用例有效性的度量。

import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) 
from Coverage import Coverage 
import [inspect](https://docs.python.org/3/library/inspect.html) 

我们添加了一个show_coverage()函数来可视化获得的覆盖率。

class VisualCoverage(Coverage):
    def show_coverage(self, fn):
        src = inspect.getsource(fn)
        name = fn.__name__
        covered = set([lineno for method,
                       lineno in self._trace if method == name])
        for i, s in enumerate(src.split('\n')):
            print('%s  %2d: %s' % ('#' if i + 1 in covered else ' ', i + 1, s)) 
with VisualCoverage() as cov:
    strong_oracle(triangle) 
cov.show_coverage(triangle) 
   1: def triangle(a, b, c):
#  2:     if a == b:
#  3:         if b == c:
#  4:             return 'Equilateral'
   5:         else:
#  6:             return 'Isosceles'
   7:     else:
#  8:         if b == c:
#  9:             return "Isosceles"
  10:         else:
# 11:             if a == c:
# 12:                 return "Isosceles"
  13:             else:
# 14:                 return "Scalene"
  15: 

我们的strong_oracle()似乎已经充分覆盖了所有可能的情况。也就是说,根据结构覆盖率,我们的测试用例集是相当好的。然而,获得的覆盖率是否就是全部故事?考虑这个测试套件:

def weak_oracle(fn):
    assert fn(1, 1, 1) == 'Equilateral'

    assert fn(1, 2, 1) != 'Equilateral'
    assert fn(2, 2, 1) != 'Equilateral'
    assert fn(1, 2, 2) != 'Equilateral'

    assert fn(1, 2, 3) != 'Equilateral' 

我们在这里检查的只是具有不等边的三角形不是等边三角形。我们获得了什么样的覆盖率?

with VisualCoverage() as cov:
    weak_oracle(triangle) 
cov.show_coverage(triangle) 
   1: def triangle(a, b, c):
#  2:     if a == b:
#  3:         if b == c:
#  4:             return 'Equilateral'
   5:         else:
#  6:             return 'Isosceles'
   7:     else:
#  8:         if b == c:
#  9:             return "Isosceles"
  10:         else:
# 11:             if a == c:
# 12:                 return "Isosceles"
  13:             else:
# 14:                 return "Scalene"
  15: 

事实上,似乎在覆盖率上没有任何差异。weak_oracle()获得的覆盖率与strong_oracle()完全相同。然而,稍加思考应该会让人相信weak_oracle()并不像strong_oracle()那样有效。然而,覆盖率无法区分这两个测试套件。我们在覆盖率上遗漏了什么?这里的问题是覆盖率无法评估我们断言的质量。事实上,覆盖率根本不在乎断言。然而,正如我们上面所看到的,断言是测试套件有效性的极其重要的一部分。因此,我们需要一种方法来评估断言的质量。

注入人工故障

注意,在 覆盖率章节 中,覆盖率被提出作为测试套件发现错误的可能性的 代理。如果我们实际上尝试评估测试套件发现错误的可能性怎么办?我们只需要将错误注入到程序中,一次一个,并计算我们的测试套件检测到的这种错误数量。检测频率将为我们提供测试套件发现错误的实际可能性。这种技术被称为 故障注入。以下是一个 故障注入 的例子。

def triangle_m1(a, b, c):
    if a == b:
        if b == c:
            return 'Equilateral'
        else:
            # return 'Isosceles'
            return None  # <-- injected fault
    else:
        if b == c:
            return "Isosceles"
        else:
            if a == c:
                return "Isosceles"
            else:
                return "Scalene" 

让我们看看我们的测试套件是否足够好,能够捕捉到这个错误。我们首先检查 weak_oracle() 是否能够检测到这个变化。

from ExpectError import ExpectError 
with ExpectError():
    weak_oracle(triangle_m1) 

weak_oracle() 无法检测到任何变化。那么我们的 strong_oracle() 呢?

with ExpectError():
    strong_oracle(triangle_m1) 
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/3191755624.py", line 2, in <module>
    strong_oracle(triangle_m1)
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/566939880.py", line 5, in strong_oracle
    assert fn(2, 2, 1) == 'Isosceles'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError (expected)

我们的 strong_oracle() 能够检测到这个错误,这是 strong_oracle() 可能是一个更好的测试套件的证据。

故障注入 可以提供一个测试套件有效性的良好度量,前提是我们有一个可能的错误列表。问题是收集这样一组 无偏 错误相当昂贵。创建合理难以检测的良好错误很困难,而且是一个手动过程。鉴于这是一个手动过程,生成的错误将受到创建它的开发者的先入为主的偏见。即使这样的精心挑选的错误可用,它们也不太可能是详尽的,可能会错过重要的错误类别和程序的某些部分。因此,故障注入 不能替代覆盖率。我们能做得更好吗?

变异分析为精心挑选的错误集提供了一种替代方案。关键洞察是,如果假设程序员理解了相关的程序,那么犯的大多数错误很可能都是小的转录错误(几个标记)。编译器可能会捕获这些错误中的大多数。因此,程序中剩余的大多数错误很可能是由程序结构中某些点的小(单个标记)变化引起的(这个特定的假设被称为 合格程序员假设有限邻域假设)。

那么由多个较小错误组成的较大错误呢?关键洞察在于,对于大多数这样的复杂错误,单独检测一个较小错误的测试用例很可能检测到包含它的较大复杂错误。(这个假设被称为 耦合效应。)

我们如何将这些假设用于实践呢?想法是简单地生成所有可能的、与原始程序不同但只经过微小变化(如单个标记变化)的 有效 程序变体(这些变体被称为 突变体)。接下来,将给定的测试套件应用于生成的每个变体。任何被测试套件检测到的突变体都被说成是被测试套件 杀死 的。测试套件的有效性由被杀死的突变体与生成的有效突变体的比例给出。

我们接下来实现一个简单的变异分析框架,并使用它来评估我们的测试套件。

变异 Python 代码

为了操作 Python 程序,我们在抽象语法树(AST)表示形式上工作——这是编译器和解释器在读取程序文本后工作的内部表示形式。

简而言之,我们将程序转换成树,然后改变树的某些部分——例如,将 + 运算符更改为 - 或相反,或将实际语句更改为不执行任何操作的 pass 语句。然后,可以进一步处理生成的变异树;它可以传递给 Python 解释器执行,或者我们可以将其反解析回文本形式。

我们首先导入 AST 操作模块。

import [ast](https://docs.python.org/3/library/ast.html)
import [inspect](https://docs.python.org/3/library/inspect.html) 

我们可以使用 inspect.getsource() 获取 Python 函数的源代码。(注意,这不适用于在其他笔记本中定义的函数。)

triangle_source = inspect.getsource(triangle)
triangle_source 
'def triangle(a, b, c):\n    if a == b:\n        if b == c:\n            return \'Equilateral\'\n        else:\n            return \'Isosceles\'\n    else:\n        if b == c:\n            return "Isosceles"\n        else:\n            if a == c:\n                return "Isosceles"\n            else:\n                return "Scalene"\n'

为了以视觉上令人愉悦的形式查看这些内容,我们的函数 print_content(s, suffix) 格式化和突出显示字符串 s,就像它是一个以 suffix 结尾的文件。因此,我们可以像查看(并突出显示)Python 文件一样查看(并突出显示)源代码:

from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import print_content 
print_content(triangle_source, '.py') 
def triangle(a, b, c):
    if a == b:
        if b == c:
            return 'Equilateral'
        else:
            return 'Isosceles'
    else:
        if b == c:
            return "Isosceles"
        else:
            if a == c:
                return "Isosceles"
            else:
                return "Scalene"

解析这些语句会给我们一个抽象语法树(AST)——这是程序以树形表示的形式。

triangle_ast = ast.parse(triangle_source) 

这个 AST 看起来是什么样子?辅助函数 ast.dump()(文本输出)和 showast.show_ast()(带有 showast 的图形输出)允许我们检查树的结构。我们看到函数以具有名称和参数的 FunctionDef 开始,后面是一个体,即语句列表;在这种情况下,体只包含一个 If,它本身包含其他类型的节点,如 IfCompareNameStrReturn

print(ast.dump(triangle_ast, indent=4)) 
Module(
    body=[
        FunctionDef(
            name='triangle',
            args=arguments(
                posonlyargs=[],
                args=[
                    arg(arg='a'),
                    arg(arg='b'),
                    arg(arg='c')],
                kwonlyargs=[],
                kw_defaults=[],
                defaults=[]),
            body=[
                If(
                    test=Compare(
                        left=Name(id='a', ctx=Load()),
                        ops=[
                            Eq()],
                        comparators=[
                            Name(id='b', ctx=Load())]),
                    body=[
                        If(
                            test=Compare(
                                left=Name(id='b', ctx=Load()),
                                ops=[
                                    Eq()],
                                comparators=[
                                    Name(id='c', ctx=Load())]),
                            body=[
                                Return(
                                    value=Constant(value='Equilateral'))],
                            orelse=[
                                Return(
                                    value=Constant(value='Isosceles'))])],
                    orelse=[
                        If(
                            test=Compare(
                                left=Name(id='b', ctx=Load()),
                                ops=[
                                    Eq()],
                                comparators=[
                                    Name(id='c', ctx=Load())]),
                            body=[
                                Return(
                                    value=Constant(value='Isosceles'))],
                            orelse=[
                                If(
                                    test=Compare(
                                        left=Name(id='a', ctx=Load()),
                                        ops=[
                                            Eq()],
                                        comparators=[
                                            Name(id='c', ctx=Load())]),
                                    body=[
                                        Return(
                                            value=Constant(value='Isosceles'))],
                                    orelse=[
                                        Return(
                                            value=Constant(value='Scalene'))])])])],
            decorator_list=[],
            type_params=[])],
    type_ignores=[])

文字太多?这个图形表示可能使事情更简单。

from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import rich_output 
if rich_output():
    import [showast](https://pypi.org/project/showast/)
    showast.show_ast(triangle_ast) 

0 FunctionDef 1 "triangle" 0--1 2 arguments 0--2 9 If 0--9 3 arg 2--3 5 arg 2--5 7 arg 2--7 4 "a" 3--4 6 "b" 5--6 8 "c" 7--8 10 Compare 9--10 18 If 9--18 33 If 9--33 11 Name 10--11 14 Eq 10--14 15 Name 10--15 12 "a" 11--12 13 Load 11--13 16 "b" 15--16 17 Load 15--17 19 Compare 18--19 27 Return 18--27 30 Return 18--30 20 Name 19--20 23 Eq 19--23 24 Name 19--24 21 "b" 20--21 22 Load 20--22 25 "c" 24--25 26 Load 24--26 28 Constant 27--28 29 "Equilateral" 28--29 31 Constant 30--31 32 "Isosceles" 31--32 34 Compare 33--34 42 Return 33--42 45 If 33--45 35 Name 34--35 38 Eq 34--38 39 Name 34--39 36 "b" 35--36 37 Load 35--37 40 "c" 39--40 41 Load 39--41 43 Constant 42--43 44 "Isosceles" 43--44 46 Compare 45--46 54 Return 45--54 57 Return 45--57 47 Name 46--47 50 Eq 46--50 51 Name 46--51 48 "a" 47--48 49 Load 47--49 52 "c" 51--52 53 Load 51--53 55 Constant 54--55 56 "Isosceles" 55--56 58 Constant 57--58 59 "Scalene" 58--59

函数 ast.unparse() 将这样的树转换回更熟悉的文本 Python 代码表示形式。

print_content(ast.unparse(triangle_ast), '.py') 
def triangle(a, b, c):
    if a == b:
        if b == c:
            return 'Equilateral'
        else:
            return 'Isosceles'
    elif b == c:
        return 'Isosceles'
    elif a == c:
        return 'Isosceles'
    else:
        return 'Scalene'

函数的简单变异器

让我们现在去修改 triangle() 程序。产生这个程序有效变异版本的一个简单方法是将其中的一些语句替换为 pass

MuFunctionAnalyzer 是负责测试套件变异分析的主要类。它接受要测试的函数。它通过解析和反解析一次提供的源代码来规范化源代码。这是确保后续的原始代码和变异代码之间的 diff 不受空白、注释等差异的影响所必需的。

class MuFunctionAnalyzer:
    def __init__(self, fn, log=False):
        self.fn = fn
        self.name = fn.__name__
        src = inspect.getsource(fn)
        self.ast = ast.parse(src)
        self.src = ast.unparse(self.ast)  # normalize
        self.mutator = self.mutator_object()
        self.nmutations = self.get_mutation_count()
        self.un_detected = set()
        self.mutants = []
        self.log = log

    def mutator_object(self, locations=None):
        return StmtDeletionMutator(locations)

    def register(self, m):
        self.mutants.append(m)

    def finish(self):
        pass 

get_mutation_count() 获取可用的变异数量。我们稍后会看到如何实现这一点。

class MuFunctionAnalyzer(MuFunctionAnalyzer):
    def get_mutation_count(self):
        self.mutator.visit(self.ast)
        return self.mutator.count 

Mutator 为实现单个突变提供了基类。它接受一个要突变的地点列表。它假设子类确定的所有感兴趣节点的 mutable_visit() 方法被调用。当 Mutator 在没有要突变的地点列表的情况下被调用时,它简单地遍历所有可能的突变点,并在 self.count 中保留计数。如果它被调用并带有特定的突变地点列表,则 mutable_visit() 方法调用 mutation_visit(),该方法在节点上执行突变。请注意,单个位置可以产生多个突变。(因此有哈希表)。

class Mutator(ast.NodeTransformer):
    def __init__(self, mutate_location=-1):
        self.count = 0
        self.mutate_location = mutate_location

    def mutable_visit(self, node):
        self.count += 1  # statements start at line no 1
        if self.count == self.mutate_location:
            return self.mutation_visit(node)
        return self.generic_visit(node) 

StmtDeletionMutator 简单地钩入所有语句处理访问者。它通过将给定的语句替换为 pass 来执行突变。正如你所见,它访问了所有类型的语句。

class StmtDeletionMutator(Mutator):
    def visit_Return(self, node): return self.mutable_visit(node)
    def visit_Delete(self, node): return self.mutable_visit(node)

    def visit_Assign(self, node): return self.mutable_visit(node)
    def visit_AnnAssign(self, node): return self.mutable_visit(node)
    def visit_AugAssign(self, node): return self.mutable_visit(node)

    def visit_Raise(self, node): return self.mutable_visit(node)
    def visit_Assert(self, node): return self.mutable_visit(node)

    def visit_Global(self, node): return self.mutable_visit(node)
    def visit_Nonlocal(self, node): return self.mutable_visit(node)

    def visit_Expr(self, node): return self.mutable_visit(node)

    def visit_Pass(self, node): return self.mutable_visit(node)
    def visit_Break(self, node): return self.mutable_visit(node)
    def visit_Continue(self, node): return self.mutable_visit(node) 

实际的突变包括用 pass 语句替换节点:

class StmtDeletionMutator(StmtDeletionMutator):
    def mutation_visit(self, node): return ast.Pass() 

对于 triangle(),这个访问者产生了五个突变——即用 pass 替换五个 return 语句:

MuFunctionAnalyzer(triangle).nmutations 
5

我们需要一种方法来获取单个突变体。为此,我们将我们的 MuFunctionAnalyzer 转换为一个 可迭代对象

class MuFunctionAnalyzer(MuFunctionAnalyzer):
    def __iter__(self):
        return PMIterator(self) 

PMIteratorMuFunctionAnalyzer迭代器 类,定义如下。

class PMIterator:
    def __init__(self, pm):
        self.pm = pm
        self.idx = 0 

next() 方法返回相应的 Mutant

class PMIterator(PMIterator):
    def __next__(self):
        i = self.idx
        if i >= self.pm.nmutations:
            self.pm.finish()
            raise StopIteration()
        self.idx += 1
        mutant = Mutant(self.pm, self.idx, log=self.pm.log)
        self.pm.register(mutant)
        return mutant 

Mutant 类包含在给定突变位置时生成突变体的逻辑。

class Mutant:
    def __init__(self, pm, location, log=False):
        self.pm = pm
        self.i = location
        self.name = "%s_%s" % (self.pm.name, self.i)
        self._src = None
        self.tests = []
        self.detected = False
        self.log = log 

这里是如何使用它的:

for m in MuFunctionAnalyzer(triangle):
    print(m.name) 
triangle_1
triangle_2
triangle_3
triangle_4
triangle_5

这些名称有点通用。让我们看看我们是否能对产生的突变有更多的了解。

generate_mutant() 简单地调用 mutator() 方法,并将 AST 的副本传递给突变器。

class Mutant(Mutant):
    def generate_mutant(self, location):
        mutant_ast = self.pm.mutator_object(
            location).visit(ast.parse(self.pm.src))  # copy
        return ast.unparse(mutant_ast) 

src() 方法返回突变后的源代码。

class Mutant(Mutant):
    def src(self):
        if self._src is None:
            self._src = self.generate_mutant(self.i)
        return self._src 

这里是如何获取突变体以及可视化与原始代码的差异:

import [difflib](https://docs.python.org/3/library/difflib.html) 
for mutant in MuFunctionAnalyzer(triangle):
    shape_src = mutant.pm.src
    for line in difflib.unified_diff(mutant.pm.src.split('\n'),
                                     mutant.src().split('\n'),
                                     fromfile=mutant.pm.name,
                                     tofile=mutant.name, n=3):
        print(line) 
--- triangle

+++ triangle_1

@@ -1,7 +1,7 @@

 def triangle(a, b, c):
     if a == b:
         if b == c:
-            return 'Equilateral'
+            pass
         else:
             return 'Isosceles'
     elif b == c:
--- triangle

+++ triangle_2

@@ -3,7 +3,7 @@

         if b == c:
             return 'Equilateral'
         else:
-            return 'Isosceles'
+            pass
     elif b == c:
         return 'Isosceles'
     elif a == c:
--- triangle

+++ triangle_3

@@ -5,7 +5,7 @@

         else:
             return 'Isosceles'
     elif b == c:
-        return 'Isosceles'
+        pass
     elif a == c:
         return 'Isosceles'
     else:
--- triangle

+++ triangle_4

@@ -7,6 +7,6 @@

     elif b == c:
         return 'Isosceles'
     elif a == c:
-        return 'Isosceles'
+        pass
     else:
         return 'Scalene'
--- triangle

+++ triangle_5

@@ -9,4 +9,4 @@

     elif a == c:
         return 'Isosceles'
     else:
-        return 'Scalene'
+        pass

在这个 diff 输出中,以 + 前缀的行是添加的,而以 - 前缀的行是删除的。我们看到五个突变体确实用 pass 语句替换了每个返回语句。

我们向 Mutant 添加了 diff() 方法,以便可以直接调用它。

class Mutant(Mutant):
    def diff(self):
        return '\n'.join(difflib.unified_diff(self.pm.src.split('\n'),
                                              self.src().split('\n'),
                                              fromfile='original',
                                              tofile='mutant',
                                              n=3)) 

评估突变

我们现在准备好实现实际的评估。我们定义我们的突变体作为一个 上下文管理器,以验证所有给出的断言是否成功。想法是我们可以编写如下代码

for mutant in MuFunctionAnalyzer(function):
    with mutant:
        assert function(x) == y 

mutant 激活时(即 with: 下的代码块),原始函数被替换为突变函数。

__enter__() 函数在进入 with 块时被调用。它创建突变体作为一个 Python 函数,并将其放置在全局命名空间中,这样 assert 语句就执行突变函数而不是原始函数。

class Mutant(Mutant):
    def __enter__(self):
        if self.log:
            print('->\t%s' % self.name)
        c = compile(self.src(), '<mutant>', 'exec')
        eval(c, globals()) 

__exit__() 函数检查是否发生了异常(即断言失败,或引发了其他错误);如果是这样,它将突变标记为 detected。最后,它恢复原始函数定义。

class Mutant(Mutant):
    def __exit__(self, exc_type, exc_value, traceback):
        if self.log:
            print('<-\t%s' % self.name)
        if exc_type is not None:
            self.detected = True
            if self.log:
                print("Detected %s" % self.name, exc_type, exc_value)
        globals()[self.pm.name] = self.pm.fn
        if self.log:
            print()
        return True 

finish() 方法简单地调用突变体上的方法,检查突变体是否被发现,并返回结果。

from ExpectError import ExpectTimeout 
class MuFunctionAnalyzer(MuFunctionAnalyzer):
    def finish(self):
        self.un_detected = {
            mutant for mutant in self.mutants if not mutant.detected} 

突变分数——测试套件检测到的突变体的比率——通过 score() 计算。1.0 的分数意味着所有突变体都被发现;0.1 的分数意味着只有 10% 的突变体被检测到。

class MuFunctionAnalyzer(MuFunctionAnalyzer):
    def score(self):
        return (self.nmutations - len(self.un_detected)) / self.nmutations 

这是我们的框架的使用方法。

import [sys](https://docs.python.org/3/library/sys.html) 
for mutant in MuFunctionAnalyzer(triangle, log=True):
    with mutant:
        assert triangle(1, 1, 1) == 'Equilateral', "Equal Check1"
        assert triangle(1, 0, 1) != 'Equilateral', "Equal Check2"
        assert triangle(1, 0, 2) != 'Equilateral', "Equal Check3"
mutant.pm.score() 
->	triangle_1
<-	triangle_1
Detected triangle_1 <class 'AssertionError'> Equal Check1

->	triangle_2
<-	triangle_2

->	triangle_3
<-	triangle_3

->	triangle_4
<-	triangle_4

->	triangle_5
<-	triangle_5

0.2

五个突变中只有一个导致了失败的断言。因此,weak_oracle() 测试套件的突变分数为 20%。

for mutant in MuFunctionAnalyzer(triangle):
    with mutant:
        weak_oracle(triangle)
mutant.pm.score() 
0.2

由于我们正在修改全局命名空间,我们不需要在突变体的 for 循环中直接引用该函数。

def oracle():
    strong_oracle(triangle) 
for mutant in MuFunctionAnalyzer(triangle, log=True):
    with mutant:
        oracle()
mutant.pm.score() 
->	triangle_1
<-	triangle_1
Detected triangle_1 <class 'AssertionError'> 

->	triangle_2
<-	triangle_2
Detected triangle_2 <class 'AssertionError'> 

->	triangle_3
<-	triangle_3
Detected triangle_3 <class 'AssertionError'> 

->	triangle_4
<-	triangle_4
Detected triangle_4 <class 'AssertionError'> 

->	triangle_5
<-	triangle_5
Detected triangle_5 <class 'AssertionError'> 

1.0

即,我们能够通过 strong_oracle() 测试套件实现 100% 的突变分数。

这里是另一个例子。gcd() 计算两个数的最大公约数。

def gcd(a, b):
    if a < b:
        c = a
        a = b
        b = c

    while b != 0:
        c = a
        a = b
        b = c % b

    return a 

这里是对它的一个测试。它有多有效?

for mutant in MuFunctionAnalyzer(gcd, log=True):
    with mutant:
        assert gcd(1, 0) == 1, "Minimal"
        assert gcd(0, 1) == 1, "Mirror" 
->	gcd_1
<-	gcd_1
Detected gcd_1 <class 'UnboundLocalError'> cannot access local variable 'c' where it is not associated with a value

->	gcd_2
<-	gcd_2
Detected gcd_2 <class 'AssertionError'> Mirror

->	gcd_3
<-	gcd_3

->	gcd_4
<-	gcd_4

->	gcd_5
<-	gcd_5

->	gcd_6
<-	gcd_6

->	gcd_7
<-	gcd_7
Detected gcd_7 <class 'AssertionError'> Minimal

mutant.pm.score() 
0.42857142857142855

我们看到我们的 TestGCD 测试套件能够获得 42% 的突变分数。

模块和测试套件的突变器

考虑我们之前讨论的 triangle() 程序。正如我们讨论的那样,产生这个程序有效突变版本的一个简单方法是将其中的一些语句替换为 pass

为了演示目的,我们希望像程序在另一个文件中一样进行操作。我们可以通过在 Python 中创建一个 Module 对象并将函数附加到它上来实现这一点。

import [types](https://docs.python.org/3/library/types.html) 
def import_code(code, name):
    module = types.ModuleType(name)
    exec(code, module.__dict__)
    return module 

我们将 triangle() 函数附加到 shape 模块。

shape = import_code(shape_src, 'shape') 

我们现在可以通过 shape 模块调用三角形。

shape.triangle(1, 1, 1) 
'Equilateral'

我们想测试 triangle() 函数。为此,我们定义了一个如下的 StrongShapeTest 类。

import [unittest](https://docs.python.org/3/library/unittest.html) 
class StrongShapeTest(unittest.TestCase):

    def test_equilateral(self):
        assert shape.triangle(1, 1, 1) == 'Equilateral'

    def test_isosceles(self):
        assert shape.triangle(1, 2, 1) == 'Isosceles'
        assert shape.triangle(2, 2, 1) == 'Isosceles'
        assert shape.triangle(1, 2, 2) == 'Isosceles'

    def test_scalene(self):
        assert shape.triangle(1, 2, 3) == 'Scalene' 

我们定义了一个辅助函数 suite(),该函数遍历给定的类并识别测试函数。

def suite(test_class):
    suite = unittest.TestSuite()
    for f in test_class.__dict__:
        if f.startswith('test_'):
            suite.addTest(test_class(f))
    return suite 

TestTriangle 类的测试可以通过不同的测试运行器调用。最简单的方法是直接调用 TestCaserun() 方法。

suite(StrongShapeTest).run(unittest.TestResult()) 
<unittest.result.TestResult run=3 errors=0 failures=0>

TextTestRunner 类提供了控制执行详细程度的能力。它还允许在 第一次 失败时返回。

runner = unittest.TextTestRunner(verbosity=0, failfast=True)
runner.run(suite(StrongShapeTest)) 
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

<unittest.runner.TextTestResult run=3 errors=0 failures=0>

在覆盖率下运行程序如下:

with VisualCoverage() as cov:
    suite(StrongShapeTest).run(unittest.TestResult()) 

获得的覆盖率如下:

cov.show_coverage(triangle) 
   1: def triangle(a, b, c):
#  2:     if a == b:
#  3:         if b == c:
#  4:             return 'Equilateral'
   5:         else:
#  6:             return 'Isosceles'
#  7:     else:
#  8:         if b == c:
#  9:             return "Isosceles"
# 10:         else:
  11:             if a == c:
# 12:                 return "Isosceles"
  13:             else:
  14:                 return "Scalene"
  15: 

class WeakShapeTest(unittest.TestCase):
    def test_equilateral(self):
        assert shape.triangle(1, 1, 1) == 'Equilateral'

    def test_isosceles(self):
        assert shape.triangle(1, 2, 1) != 'Equilateral'
        assert shape.triangle(2, 2, 1) != 'Equilateral'
        assert shape.triangle(1, 2, 2) != 'Equilateral'

    def test_scalene(self):
        assert shape.triangle(1, 2, 3) != 'Equilateral' 

它获得了多少覆盖率?

with VisualCoverage() as cov:
    suite(WeakShapeTest).run(unittest.TestResult()) 
cov.show_coverage(triangle) 
   1: def triangle(a, b, c):
#  2:     if a == b:
#  3:         if b == c:
#  4:             return 'Equilateral'
   5:         else:
#  6:             return 'Isosceles'
#  7:     else:
#  8:         if b == c:
#  9:             return "Isosceles"
# 10:         else:
  11:             if a == c:
# 12:                 return "Isosceles"
  13:             else:
  14:                 return "Scalene"
  15: 

MuProgramAnalyzer 是负责测试套件突变分析的主要类。它接受要测试的模块名称及其源代码。它通过解析和重新解析一次给定的源代码来规范化源代码。这是确保后续的原始和突变体之间的 diff 不受空白、注释等差异的影响所必需的。

class MuProgramAnalyzer(MuFunctionAnalyzer):
    def __init__(self, name, src):
        self.name = name
        self.ast = ast.parse(src)
        self.src = ast.unparse(self.ast)
        self.changes = []
        self.mutator = self.mutator_object()
        self.nmutations = self.get_mutation_count()
        self.un_detected = set()

    def mutator_object(self, locations=None):
        return AdvStmtDeletionMutator(self, locations) 

我们现在扩展 Mutator 类。

class AdvMutator(Mutator):
    def __init__(self, analyzer, mutate_locations=None):
        self.count = 0
        self.mutate_locations = [] if mutate_locations is None else mutate_locations
        self.pm = analyzer

    def mutable_visit(self, node):
        self.count += 1  # statements start at line no 1
        return self.mutation_visit(node) 

AdvStmtDeletionMutator 简单地钩入所有语句处理访问者。它通过用 pass 替换给定的语句来进行突变。

class AdvStmtDeletionMutator(AdvMutator, StmtDeletionMutator):
    def __init__(self, analyzer, mutate_locations=None):
        AdvMutator.__init__(self, analyzer, mutate_locations)

    def mutation_visit(self, node):
        index = 0  # there is only one way to delete a statement -- replace it by pass
        if not self.mutate_locations:  # counting pass
            self.pm.changes.append((self.count, index))
            return self.generic_visit(node)
        else:
            # get matching changes for this pass
            mutating_lines = set((count, idx)
                                 for (count, idx) in self.mutate_locations)
            if (self.count, index) in mutating_lines:
                return ast.Pass()
            else:
                return self.generic_visit(node) 

再次,我们可以获得 triangle() 产生的突变数量如下。

MuProgramAnalyzer('shape', shape_src).nmutations 
5

我们需要一种方法来获取单个突变体。为此,我们将我们的 MuProgramAnalyzer 转换为 可迭代

class MuProgramAnalyzer(MuProgramAnalyzer):
    def __iter__(self):
        return AdvPMIterator(self) 

AdvPMIteratorMuProgramAnalyzer迭代器 类,定义如下。

class AdvPMIterator:
    def __init__(self, pm):
        self.pm = pm
        self.idx = 0 

next() 方法返回相应的 Mutant

class AdvPMIterator(AdvPMIterator):
    def __next__(self):
        i = self.idx
        if i >= len(self.pm.changes):
            raise StopIteration()
        self.idx += 1
        # there could be multiple changes in one mutant
        return AdvMutant(self.pm, [self.pm.changes[i]]) 

Mutant 类包含在给定突变位置时生成突变体的逻辑。

class AdvMutant(Mutant):
    def __init__(self, pm, locations):
        self.pm = pm
        self.i = locations
        self.name = "%s_%s" % (self.pm.name,
                               '_'.join([str(i) for i in self.i]))
        self._src = None 

这是它的用法:

shape_src = inspect.getsource(triangle) 
for m in MuProgramAnalyzer('shape', shape_src):
    print(m.name) 
shape_(1, 0)
shape_(2, 0)
shape_(3, 0)
shape_(4, 0)
shape_(5, 0)

generate_mutant()函数简单地调用mutator()方法,并将 AST 的副本传递给突变器。

class AdvMutant(AdvMutant):
    def generate_mutant(self, locations):
        mutant_ast = self.pm.mutator_object(
            locations).visit(ast.parse(self.pm.src))  # copy
        return ast.unparse(mutant_ast) 

src()方法返回突变后的源代码。

class AdvMutant(AdvMutant):
    def src(self):
        if self._src is None:
            self._src = self.generate_mutant(self.i)
        return self._src 

再次,我们将突变体表示为与原始版本的差异:

import [difflib](https://docs.python.org/3/library/difflib.html) 

我们向Mutant类添加了diff()方法,以便可以直接调用。

class AdvMutant(AdvMutant):
    def diff(self):
        return '\n'.join(difflib.unified_diff(self.pm.src.split('\n'),
                                              self.src().split('\n'),
                                              fromfile='original',
                                              tofile='mutant',
                                              n=3)) 
for mutant in MuProgramAnalyzer('shape', shape_src):
    print(mutant.name)
    print(mutant.diff())
    break 
shape_(1, 0)
--- original

+++ mutant

@@ -1,7 +1,7 @@

 def triangle(a, b, c):
     if a == b:
         if b == c:
-            return 'Equilateral'
+            pass
         else:
             return 'Isosceles'
     elif b == c:

我们现在准备实施实际的评估。为此,我们需要能够接受定义测试套件的模块,并在其上调用测试方法。getitem方法接受测试模块,将测试模块上的导入条目固定为正确指向突变体模块,并将其传递给测试运行器MutantTestRunner

class AdvMutant(AdvMutant):
    def __getitem__(self, test_module):
        test_module.__dict__[
            self.pm.name] = import_code(
            self.src(), self.pm.name)
        return MutantTestRunner(self, test_module) 

MutantTestRunner简单地调用测试模块上的所有test_方法,检查突变体是否被发现,并返回结果。

from ExpectError import ExpectTimeout 
class MutantTestRunner:
    def __init__(self, mutant, test_module):
        self.mutant = mutant
        self.tm = test_module

    def runTest(self, tc):
        suite = unittest.TestSuite()
        test_class = self.tm.__dict__[tc]
        for f in test_class.__dict__:
            if f.startswith('test_'):
                suite.addTest(test_class(f))
        runner = unittest.TextTestRunner(verbosity=0, failfast=True)
        try:
            with ExpectTimeout(1):
                res = runner.run(suite)
                if res.wasSuccessful():
                    self.mutant.pm.un_detected.add(self)
                return res
        except SyntaxError:
            print('Syntax Error (%s)' % self.mutant.name)
            return None
        raise Exception('Unhandled exception during test execution') 

突变分数是通过score()函数计算的。

class MuProgramAnalyzer(MuProgramAnalyzer):
    def score(self):
        return (self.nmutations - len(self.un_detected)) / self.nmutations 

下面是如何使用我们的框架。

import [sys](https://docs.python.org/3/library/sys.html) 
test_module = sys.modules[__name__]
for mutant in MuProgramAnalyzer('shape', shape_src):
    mutant[test_module].runTest('WeakShapeTest')
mutant.pm.score() 
======================================================================
FAIL: test_equilateral (__main__.WeakShapeTest.test_equilateral)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/511514204.py", line 3, in test_equilateral
    assert shape.triangle(1, 1, 1) == 'Equilateral'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

0.2

WeakShape测试套件只产生了20%的突变分数。

for mutant in MuProgramAnalyzer('shape', shape_src):
    mutant[test_module].runTest('StrongShapeTest')
mutant.pm.score() 
======================================================================
FAIL: test_equilateral (__main__.StrongShapeTest.test_equilateral)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2670057129.py", line 4, in test_equilateral
    assert shape.triangle(1, 1, 1) == 'Equilateral'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_isosceles (__main__.StrongShapeTest.test_isosceles)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2670057129.py", line 8, in test_isosceles
    assert shape.triangle(2, 2, 1) == 'Isosceles'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_isosceles (__main__.StrongShapeTest.test_isosceles)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2670057129.py", line 9, in test_isosceles
    assert shape.triangle(1, 2, 2) == 'Isosceles'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_isosceles (__main__.StrongShapeTest.test_isosceles)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2670057129.py", line 7, in test_isosceles
    assert shape.triangle(1, 2, 1) == 'Isosceles'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=1)
======================================================================
FAIL: test_scalene (__main__.StrongShapeTest.test_scalene)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2670057129.py", line 12, in test_scalene
    assert shape.triangle(1, 2, 3) == 'Scalene'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1)

1.0

另一方面,我们能够使用StrongShapeTest测试套件实现100%的突变分数。

这里是另一个例子,gcd()

gcd_src = inspect.getsource(gcd) 
class TestGCD(unittest.TestCase):
    def test_simple(self):
        assert cfg.gcd(1, 0) == 1

    def test_mirror(self):
        assert cfg.gcd(0, 1) == 1 
for mutant in MuProgramAnalyzer('cfg', gcd_src):
    mutant[test_module].runTest('TestGCD')
mutant.pm.score() 
======================================================================
ERROR: test_mirror (__main__.TestGCD.test_mirror)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 6, in test_mirror
    assert cfg.gcd(0, 1) == 1
           ^^^^^^^^^^^^^
  File "<string>", line 5, in gcd
UnboundLocalError: cannot access local variable 'c' where it is not associated with a value

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
======================================================================
FAIL: test_mirror (__main__.TestGCD.test_mirror)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 6, in test_mirror
    assert cfg.gcd(0, 1) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=1)
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
======================================================================
FAIL: test_simple (__main__.TestGCD.test_simple)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_7987/2565918356.py", line 3, in test_simple
    assert cfg.gcd(1, 0) == 1
           ^^^^^^^^^^^^^^^^^^
AssertionError

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)

0.42857142857142855

我们看到我们的TestGCD测试套件能够获得42%的突变分数。

等价突变体的问题

突变分析的一个问题在于,并非所有生成的突变体都需要是错误的。例如,考虑下面的new_gcd()程序。

def new_gcd(a, b):
    if a < b:
        a, b = b, a
    else:
        a, b = a, b

    while b != 0:
        a, b = b, a % b
    return a 

这个程序可以被突变以产生以下突变体。

def mutated_gcd(a, b):
    if a < b:
        a, b = b, a
    else:
        pass

    while b != 0:
        a, b = b, a % b
    return a 
for i, mutant in enumerate(MuFunctionAnalyzer(new_gcd)):
    print(i,mutant.src()) 
0 def new_gcd(a, b):
    if a < b:
        pass
    else:
        a, b = (a, b)
    while b != 0:
        a, b = (b, a % b)
    return a
1 def new_gcd(a, b):
    if a < b:
        a, b = (b, a)
    else:
        pass
    while b != 0:
        a, b = (b, a % b)
    return a
2 def new_gcd(a, b):
    if a < b:
        a, b = (b, a)
    else:
        a, b = (a, b)
    while b != 0:
        pass
    return a
3 def new_gcd(a, b):
    if a < b:
        a, b = (b, a)
    else:
        a, b = (a, b)
    while b != 0:
        a, b = (b, a % b)
    pass

与原始版本相比,其他突变体可能是错误的,但mutant 1在语义上与原始版本不可区分,因为它删除了一个无关紧要的赋值。这意味着mutant 1不代表错误。这类不代表错误的突变体被称为等价突变体。等价突变体的问题在于,在存在等价突变体的情况下,判断突变分数变得非常困难。例如,在 70%的突变分数下,从 0 到 30%的突变体可能是等价的。因此,不知道实际等价突变体的数量,就无法判断测试可以改进多少。我们讨论了两种处理等价突变体的方法。

等价突变体数量的统计估计

如果存活的突变体数量足够少,人们可能可以简单地手动检查它们。然而,如果突变体的数量足够大(比如说 > 1000),人们可以从存活的突变体中随机选择较少的数量并手动评估它们,以查看它们是否代表错误。样本大小的确定由以下二项分布公式(通过正态分布近似)控制:

\[n \ge \hat{p}(1-\hat{p})\bigg(\frac{Z_{\frac{\alpha}{2}}}{\Delta}\bigg)² \]

其中 \(n\) 是样本数量,\(p\) 是概率分布的参数,\(\alpha\) 是所需的精度,\(\Delta\) 是精度。对于 95% 的精度,\(Z_{0.95}=1.96\)。我们有以下值(\(\hat{p}(1-\hat{p})\) 的最大值为 0.25)和 \(Z\) 是正态分布的临界值:

\[n \ge 0.25\bigg(\frac{1.96}{\Delta}\bigg)² \]

对于 \(\Delta = 0.01\)(即最大误差为 1%),我们需要评估 \(9604\) 个突变体以确定等效性。如果将约束放宽到 \(\Delta = 0.1\)(即误差为 10%),那么只需要评估 \(96\) 个突变体以确定等效性。

Chao 估计器对不死突变体数量的统计估计

虽然仅采样有限数量的突变体的想法很有吸引力,但它仍然有限,因为需要手动分析。如果计算能力便宜,另一种通过 Chao 估计器估计真实突变体数量(因此是等效突变体数量)的方法是通过 Chao 估计器。正如我们将在关于 何时停止模糊测试 的章节中看到的那样,公式如下:

\[\hat S_\text{Chao1} = \begin{cases} S(n) + \frac{f_1²}{2f_2} & \text{if } f_2>0\\ S(n) + \frac{f_1(f_1-1)}{2} & \text{otherwise} \end{cases} \]

基本思想是计算每个测试对每个突变体的完整测试矩阵 \(T \times M\) 的结果。变量 \(f_1\) 表示恰好被杀死一次的突变体数量,而变量 \(f_2\) 表示恰好被杀死两次的变量数量。\(S(n)\) 是被杀死的突变体总数。在这里,\(\hat{S}_{Chao1}\) 提供了真实突变体数量的估计。如果 \(M\) 是生成的总突变体数量,那么 \(M - \hat{S}_{Chao1}\) 表示 不死 突变体的数量。请注意,这些 不死 突变体与传统等效突变体有些不同,因为 死亡率 取决于用于区分变异行为的预言者。也就是说,如果使用依赖于抛出错误来检测杀死的模糊器,它将无法检测出产生不同输出但不抛出错误的突变体。因此,Chao1 估计将基本上是模糊器在给定无限时间的情况下可以检测到的突变体的渐近值。当使用的预言者足够强大时,不死 突变体估计将接近真实的 等效 突变体估计。更多细节请参阅关于 何时停止模糊测试 的章节。测试中物种发现的全面指南是 Boehme 等人于 2018 年发表的论文 [Böhme 等人,2018]。

经验教训

  • 我们已经了解到为什么结构覆盖率不足以评估测试套件的质量。

  • 我们已经了解到如何使用突变分析来评估测试套件的质量。

  • 我们已经了解到突变分析的局限性——等效和冗余突变体,以及如何估计它们。

下一步

  • 虽然简单的模糊测试生成质量较差的预言机,但诸如符号和条件等技术可以提高模糊测试中使用的预言机的质量。

  • 动态不变量也可以在提高预言机质量方面大有帮助。

  • 关于何时停止模糊测试的章节提供了 Chao 估计器的详细概述。

背景

突变分析的想法最初由 Lipton 等人提出[Lipton et al, 1971]。Jia 等人发表了一篇关于突变分析研究的优秀调查[Jia et al, 2011]。Papadakis 等人关于突变分析的章节[Papadakis et al, 2019]也是对当前突变分析趋势的另一个优秀概述。

练习

练习 1:算术表达式突变器

我们简单的语句删除突变只是程序可能突变的一种方式。另一类突变体是表达式突变,其中算术运算符(如{+,-,*,/}等)相互替换。例如,给定一个表达式如下

x = x + 1

可以将其突变成

x = x - 1

x = x * 1

x = x / 1

首先,我们需要找出我们想要突变的节点类型。我们通过 ast 函数获取这些信息,并发现节点类型被命名为 BinOp

print(ast.dump(ast.parse("1 + 2 - 3 * 4 / 5"), indent=4)) 
Module(
    body=[
        Expr(
            value=BinOp(
                left=BinOp(
                    left=Constant(value=1),
                    op=Add(),
                    right=Constant(value=2)),
                op=Sub(),
                right=BinOp(
                    left=BinOp(
                        left=Constant(value=3),
                        op=Mult(),
                        right=Constant(value=4)),
                    op=Div(),
                    right=Constant(value=5))))],
    type_ignores=[])

要突变树,因此你需要更改op属性(它具有AddSubMultDiv等值之一)

使用笔记本来练习并查看解决方案。

要突变树,我们需要更改op属性(它具有AddSubMultDiv等值之一)。编写一个BinOpMutator类来完成必要的突变,然后创建一个MuBinOpAnalyzer类,它是MuFunctionAnalyzer的子类,并利用BinOpMutator

使用笔记本来练习并查看解决方案。

练习 2:优化突变分析

我们进行突变分析的技巧在效率上有些低,因为我们在测试那些在测试用例未覆盖的代码中的突变体时也会运行测试。测试用例没有检测它们未覆盖的代码部分错误的可能性。因此,最简单的优化之一是首先从给定的测试用例中恢复覆盖率信息,并且只对那些突变位于测试用例覆盖的代码中的突变体运行测试用例。你能修改MuFunctionAnalyzer以将恢复覆盖率作为第一步吗?

使用笔记本来练习并查看解决方案。

练习 3:字节码突变器

我们已经看到了如何根据源代码进行突变。这种方法的一个缺点是 Python 字节码也被其他语言针对。在这种情况下,源代码可能无法直接转换为 Python AST,而突变字节码是更可取的。你能实现一个针对 Python 函数的字节码突变器,该突变器直接突变字节码而不是先获取源代码然后进行突变吗?

使用笔记本来处理练习并查看解决方案。

练习 4:估计残留缺陷密度

程序的缺陷密度是指程序在发布前检测到的缺陷数量除以程序大小。残留缺陷密度是逃逸检测的缺陷百分比。虽然估计实际的残留缺陷密度很困难,但突变分析可以提供一个上限。未检测到的突变数量是程序内剩余缺陷数量的一个合理的上限。然而,这个上限可能太宽。原因是某些剩余的错误可能相互影响,如果同时存在,可能被现有的测试套件检测到。因此,一个更紧的上限是在给定程序中可以存在而不会被给定测试套件检测到的突变数量。这可以通过从可能的完整突变集开始,并应用来自减少章节的 delta-debugging 来确定需要移除的最小突变数量来实现,以使突变通过测试套件不被检测到。你能通过扩展MuFunctionAnalyzer来生成一个新的RDDEstimator,该RDDEstimator使用这种技术估计残留缺陷密度的上限吗?

使用笔记本来处理练习并查看解决方案。

Creative Commons License 本项目的内容受Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议的许可。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,受MIT 许可协议的许可。 最后更改:2023-11-11 18:18:06+01:00 • 引用 • 印记

如何引用本作品

安德烈亚斯·策勒,拉胡尔·戈皮纳特,马塞尔·博 hme,戈登·弗莱泽,以及克里斯蒂安·霍勒:"突变分析"。收录于安德烈亚斯·策勒,拉胡尔·戈皮纳特,马塞尔·博 hme,戈登·弗莱泽,以及克里斯蒂安·霍勒所著的"模糊测试书"中。www.fuzzingbook.org/html/MutationAnalysis.html。检索日期:2023-11-11 18:18:06+01:00.

@incollection{fuzzingbook2023:MutationAnalysis,
    author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
    booktitle = {The Fuzzing Book},
    title = {Mutation Analysis},
    year = {2023},
    publisher = {CISPA Helmholtz Center for Information Security},
    howpublished = {\url{https://www.fuzzingbook.org/html/MutationAnalysis.html}},
    note = {Retrieved 2023-11-11 18:18:06+01:00},
    url = {https://www.fuzzingbook.org/html/MutationAnalysis.html},
    urldate = {2023-11-11 18:18:06+01:00}
}

第三部分:句法模糊测试

原文:www.fuzzingbook.org/html/03_Syntactical_Fuzzing.html

本部分介绍了在句法级别的测试生成,即从语言结构中组合输入。

  • 语法提供了程序合法输入的规范。通过语法指定输入可以实现非常系统和高效的测试生成,特别是对于复杂的输入格式。

  • 高效语法模糊测试介绍了基于树的语法模糊测试算法,这些算法速度更快,允许对模糊输入的生产有更多的控制。

  • 语法覆盖率允许系统地覆盖语法的元素,从而最大化多样性,不会遗漏任何单个元素。

  • 解析输入展示了如何使用语法解析和分解一组有效的种子输入到它们对应的推导树。

  • 概率语法模糊测试通过为单个扩展分配概率,使语法拥有更大的能力。

  • 使用生成器进行模糊测试展示了如何通过函数扩展语法——这些代码片段在语法扩展期间执行,可以生成、检查或更改生成的元素。

  • 灰盒语法模糊测试利用结构表示,使我们能够突变、交叉和重组它们的部分,以生成新的有效、略微改变的输入。

  • 减少导致失败的输入介绍了自动将导致失败的输入减少和简化到最小,以减轻调试难度的技术。

Creative Commons License 本项目的内容根据Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议授权。作为内容一部分的源代码,以及用于格式化和显示该内容的源代码,根据MIT 许可协议授权。 最后更改:2023-10-16 19:18:09+02:00 • 引用 • 印记

如何引用本作品

Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, and Christian Holler: "第三部分:句法模糊测试". In Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, and Christian Holler, "The Fuzzing Book", www.fuzzingbook.org/html/03_Syntactical_Fuzzing.html. Retrieved 2023-10-16 19:18:09+02:00.

@incollection{fuzzingbook2023:03_Syntactical_Fuzzing,
    author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
    booktitle = {The Fuzzing Book},
    title = {Part III: Syntactic Fuzzing},
    year = {2023},
    publisher = {CISPA Helmholtz Center for Information Security},
    howpublished = {\url{https://www.fuzzingbook.org/html/03_Syntactical_Fuzzing.html}},
    note = {Retrieved 2023-10-16 19:18:09+02:00},
    url = {https://www.fuzzingbook.org/html/03_Syntactical_Fuzzing.html},
    urldate = {2023-10-16 19:18:09+02:00}
}

使用语法进行模糊测试

原文:www.fuzzingbook.org/html/Grammars.html

在"基于变异的模糊测试"章节中,我们已经看到了如何使用额外的提示——例如示例输入文件——来加速测试生成。在本章中,我们进一步发展了这个想法,通过提供程序合法输入的规范。通过语法指定输入允许非常系统且高效地生成测试,特别是对于复杂的输入格式。语法还作为配置模糊测试、API 模糊测试、GUI 模糊测试以及更多的基础。

from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import YouTubeVideo
YouTubeVideo('Jc8Whz0W41o') 

先决条件

  • 你应该了解基本的模糊测试是如何工作的,例如从介绍模糊测试的章节中了解。

  • 对于基于变异的模糊测试和覆盖率的知识目前不需要,但仍然推荐。

import [bookutils.setup](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) 
from [typing](https://docs.python.org/3/library/typing.html) import List, Dict, Union, Any, Tuple, Optional 
import Fuzzer 

概述

要使用本章提供的代码,请编写

>>> from fuzzingbook.Grammars import <identifier> 

然后利用以下功能。

本章介绍了语法作为指定输入语言的一种简单方法,以及如何使用它们对具有语法有效输入的程序进行测试。语法被定义为非终结符号到一系列替代扩展的映射,如下例所示:

>>> US_PHONE_GRAMMAR: Grammar = {
>>>     "<start>": ["<phone-number>"],
>>>     "<phone-number>": ["(<area>)<exchange>-<line>"],
>>>     "<area>": ["<lead-digit><digit><digit>"],
>>>     "<exchange>": ["<lead-digit><digit><digit>"],
>>>     "<line>": ["<digit><digit><digit><digit>"],
>>>     "<lead-digit>": ["2", "3", "4", "5", "6", "7", "8", "9"],
>>>     "<digit>": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
>>> }
>>> 
>>> assert is_valid_grammar(US_PHONE_GRAMMAR) 

非终结符号用尖括号括起来(例如,<digit>)。要从语法生成一个输入字符串,一个生成器从起始符号(<start>)开始,并随机选择这个符号的随机扩展。它继续这个过程,直到所有非终结符号都被扩展。simple_grammar_fuzzer()函数正是这样做的:

>>> [simple_grammar_fuzzer(US_PHONE_GRAMMAR) for i in range(5)]
['(692)449-5179',
 '(519)230-7422',
 '(613)761-0853',
 '(979)881-3858',
 '(810)914-5475'] 

实际上,虽然你应该使用 GrammarFuzzer 类或其基于覆盖率的、基于概率的或基于生成器的衍生类之一,而不是simple_grammar_fuzzer();这些更高效,可以防止无限增长,并提供几个额外的功能。

本章还介绍了一个语法工具箱,其中包含几个辅助函数,这些函数可以简化语法的编写,例如使用字符类和重复的快捷符号,或扩展语法。

输入语言

程序的所有可能行为都可以通过其输入触发。"输入"在这里可以是一系列可能的来源:我们谈论的是从文件、环境或网络中读取的数据,用户输入的数据,或从与其他资源的交互中获得的数据。所有这些输入的集合决定了程序将如何表现——包括其失败。在测试时,考虑可能的输入来源、如何控制它们以及如何系统地测试它们是非常有帮助的。

为了简化起见,我们目前假设程序只有一个输入源;这也是我们在前几章中使用的相同假设。程序的有效输入集合被称为语言。语言的范围从简单到复杂:CSV 语言表示有效的逗号分隔输入集合,而 Python 语言表示有效的 Python 程序集合。我们通常将数据语言和编程语言分开,尽管任何程序也可以被视为输入数据(例如,用于编译器)。维基百科上的文件格式页面列出了超过 1,000 种不同的文件格式,每种格式都是其自己的语言。

为了正式描述语言,形式语言领域已经设计了一系列语言规范,这些规范描述了一种语言。正则表达式代表了这些语言中最简单的一类,用于表示字符串集合:例如,正则表达式[a-z]*表示一个(可能为空)的小写字母序列。自动机理论将这些语言与接受这些输入的自动机联系起来;例如,有限状态机可以用来指定正则表达式的语言。

正则表达式非常适合不太复杂的输入格式,与之相关的有限状态机也具有许多使它们非常适合推理的性质。然而,为了指定更复杂的输入,它们很快就会遇到限制。在语言谱的另一端,我们有通用语法,它表示由图灵机接受的语言。图灵机可以计算任何可以计算的东西;由于 Python 是图灵完备的,这意味着我们也可以使用 Python 程序\(p\)来指定或甚至枚举合法的输入。但是,计算机科学理论也告诉我们,每个这样的测试程序都必须为要测试的程序专门编写,这不是我们想要的自动化水平。

语法

正则表达式和图灵机之间的中间地带由语法覆盖。语法是正式指定输入语言中最受欢迎(且理解最好)的形式化方法之一。使用语法,可以表达输入语言的各种属性。语法特别适合表达输入的句法结构,并且是表达嵌套或递归输入的首选形式化方法。我们使用的语法是所谓的上下文无关语法,这是一种最容易且最受欢迎的语法形式化方法。

规则和扩展

语法由一个起始符号和一组扩展规则(或简单地称为规则)组成,这些规则表明起始符号(和其他符号)如何进行扩展。例如,考虑以下语法,表示两个数字的序列:

<start> ::= <digit><digit>
<digit> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

读取这样的语法时,从起始符号(<start>)开始。一个展开规则<A> ::= <B>意味着左侧的符号(<A>)可以被右侧的字符串(<B>)替换。在上面的语法中,<start>将被替换为<digit><digit>

在这个字符串中,<digit>将被替换为<digit>规则右侧的字符串。特殊运算符|表示展开替代项(或简称为替代项),意味着可以选择任何数字进行展开。因此,每个<digit>将被展开为给定的数字之一,最终得到一个介于0099之间的字符串。对于09没有进一步的展开,所以我们已经准备好了。

关于语法的有趣之处在于它们可以是递归的。也就是说,展开可以使用之前展开的符号——然后这些符号将被再次展开。作为一个例子,考虑一个描述整数的语法:

<start>  ::= <integer>
<integer> ::= <digit> | <digit><integer>
<digit>   ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

在这里,一个<integer>要么是一个单独的数字,要么是一个数字后面跟着另一个整数。因此,数字1234将被表示为一个单独的数字1,后面跟着整数234,这个整数又是一个数字2,后面跟着整数34

如果我们想要表达一个整数可以由一个符号(+-) precede,我们就会把语法写成

<start>   ::= <number>
<number>  ::= <integer> | +<integer> | -<integer>
<integer> ::= <digit> | <digit><integer>
<digit>   ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

这些规则正式定义了语言:可以从起始符号推导出的任何内容都是语言的一部分;不能推导出的则不是。

from [bookutils](https://github.com/uds-se/fuzzingbook//tree/master/notebooks/shared/bookutils) import quiz 

习题

哪些字符串不能由上述<start>符号产生?

算术表达式

让我们扩展我们的语法以涵盖完整的算术表达式——这是一个语法的典型示例。我们看到一个表达式(<expr>)要么是一个和,要么是一个差,要么是一个项;一个项要么是一个乘法,要么是一个除法,要么是一个因子;一个因子要么是一个数字,要么是一个括号表达式。几乎所有的规则都可以有递归性,从而允许任意复杂的表达式,例如(1 + 2) * (3.4 / 5.6 - 789)

<start>   ::= <expr>
<expr>    ::= <term> + <expr> | <term> - <expr> | <term>
<term>    ::= <term> * <factor> | <term> / <factor> | <factor>
<factor>  ::= +<factor> | -<factor> | (<expr>) | <integer> | <integer>.<integer>
<integer> ::= <digit><integer> | <digit>
<digit>   ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

在这样的语法中,如果我们从<start>开始,然后逐个展开符号,随机选择替代项,我们可以快速产生一个有效的算术表达式,然后是另一个。这种语法模糊化在产生复杂输入时非常有效,这正是我们将在本章中实现的。

习题

哪些字符串不能由上述<start>符号产生?

在 Python 中表示语法

构建语法模糊器时的第一步是找到一个合适的语法格式。为了使语法的编写尽可能简单,我们使用基于字符串和列表的格式。我们的 Python 语法采用符号名称和展开之间的映射格式,其中展开是列表形式的替代项。因此,一个用于数字的单规则语法具有以下形式

DIGIT_GRAMMAR = {
    "<start>":
        ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
} 
一个`Grammar`类型

让我们定义一个语法类型,以便我们可以静态地检查语法类型。

语法类型的第一次尝试可能是这样的:每个符号(字符串)映射到一个展开列表(字符串):

SimpleGrammar = Dict[str, List[str]] 

然而,我们的 opts() 功能,用于添加可选属性,我们将在本章后面介绍,也允许展开成为由字符串和选项组成的配对,其中选项是将字符串映射到值的映射:

Option = Dict[str, Any] 

因此,一个展开要么是一个字符串——要么是一个字符串和选项的配对。

Expansion = Union[str, Tuple[str, Option]] 

使用这个,我们现在可以定义一个 Grammar,它将字符串映射到 Expansion 列表。

我们可以在 Grammar 类型中捕获语法结构,其中每个符号(字符串)映射到一个展开列表(字符串):

Grammar = Dict[str, List[Expansion]] 

使用这种 Grammar 类型,算术表达式的完整语法看起来是这样的:

EXPR_GRAMMAR: Grammar = {
    "<start>":
        ["<expr>"],

    "<expr>":
        ["<term> + <expr>", "<term> - <expr>", "<term>"],

    "<term>":
        ["<factor> * <term>", "<factor> / <term>", "<factor>"],

    "<factor>":
        ["+<factor>",
         "-<factor>",
         "(<expr>)",
         "<integer>.<integer>",
         "<integer>"],

    "<integer>":
        ["<digit><integer>", "<digit>"],

    "<digit>":
        ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
} 

在语法中,每个符号只能定义一次。我们可以通过其符号访问任何规则...

EXPR_GRAMMAR["<digit>"] 
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

...并且我们可以检查一个符号是否在语法中:

"<identifier>" in EXPR_GRAMMAR 
False

注意,我们假设规则左侧(即映射中的键)始终是单个符号。这是赋予我们的语法以 上下文无关 特性的属性。

一些定义

我们假设规范起始符号是 <start>

START_SYMBOL = "<start>" 

方便的 nonterminals() 函数可以从一个展开中提取非终结符符号列表(即,<> 之间的任何内容,除了空格)。

import [re](https://docs.python.org/3/library/re.html) 
RE_NONTERMINAL = re.compile(r'(<[^<> ]*>)') 
def nonterminals(expansion):
    # In later chapters, we allow expansions to be tuples,
    # with the expansion being the first element
    if isinstance(expansion, tuple):
        expansion = expansion[0]

    return RE_NONTERMINAL.findall(expansion) 
assert nonterminals("<term> * <factor>") == ["<term>", "<factor>"]
assert nonterminals("<digit><integer>") == ["<digit>", "<integer>"]
assert nonterminals("1 < 3 > 2") == []
assert nonterminals("1 <3> 2") == ["<3>"]
assert nonterminals("1 + 2") == []
assert nonterminals(("<1>", {'option': 'value'})) == ["<1>"] 

同样,is_nonterminal() 检查某个符号是否是非终结符:

def is_nonterminal(s):
    return RE_NONTERMINAL.match(s) 
assert is_nonterminal("<abc>")
assert is_nonterminal("<symbol-1>")
assert not is_nonterminal("+") 

简单语法模糊器

让我们现在使用上述语法。我们将构建一个非常简单的语法模糊器,它从起始符号(<start>)开始,然后不断展开它。为了避免无限输入的展开,我们对非终结符的数量(max_nonterminals)设置了一个限制。此外,为了避免陷入无法进一步减少符号数量的情况,我们还限制了总的展开步骤数。

import [random](https://docs.python.org/3/library/random.html) 
class ExpansionError(Exception):
    pass 
def simple_grammar_fuzzer(grammar: Grammar, 
                          start_symbol: str = START_SYMBOL,
                          max_nonterminals: int = 10,
                          max_expansion_trials: int = 100,
                          log: bool = False) -> str:
  """Produce a string from `grammar`.
 `start_symbol`: use a start symbol other than `<start>` (default).
 `max_nonterminals`: the maximum number of nonterminals 
 still left for expansion
 `max_expansion_trials`: maximum # of attempts to produce a string
 `log`: print expansion progress if True"""

    term = start_symbol
    expansion_trials = 0

    while len(nonterminals(term)) > 0:
        symbol_to_expand = random.choice(nonterminals(term))
        expansions = grammar[symbol_to_expand]
        expansion = random.choice(expansions)
        # In later chapters, we allow expansions to be tuples,
        # with the expansion being the first element
        if isinstance(expansion, tuple):
            expansion = expansion[0]

        new_term = term.replace(symbol_to_expand, expansion, 1)

        if len(nonterminals(new_term)) < max_nonterminals:
            term = new_term
            if log:
                print("%-40s" % (symbol_to_expand + " -> " + expansion), term)
            expansion_trials = 0
        else:
            expansion_trials += 1
            if expansion_trials >= max_expansion_trials:
                raise ExpansionError("Cannot expand " + repr(term))

    return term 

让我们看看这个简单的语法模糊器是如何从起始符号获得算术表达式的:

simple_grammar_fuzzer(grammar=EXPR_GRAMMAR, max_nonterminals=3, log=True) 
<start> -> <expr>                        <expr>
<expr> -> <term> + <expr>                <term> + <expr>
<term> -> <factor>                       <factor> + <expr>
<factor> -> <integer>                    <integer> + <expr>
<integer> -> <digit>                     <digit> + <expr>
<digit> -> 6                             6 + <expr>
<expr> -> <term> - <expr>                6 + <term> - <expr>
<expr> -> <term>                         6 + <term> - <term>
<term> -> <factor>                       6 + <factor> - <term>
<factor> -> -<factor>                    6 + -<factor> - <term>
<term> -> <factor>                       6 + -<factor> - <factor>
<factor> -> (<expr>)                     6 + -(<expr>) - <factor>
<factor> -> (<expr>)                     6 + -(<expr>) - (<expr>)
<expr> -> <term>                         6 + -(<term>) - (<expr>)
<expr> -> <term>                         6 + -(<term>) - (<term>)
<term> -> <factor>                       6 + -(<factor>) - (<term>)
<factor> -> +<factor>                    6 + -(+<factor>) - (<term>)
<factor> -> +<factor>                    6 + -(++<factor>) - (<term>)
<term> -> <factor>                       6 + -(++<factor>) - (<factor>)
<factor> -> (<expr>)                     6 + -(++(<expr>)) - (<factor>)
<factor> -> <integer>                    6 + -(++(<expr>)) - (<integer>)
<expr> -> <term>                         6 + -(++(<term>)) - (<integer>)
<integer> -> <digit>                     6 + -(++(<term>)) - (<digit>)
<digit> -> 9                             6 + -(++(<term>)) - (9)
<term> -> <factor> * <term>              6 + -(++(<factor> * <term>)) - (9)
<term> -> <factor>                       6 + -(++(<factor> * <factor>)) - (9)
<factor> -> <integer>                    6 + -(++(<integer> * <factor>)) - (9)
<integer> -> <digit>                     6 + -(++(<digit> * <factor>)) - (9)
<digit> -> 2                             6 + -(++(2 * <factor>)) - (9)
<factor> -> +<factor>                    6 + -(++(2 * +<factor>)) - (9)
<factor> -> -<factor>                    6 + -(++(2 * +-<factor>)) - (9)
<factor> -> -<factor>                    6 + -(++(2 * +--<factor>)) - (9)
<factor> -> -<factor>                    6 + -(++(2 * +---<factor>)) - (9)
<factor> -> -<factor>                    6 + -(++(2 * +----<factor>)) - (9)
<factor> -> <integer>.<integer>          6 + -(++(2 * +----<integer>.<integer>)) - (9)
<integer> -> <digit>                     6 + -(++(2 * +----<digit>.<integer>)) - (9)
<integer> -> <digit>                     6 + -(++(2 * +----<digit>.<digit>)) - (9)
<digit> -> 1                             6 + -(++(2 * +----1.<digit>)) - (9)
<digit> -> 7                             6 + -(++(2 * +----1.7)) - (9)

'6 + -(++(2 * +----1.7)) - (9)'

通过增加非终结符的限制,我们可以快速得到更长的产生式:

for i in range(10):
    print(simple_grammar_fuzzer(grammar=EXPR_GRAMMAR, max_nonterminals=5)) 
7 / +48.5
-5.9 / 9 - 4 * +-(-+++((1 + (+7 - (-1 * (++-+7.7 - -+-4.0))))) * +--4 - -(6) + 64)
8.2 - 27 - -9 / +((+9 * --2 + --+-+-((-1 * +(8 - 5 - 6)) * (-((-+(((+(4))))) - ++4) / +(-+---((5.6 - --(3 * -1.8 * +(6 * +-(((-(-6) * ---+6)) / +--(+-+-7 * (-0 * (+(((((2)) + 8 - 3 - ++9.0 + ---(--+7 / (1 / +++6.37) + (1) / 482) / +++-+0)))) * -+5 + 7.513)))) - (+1 / ++((-84)))))))) * ++5 / +-(--2 - -++-9.0)))) / 5 * --++090
1 - -3 * 7 - 28 / 9
(+9) * +-5 * ++-926.2 - (+9.03 / -+(-(-6) / 2 * +(-+--(8) / -(+1.0) - 5 + 4)) * 3.5)
8 + -(9.6 - 3 - -+-4 * +77)
-(((((++((((+((++++-((+-37))))))))))))) / ++(-(+++(+6)) * -++-(+(++(---6 * (((7)) * (1) / (-7.6 * 535338) + +256) * 0) * 0))) - 4 + +1
5.43
(9 / -405 / -23 - +-((+-(2 * (13))))) + +6 - +8 - 934
-++2 - (--+715769550) / 8 / (1)

注意,虽然我们的模糊器在大多数情况下都能完成任务,但它有一些缺点。

问答

simple_grammar_fuzzer() 有哪些缺点?

事实上,simple_grammar_fuzzer() 由于搜索和替换操作的数量庞大,效率相当低,甚至可能无法生成字符串。另一方面,实现简单,在大多数情况下都能完成任务。对于本章,我们将坚持使用它;在下一章中,我们将展示如何构建一个更高效的版本。

将语法可视化成铁路图

使用语法,我们可以轻松地指定我们之前讨论的几个示例的格式。例如,上述算术表达式可以直接发送到 bc(或任何接受算术表达式的程序)。在我们介绍一些额外的语法之前,让我们提供一种 可视化 语法的方法,以提供另一种辅助理解的观点。

铁路图,也称为 语法图,是上下文无关语法的图形表示。它们从左到右读取,遵循可能的“铁路”轨道;轨道上遇到的符号序列定义了语言。为了生成铁路图,我们实现了一个名为 syntax_diagram() 的函数。

实现 `syntax_diagram()`

我们使用 RailroadDiagrams,一个用于可视化的外部库。

from RailroadDiagrams import NonTerminal, Terminal, Choice, HorizontalChoice, Sequence
from RailroadDiagrams import show_diagram 
from [IPython.display](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html) import SVG 

我们首先定义了一个名为 syntax_diagram_symbol() 的方法来可视化给定的符号。终结符号用椭圆形表示,而非终结符号(如 <term>)用矩形表示。

def syntax_diagram_symbol(symbol: str) -> Any:
    if is_nonterminal(symbol):
        return NonTerminal(symbol[1:-1])
    else:
        return Terminal(symbol) 
SVG(show_diagram(syntax_diagram_symbol('<term>'))) 

term

我们定义 syntax_diagram_expr() 来可视化扩展替代方案。

def syntax_diagram_expr(expansion: Expansion) -> Any:
    # In later chapters, we allow expansions to be tuples,
    # with the expansion being the first element
    if isinstance(expansion, tuple):
        expansion = expansion[0]

    symbols = [sym for sym in re.split(RE_NONTERMINAL, expansion) if sym != ""]
    if len(symbols) == 0:
        symbols = [""]  # special case: empty expansion

    return Sequence(*[syntax_diagram_symbol(sym) for sym in symbols]) 
SVG(show_diagram(syntax_diagram_expr(EXPR_GRAMMAR['<term>'][0]))) 

factor * term

这是 <term> 的第一个替代方案——一个 <factor> 后跟 * 和一个 <term>

接下来,我们定义 syntax_diagram_alt() 来显示替代表达式。

from [itertools](https://docs.python.org/3/library/itertools.html) import zip_longest 
def syntax_diagram_alt(alt: List[Expansion]) -> Any:
    max_len = 5
    alt_len = len(alt)
    if alt_len > max_len:
        iter_len = alt_len // max_len
        alts = list(zip_longest(*[alt[i::iter_len] for i in range(iter_len)]))
        exprs = [[syntax_diagram_expr(expr) for expr in alt
                  if expr is not None] for alt in alts]
        choices = [Choice(len(expr) // 2, *expr) for expr in exprs]
        return HorizontalChoice(*choices)
    else:
        return Choice(alt_len // 2, *[syntax_diagram_expr(expr) for expr in alt]) 
SVG(show_diagram(syntax_diagram_alt(EXPR_GRAMMAR['<digit>']))) 

0 1 2 3 4 5 6 7 8 9

我们可以看到 <digit> 可以是 09 之间的任何单个数字。

最后,我们定义了 syntax_diagram(),它接受一个语法,并显示其规则的语法图。

def syntax_diagram(grammar: Grammar) -> None:
    from [IPython.display](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html) import SVG, display

    for key in grammar:
        print("%s" % key[1:-1])
        display(SVG(show_diagram(syntax_diagram_alt(grammar[key])))) 
```</details>

让我们使用 `syntax_diagram()` 来生成我们的表达式语法的铁路图:

```py
syntax_diagram(EXPR_GRAMMAR) 
start

expr

expr

term + expr term - expr term

term

factor * term factor / term factor

factor

- factor + factor ( expr ) integer . integer integer

integer

digit integer digit

digit

0 1 2 3 4 5 6 7 8 9

这种铁路图表示法在可视化语法结构时非常有用,尤其是对于更复杂的语法。

一些语法

让我们创建(并可视化)一些更多的语法,并使用它们进行模糊测试。

CGI 语法

这里是 覆盖章节 中引入的 cgi_decode() 的语法。

CGI_GRAMMAR: Grammar = {
    "<start>":
        ["<string>"],

    "<string>":
        ["<letter>", "<letter><string>"],

    "<letter>":
        ["<plus>", "<percent>", "<other>"],

    "<plus>":
        ["+"],

    "<percent>":
        ["%<hexdigit><hexdigit>"],

    "<hexdigit>":
        ["0", "1", "2", "3", "4", "5", "6", "7",
            "8", "9", "a", "b", "c", "d", "e", "f"],

    "<other>":  # Actually, could be _all_ letters
        ["0", "1", "2", "3", "4", "5", "a", "b", "c", "d", "e", "-", "_"],
} 
syntax_diagram(CGI_GRAMMAR) 
start

string

string

letter letter string

letter

plus percent other

plus

+

percent

% hexdigit hexdigit

hexdigit

0 1 2 3 4 5 6 7 8 9 a b c d e f

other

0 1 2 3 4 5 a b c d e - _

与 基本模糊测试 或 基于变异的模糊测试 相比,语法可以快速生成各种组合:

for i in range(10):
    print(simple_grammar_fuzzer(grammar=CGI_GRAMMAR, max_nonterminals=10)) 
+%9a
+++%ce+
+_
+%c6c
++
+%cd+5
1%ee
%b9%d5
%96
%57d%42

URL 语法

我们在 CGI 输入中看到的相同属性也适用于更复杂的输入。让我们使用语法生成大量的有效 URL:

URL_GRAMMAR: Grammar = {
    "<start>":
        ["<url>"],
    "<url>":
        ["<scheme>://<authority><path><query>"],
    "<scheme>":
        ["http", "https", "ftp", "ftps"],
    "<authority>":
        ["<host>", "<host>:<port>", "<userinfo>@<host>", "<userinfo>@<host>:<port>"],
    "<host>":  # Just a few
        ["cispa.saarland", "www.google.com", "fuzzingbook.com"],
    "<port>":
        ["80", "8080", "<nat>"],
    "<nat>":
        ["<digit>", "<digit><digit>"],
    "<digit>":
        ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"],
    "<userinfo>":  # Just one
        ["user:password"],
    "<path>":  # Just a few
        ["", "/", "/<id>"],
    "<id>":  # Just a few
        ["abc", "def", "x<digit><digit>"],
    "<query>":
        ["", "?<params>"],
    "<params>":
        ["<param>", "<param>&<params>"],
    "<param>":  # Just a few
        ["<id>=<id>", "<id>=<nat>"],
} 
syntax_diagram(URL_GRAMMAR) 
start

url

url

scheme 😕/ authority path query

scheme

https http ftp ftps

authority

host : port host userinfo @ host userinfo @ host : port

host

cispa.saarland www.google.com fuzzingbook.com

port

80 8080 nat

nat

digit digit digit

digit

0 1 2 3 4 5 6 7 8 9

userinfo

user:password

path

/ / id

id

abc def x digit digit

query

? params

params

param param & params

param

id = id id = nat

再次,在毫秒之内,我们可以生成大量的有效输入。

for i in range(10):
    print(simple_grammar_fuzzer(grammar=URL_GRAMMAR, max_nonterminals=10)) 
https://user:password@cispa.saarland:80/
http://fuzzingbook.com?def=56&x89=3&x46=48&def=def
ftp://cispa.saarland/?x71=5&x35=90&def=abc
https://cispa.saarland:80/def?def=7&x23=abc
https://fuzzingbook.com:80/
https://fuzzingbook.com:80/abc?def=abc&abc=x14&def=abc&abc=2&def=38
ftps://fuzzingbook.com/x87
https://user:password@fuzzingbook.com:6?def=54&x44=abc
http://fuzzingbook.com:80?x33=25&def=8
http://fuzzingbook.com:8080/def

自然语言语法

最后,语法不仅限于 形式语言,如计算机输入,还可以用于生成 自然语言。这是我们用来选择这本书标题的语法:

TITLE_GRAMMAR: Grammar = {
    "<start>": ["<title>"],
    "<title>": ["<topic>: <subtopic>"],
    "<topic>": ["Generating Software Tests", "<fuzzing-prefix>Fuzzing", "The Fuzzing Book"],
    "<fuzzing-prefix>": ["", "The Art of ", "The Joy of "],
    "<subtopic>": ["<subtopic-main>",
                   "<subtopic-prefix><subtopic-main>",
                   "<subtopic-main><subtopic-suffix>"],
    "<subtopic-main>": ["Breaking Software",
                        "Generating Software Tests",
                        "Principles, Techniques and Tools"],
    "<subtopic-prefix>": ["", "Tools and Techniques for "],
    "<subtopic-suffix>": [" for <reader-property> and <reader-property>",
                          " for <software-property> and <software-property>"],
    "<reader-property>": ["Fun", "Profit"],
    "<software-property>": ["Robustness", "Reliability", "Security"],
} 
syntax_diagram(TITLE_GRAMMAR) 
start

title

title

topic : subtopic

topic

Generating Software Tests fuzzing-prefix Fuzzing The Fuzzing Book

fuzzing-prefix

The Art of The Joy of

subtopic

subtopic-main subtopic-prefix subtopic-main subtopic-main subtopic-suffix

subtopic-main

Breaking Software Generating Software Tests Principles, Techniques and Tools

subtopic-prefix

Tools and Techniques for

subtopic-suffix

for reader-property and reader-property for software-property and software-property

reader-property

Fun Profit

software-property

Robustness Reliability Security

from [typing](https://docs.python.org/3/library/typing.html) import Set 
titles: Set[str] = set()
while len(titles) < 10:
    titles.add(simple_grammar_fuzzer(
        grammar=TITLE_GRAMMAR, max_nonterminals=10))
titles 
{'Fuzzing: Generating Software Tests',
 'Fuzzing: Principles, Techniques and Tools',
 'Generating Software Tests: Breaking Software',
 'Generating Software Tests: Breaking Software for Robustness and Robustness',
 'Generating Software Tests: Principles, Techniques and Tools',
 'Generating Software Tests: Principles, Techniques and Tools for Profit and Fun',
 'Generating Software Tests: Tools and Techniques for Principles, Techniques and Tools',
 'The Fuzzing Book: Breaking Software',
 'The Fuzzing Book: Generating Software Tests for Profit and Profit',
 'The Fuzzing Book: Generating Software Tests for Robustness and Robustness'}

(如果你在这里发现存在冗余(“鲁棒性和鲁棒性”):在我们的 基于覆盖的模糊测试章节 中,我们将展示如何只覆盖每个扩展一次。如果你更喜欢某些替代方案,概率语法模糊测试 将为你提供。)

语法作为变异种子

语法的一个非常有用的特性是它们产生的大多数输入都是有效的。从句法的角度来看,输入实际上是始终有效的,因为它们满足给定语法的约束。(当然,首先需要一个有效的语法。)然而,还有一些语义特性在语法中难以表达。例如,对于一个 URL,端口号的范围应该在 1024 到 2048 之间,这在语法中很难写出来。如果必须满足更复杂的约束,很快就会达到语法的表达能力极限。

一种解决方法是给语法附加约束,正如我们将在本书后面讨论的。另一种可能性是将基于语法的模糊测试和基于变异的模糊测试的优点结合起来。想法是使用语法生成的输入作为进一步基于变异的模糊测试的种子。这样,我们不仅可以探索有效的输入,还可以检查有效和无效输入之间的边界。这尤其有趣,因为略微无效的输入可以找到解析错误(这些错误通常很多)。与一般的模糊测试一样,意外的是揭示程序中错误的关键。

要使用我们生成的输入作为种子,我们可以直接将它们输入到之前介绍的变异模糊测试器中:

from MutationFuzzer import MutationFuzzer  # minor dependency 
number_of_seeds = 10
seeds = [
    simple_grammar_fuzzer(
        grammar=URL_GRAMMAR,
        max_nonterminals=10) for i in range(number_of_seeds)]
seeds 
['ftps://user:password@www.google.com:80',
 'http://cispa.saarland/',
 'ftp://www.google.com:42/',
 'ftps://user:password@fuzzingbook.com:39?abc=abc',
 'https://www.google.com?x33=1&x06=1',
 'http://www.google.com:02/',
 'https://user:password@www.google.com/',
 'ftp://cispa.saarland:8080/?abc=abc&def=def&abc=5',
 'http://www.google.com:80/def?def=abc',
 'http://user:password@cispa.saarland/']

m = MutationFuzzer(seeds) 
[m.fuzz() for i in range(20)] 
['ftps://user:password@www.google.com:80',
 'http://cispa.saarland/',
 'ftp://www.google.com:42/',
 'ftps://user:password@fuzzingbook.com:39?abc=abc',
 'https://www.google.com?x33=1&x06=1',
 'http://www.google.com:02/',
 'https://user:password@www.google.com/',
 'ftp://cispa.saarland:8080/?abc=abc&def=def&abc=5',
 'http://www.google.com:80/def?def=abc',
 'http://user:password@cispa.saarland/',
 'Eh4tp:www.coogle.com:80/def?d%f=abc',
 'ftps://}ser:passwod@fuzzingbook.com:9?abc=abc',
 'uftp//cispa.sRaarland:808&0?abc=abc&def=defabc=5',
 'http://user:paswor9d@cispar.saarland/v',
 'ftp://Www.g\x7fogle.cAom:42/',
 'hht://userC:qassMword@cispy.csaarland/',
 'httx://ww.googlecom:80defde`f=ac',
 'htt://cispq.waarlnd/',
 'htFtp\t://cmspa./saarna(md/',
 'ft:/www.google.com:42\x0f']

虽然前 10 次fuzz()调用返回的是种子输入(按设计),但后续的调用又创建了任意的变异。使用MutationCoverageFuzzer而不是MutationFuzzer,我们可以再次通过覆盖率来引导搜索——从而将多个世界的优点结合起来。

语法工具箱

现在我们介绍一些有助于我们编写语法的技巧。

转义

在我们的语法中使用<>来界定非终结符,我们如何实际上表达某些输入应该包含<>呢?答案是简单的:只需为它们引入一个符号。

simple_nonterminal_grammar: Grammar = {
    "<start>": ["<nonterminal>"],
    "<nonterminal>": ["<left-angle><identifier><right-angle>"],
    "<left-angle>": ["<"],
    "<right-angle>": [">"],
    "<identifier>": ["id"]  # for now
} 

simple_nonterminal_grammar中,<left-angle>的展开和<right-angle>的展开都不可能被误认为是非终结符。因此,我们可以生成尽可能多的。

(注意,这并不适用于simple_grammar_fuzzer(),而是适用于我们在下一章中将要介绍的GrammarFuzzer类。)

扩展语法

在本书的进程中,我们经常遇到通过扩展现有语法以添加新功能来创建语法的问题。这种扩展在面向对象编程中非常类似于子类化。

要从一个现有语法\(g\)创建一个新的语法\(g'\),我们首先将\(g\)复制到\(g'\)中,然后添加新的选择和/或添加新符号来扩展现有规则。以下是一个示例,扩展上述nonterminal语法以包含一个更好的标识符规则:

import [copy](https://docs.python.org/3/library/copy.html) 
nonterminal_grammar = copy.deepcopy(simple_nonterminal_grammar)
nonterminal_grammar["<identifier>"] = ["<idchar>", "<identifier><idchar>"]
nonterminal_grammar["<idchar>"] = ['a', 'b', 'c', 'd']  # for now 
nonterminal_grammar 
{'<start>': ['<nonterminal>'],
 '<nonterminal>': ['<left-angle><identifier><right-angle>'],
 '<left-angle>': ['<'],
 '<right-angle>': ['>'],
 '<identifier>': ['<idchar>', '<identifier><idchar>'],
 '<idchar>': ['a', 'b', 'c', 'd']}

由于这种语法的扩展是一个常见的操作,我们引入了一个自定义函数extend_grammar(),它首先复制给定的语法,然后使用 Python 字典的update()方法从字典中更新它:

def extend_grammar(grammar: Grammar, extension: Grammar = {}) -> Grammar:
  """Create a copy of `grammar`, updated with `extension`."""
    new_grammar = copy.deepcopy(grammar)
    new_grammar.update(extension)
    return new_grammar 

这个对 extend_grammar() 的调用将 simple_nonterminal_grammar 扩展到 nonterminal_grammar,就像上面的“手动”示例一样:

nonterminal_grammar = extend_grammar(simple_nonterminal_grammar,
                                     {
                                         "<identifier>": ["<idchar>", "<identifier><idchar>"],
                                         # for now
                                         "<idchar>": ['a', 'b', 'c', 'd']
                                     }
                                     ) 

字符类别

在上述 nonterminal_grammar 中,我们只列出了前几个字母;实际上,手动枚举语法中的所有字母或数字,如 <idchar> ::= 'a' | 'b' | 'c' ... 是一件痛苦的事情。

然而,请记住,语法是程序的一部分,因此也可以通过编程方式构建。我们引入了一个名为 srange() 的函数,该函数构建一个字符串中的字符列表:

import [string](https://docs.python.org/3/library/string.html) 
def srange(characters: str) -> List[Expansion]:
  """Construct a list with all characters in the string"""
    return [c for c in characters] 

如果我们传递它常量 string.ascii_letters,它包含所有 ASCII 字母,srange() 返回所有 ASCII 字母的列表:

string.ascii_letters 
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

srange(string.ascii_letters)[:10] 
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

我们可以在我们的语法中使用这样的常量来快速定义标识符:

nonterminal_grammar = extend_grammar(nonterminal_grammar,
                                     {
                                         "<idchar>": (srange(string.ascii_letters) + 
                                                      srange(string.digits) + 
                                                      srange("-_"))
                                     }
                                     ) 
[simple_grammar_fuzzer(nonterminal_grammar, "<identifier>") for i in range(10)] 
['b', 'd', 'V9', 'x4c', 'YdiEWj', 'c', 'xd', '7', 'vIU', 'QhKD']

短路 crange(start, end) 返回从 start 到(包括)end 的 ASCII 范围内的所有字符列表:

def crange(character_start: str, character_end: str) -> List[Expansion]:
    return [chr(i)
            for i in range(ord(character_start), ord(character_end) + 1)] 

我们可以使用这一点来表示字符范围:

crange('0', '9') 
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

assert crange('a', 'z') == srange(string.ascii_lowercase) 

语法快捷方式

在上述 nonterminal_grammar 中,就像在其他语法中一样,我们必须使用递归来表示字符的重复,即通过引用原始定义:

nonterminal_grammar["<identifier>"] 
['<idchar>', '<identifier><idchar>']

如果我们能够简单地声明一个非终结符应该是一个非空字母序列——例如,如下所示

<identifier> = <idchar>+

其中 + 表示其后符号的非空重复。

在语法中,像 + 这样的运算符经常被用作方便的快捷方式。正式来说,我们的语法采用所谓的巴科斯-诺尔范式,或简称BNF。运算符扩展了所谓的扩展 BNF,或简称EBNF

  • <symbol>? 的形式表示 <symbol> 是可选的——也就是说,它可以出现 0 次或 1 次。

  • <symbol>+ 的形式表示 <symbol> 可以出现 1 次或多次重复。

  • <symbol>* 的形式表示 <symbol> 可以出现 0 次或多次。(换句话说,它是一个可选的重复。)

为了使事情更有趣,我们希望使用括号与上述快捷方式一起使用。因此,(<foo><bar>)? 表示 <foo><bar> 的序列是可选的。

使用这样的运算符,我们可以以更简单的方式定义标识符规则。为此,让我们创建原始语法的副本并修改 <identifier> 规则:

nonterminal_ebnf_grammar = extend_grammar(nonterminal_grammar,
                                          {
                                              "<identifier>": ["<idchar>+"]
                                          }
                                          ) 

同样,我们也可以简化表达式语法。考虑符号是可选的,以及整数可以表示为数字序列。

EXPR_EBNF_GRAMMAR: Grammar = {
    "<start>":
        ["<expr>"],

    "<expr>":
        ["<term> + <expr>", "<term> - <expr>", "<term>"],

    "<term>":
        ["<factor> * <term>", "<factor> / <term>", "<factor>"],

    "<factor>":
        ["<sign>?<factor>", "(<expr>)", "<integer>(.<integer>)?"],

    "<sign>":
        ["+", "-"],

    "<integer>":
        ["<digit>+"],

    "<digit>":
        srange(string.digits)
} 

让我们实现一个名为 convert_ebnf_grammar() 的函数,它接受这样的 EBNF 语法并将其自动转换为 BNF 语法。

实现 `convert_ebnf_grammar()`

我们的目的是将像上面那样的 EBNF 语法转换为常规的 BNF 语法。这是通过四个规则完成的:

  1. 表达式 (content)op,其中 op?+* 之一,变为 <new-symbol>op,并添加一个新规则 <new-symbol> ::= content

  2. 表达式 <symbol>? 变为 <new-symbol>,其中 <new-symbol> ::= <empty> | <symbol>

  3. 表达式 <symbol>+ 变为 <new-symbol>,其中 <new-symbol> ::= <symbol> | <symbol><new-symbol>

  4. 表达式 <symbol>* 变为 <new-symbol>,其中 <new-symbol> ::= <empty> | <symbol><new-symbol>

在这里,<empty> 扩展为空字符串,正如 <empty> ::=。 (这也可以称为 epsilon 扩展。)

如果这些运算符让你想起了 正则表达式,这并非偶然:实际上,任何基本正则表达式都可以使用上述规则(以及上面定义的 crange() 中的字符类)转换为语法。

在上述示例上应用这些规则会产生以下结果:

  • <idchar>+ 变为 <idchar><new-symbol>,其中 <new-symbol> ::= <idchar> | <idchar><new-symbol>

  • <integer>(.<integer>)? 变为 <integer><new-symbol>,其中 <new-symbol> ::= <empty> | .<integer>

让我们在三个步骤中实现这些规则。

创建新符号

首先,我们需要一种创建新符号的机制。这相当直接。

def new_symbol(grammar: Grammar, symbol_name: str = "<symbol>") -> str:
  """Return a new symbol for `grammar` based on `symbol_name`"""
    if symbol_name not in grammar:
        return symbol_name

    count = 1
    while True:
        tentative_symbol_name = symbol_name[:-1] + "-" + repr(count) + ">"
        if tentative_symbol_name not in grammar:
            return tentative_symbol_name
        count += 1 
assert new_symbol(EXPR_EBNF_GRAMMAR, '<expr>') == '<expr-1>' 
扩展括号表达式

接下来,我们需要一种方法从我们的扩展中提取括号表达式,并根据上述规则进行扩展。让我们从提取表达式开始:

RE_PARENTHESIZED_EXPR = re.compile(r'\([^()]*\)[?+*]') 
def parenthesized_expressions(expansion: Expansion) -> List[str]:
    # In later chapters, we allow expansions to be tuples,
    # with the expansion being the first element
    if isinstance(expansion, tuple):
        expansion = expansion[0]

    return re.findall(RE_PARENTHESIZED_EXPR, expansion) 
assert parenthesized_expressions("(<foo>)* (<foo><bar>)+ (+<foo>)? <integer>(.<integer>)?") == [
    '(<foo>)*', '(<foo><bar>)+', '(+<foo>)?', '(.<integer>)?'] 

我们现在可以使用这些来应用上述第 1 条规则,为括号中的表达式引入新符号。

def convert_ebnf_parentheses(ebnf_grammar: Grammar) -> Grammar:
  """Convert a grammar in extended BNF to BNF"""
    grammar = extend_grammar(ebnf_grammar)
    for nonterminal in ebnf_grammar:
        expansions = ebnf_grammar[nonterminal]

        for i in range(len(expansions)):
            expansion = expansions[i]
            if not isinstance(expansion, str):
                expansion = expansion[0]

            while True:
                parenthesized_exprs = parenthesized_expressions(expansion)
                if len(parenthesized_exprs) == 0:
                    break

                for expr in parenthesized_exprs:
                    operator = expr[-1:]
                    contents = expr[1:-2]

                    new_sym = new_symbol(grammar)

                    exp = grammar[nonterminal][i]
                    opts = None
                    if isinstance(exp, tuple):
                        (exp, opts) = exp
                    assert isinstance(exp, str)

                    expansion = exp.replace(expr, new_sym + operator, 1)
                    if opts:
                        grammar[nonterminal][i] = (expansion, opts)
                    else:
                        grammar[nonterminal][i] = expansion

                    grammar[new_sym] = [contents]

    return grammar 

这将按照上述草图进行转换:

convert_ebnf_parentheses({"<number>": ["<integer>(.<integer>)?"]}) 
{'<number>': ['<integer><symbol>?'], '<symbol>': ['.<integer>']}

即使对于嵌套的括号表达式也有效:

convert_ebnf_parentheses({"<foo>": ["((<foo>)?)+"]}) 
{'<foo>': ['<symbol-1>+'], '<symbol>': ['<foo>'], '<symbol-1>': ['<symbol>?']}

扩展运算符

在扩展括号表达式之后,我们现在需要处理跟在运算符后面的符号(?*+)。与上面的 convert_ebnf_parentheses() 类似,我们首先提取所有跟在运算符后面的符号。

RE_EXTENDED_NONTERMINAL = re.compile(r'(<[^<> ]*>[?+*])') 
def extended_nonterminals(expansion: Expansion) -> List[str]:
    # In later chapters, we allow expansions to be tuples,
    # with the expansion being the first element
    if isinstance(expansion, tuple):
        expansion = expansion[0]

    return re.findall(RE_EXTENDED_NONTERMINAL, expansion) 
assert extended_nonterminals(
    "<foo>* <bar>+ <elem>? <none>") == ['<foo>*', '<bar>+', '<elem>?'] 

我们的转换器提取符号和运算符,并根据上述规则添加新符号。

def convert_ebnf_operators(ebnf_grammar: Grammar) -> Grammar:
  """Convert a grammar in extended BNF to BNF"""
    grammar = extend_grammar(ebnf_grammar)
    for nonterminal in ebnf_grammar:
        expansions = ebnf_grammar[nonterminal]

        for i in range(len(expansions)):
            expansion = expansions[i]
            extended_symbols = extended_nonterminals(expansion)

            for extended_symbol in extended_symbols:
                operator = extended_symbol[-1:]
                original_symbol = extended_symbol[:-1]
                assert original_symbol in ebnf_grammar, \
                    f"{original_symbol} is not defined in grammar"

                new_sym = new_symbol(grammar, original_symbol)

                exp = grammar[nonterminal][i]
                opts = None
                if isinstance(exp, tuple):
                    (exp, opts) = exp
                assert isinstance(exp, str)

                new_exp = exp.replace(extended_symbol, new_sym, 1)
                if opts:
                    grammar[nonterminal][i] = (new_exp, opts)
                else:
                    grammar[nonterminal][i] = new_exp

                if operator == '?':
                    grammar[new_sym] = ["", original_symbol]
                elif operator == '*':
                    grammar[new_sym] = ["", original_symbol + new_sym]
                elif operator == '+':
                    grammar[new_sym] = [
                        original_symbol, original_symbol + new_sym]

    return grammar 
convert_ebnf_operators({"<integer>": ["<digit>+"], "<digit>": ["0"]}) 
{'<integer>': ['<digit-1>'],
 '<digit>': ['0'],
 '<digit-1>': ['<digit>', '<digit><digit-1>']}

全部一起

我们可以将这两个结合起来,首先扩展括号,然后是运算符:

def convert_ebnf_grammar(ebnf_grammar: Grammar) -> Grammar:
    return convert_ebnf_operators(convert_ebnf_parentheses(ebnf_grammar)) 
```</details>

这里是一个使用 `convert_ebnf_grammar()` 的例子:

```py
convert_ebnf_grammar({"<authority>": ["(<userinfo>@)?<host>(:<port>)?"]}) 
{'<authority>': ['<symbol-2><host><symbol-1-1>'],
 '<symbol>': ['<userinfo>@'],
 '<symbol-1>': [':<port>'],
 '<symbol-2>': ['', '<symbol>'],
 '<symbol-1-1>': ['', '<symbol-1>']}

expr_grammar = convert_ebnf_grammar(EXPR_EBNF_GRAMMAR)
expr_grammar 
{'<start>': ['<expr>'],
 '<expr>': ['<term> + <expr>', '<term> - <expr>', '<term>'],
 '<term>': ['<factor> * <term>', '<factor> / <term>', '<factor>'],
 '<factor>': ['<sign-1><factor>', '(<expr>)', '<integer><symbol-1>'],
 '<sign>': ['+', '-'],
 '<integer>': ['<digit-1>'],
 '<digit>': ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
 '<symbol>': ['.<integer>'],
 '<sign-1>': ['', '<sign>'],
 '<symbol-1>': ['', '<symbol>'],
 '<digit-1>': ['<digit>', '<digit><digit-1>']}

成功!我们已经将 EBNF 语法优雅地转换为 BNF。

使用字符类和 EBNF 语法转换,我们有两个强大的工具,使编写语法变得更容易。我们将反复使用这些工具,当我们处理语法时。

语法扩展

在本书的整个过程中,我们经常需要为语法指定 附加信息,例如 概率约束。为了支持这些扩展,以及可能的其他扩展,我们定义了一种 注释 机制。

我们对注释语法的概念是向单个扩展添加 注释。为此,我们允许扩展不仅可以是一个字符串,还可以是一个字符串和一组属性的 ,如下所示

"<expr>":
        [("<term> + <expr>", opts(min_depth=10)),
         ("<term> - <expr>", opts(max_depth=2)),
         "<term>"] 

在这里,opts()函数允许我们表达适用于单个扩展的注释;在这种情况下,加法将被注释为具有 10 的min_depth值,减法将被注释为具有 2 的max_depth值。这些注释的含义留给处理语法的各个算法来决定;然而,一般而言,它们可以被忽略。

实现`opts()`

我们的opts()辅助函数返回其参数到值的映射:

def opts(**kwargs: Any) -> Dict[str, Any]:
    return kwargs 
opts(min_depth=10) 
{'min_depth': 10}

为了处理扩展字符串以及扩展和注释的成对出现,我们通过指定的辅助函数exp_string()exp_opts()来访问扩展字符串和相关注释:

def exp_string(expansion: Expansion) -> str:
  """Return the string to be expanded"""
    if isinstance(expansion, str):
        return expansion
    return expansion[0] 
exp_string(("<term> + <expr>", opts(min_depth=10))) 
'<term> + <expr>'

def exp_opts(expansion: Expansion) -> Dict[str, Any]:
  """Return the options of an expansion.  If options are not defined, return {}"""
    if isinstance(expansion, str):
        return {}
    return expansion[1] 
def exp_opt(expansion: Expansion, attribute: str) -> Any:
  """Return the given attribution of an expansion.
 If attribute is not defined, return None"""
    return exp_opts(expansion).get(attribute, None) 
exp_opts(("<term> + <expr>", opts(min_depth=10))) 
{'min_depth': 10}

exp_opt(("<term> - <expr>", opts(max_depth=2)), 'max_depth') 
2

最后,我们定义一个设置特定选项的辅助函数:

def set_opts(grammar: Grammar, symbol: str, expansion: Expansion, 
             opts: Option = {}) -> None:
  """Set the options of the given expansion of grammar[symbol] to opts"""
    expansions = grammar[symbol]
    for i, exp in enumerate(expansions):
        if exp_string(exp) != exp_string(expansion):
            continue

        new_opts = exp_opts(exp)
        if opts == {} or new_opts == {}:
            new_opts = opts
        else:
            for key in opts:
                new_opts[key] = opts[key]

        if new_opts == {}:
            grammar[symbol][i] = exp_string(exp)
        else:
            grammar[symbol][i] = (exp_string(exp), new_opts)

        return

    raise KeyError(
        "no expansion " +
        repr(symbol) +
        " -> " +
        repr(
            exp_string(expansion))) 
```</details>

## 检查语法

由于语法表示为字符串,因此引入错误相对容易。因此,让我们引入一个检查语法一致性的辅助函数。

辅助函数`is_valid_grammar()`遍历语法以检查是否所有使用的符号都已定义,反之亦然,这对于调试非常有用;它还检查是否所有符号都从起始符号可达。你不必深入了解细节,但像往常一样,在使用输入数据之前确保输入数据正确是非常重要的。

<details id="Excursion:-Implementing-is_valid_grammar()"><summary>实现`is_valid_grammar()`</summary>

```py
import [sys](https://docs.python.org/3/library/sys.html) 
def def_used_nonterminals(grammar: Grammar, start_symbol: 
                          str = START_SYMBOL) -> Tuple[Optional[Set[str]], 
                                                       Optional[Set[str]]]:
  """Return a pair (`defined_nonterminals`, `used_nonterminals`) in `grammar`.
 In case of error, return (`None`, `None`)."""

    defined_nonterminals = set()
    used_nonterminals = {start_symbol}

    for defined_nonterminal in grammar:
        defined_nonterminals.add(defined_nonterminal)
        expansions = grammar[defined_nonterminal]
        if not isinstance(expansions, list):
            print(repr(defined_nonterminal) + ": expansion is not a list",
                  file=sys.stderr)
            return None, None

        if len(expansions) == 0:
            print(repr(defined_nonterminal) + ": expansion list empty",
                  file=sys.stderr)
            return None, None

        for expansion in expansions:
            if isinstance(expansion, tuple):
                expansion = expansion[0]
            if not isinstance(expansion, str):
                print(repr(defined_nonterminal) + ": "
                      + repr(expansion) + ": not a string",
                      file=sys.stderr)
                return None, None

            for used_nonterminal in nonterminals(expansion):
                used_nonterminals.add(used_nonterminal)

    return defined_nonterminals, used_nonterminals 
def reachable_nonterminals(grammar: Grammar,
                           start_symbol: str = START_SYMBOL) -> Set[str]:
    reachable = set()

    def _find_reachable_nonterminals(grammar, symbol):
        nonlocal reachable
        reachable.add(symbol)
        for expansion in grammar.get(symbol, []):
            for nonterminal in nonterminals(expansion):
                if nonterminal not in reachable:
                    _find_reachable_nonterminals(grammar, nonterminal)

    _find_reachable_nonterminals(grammar, start_symbol)
    return reachable 
def unreachable_nonterminals(grammar: Grammar,
                             start_symbol=START_SYMBOL) -> Set[str]:
    return grammar.keys() - reachable_nonterminals(grammar, start_symbol) 
def opts_used(grammar: Grammar) -> Set[str]:
    used_opts = set()
    for symbol in grammar:
        for expansion in grammar[symbol]:
            used_opts |= set(exp_opts(expansion).keys())
    return used_opts 
def is_valid_grammar(grammar: Grammar,
                     start_symbol: str = START_SYMBOL, 
                     supported_opts: Set[str] = set()) -> bool:
  """Check if the given `grammar` is valid.
 `start_symbol`: optional start symbol (default: `<start>`)
 `supported_opts`: options supported (default: none)"""

    defined_nonterminals, used_nonterminals = \
        def_used_nonterminals(grammar, start_symbol)
    if defined_nonterminals is None or used_nonterminals is None:
        return False

    # Do not complain about '<start>' being not used,
    # even if start_symbol is different
    if START_SYMBOL in grammar:
        used_nonterminals.add(START_SYMBOL)

    for unused_nonterminal in defined_nonterminals - used_nonterminals:
        print(repr(unused_nonterminal) + ": defined, but not used. Consider applying trim_grammar() on the grammar",
              file=sys.stderr)
    for undefined_nonterminal in used_nonterminals - defined_nonterminals:
        print(repr(undefined_nonterminal) + ": used, but not defined",
              file=sys.stderr)

    # Symbols must be reachable either from <start> or given start symbol
    unreachable = unreachable_nonterminals(grammar, start_symbol)
    msg_start_symbol = start_symbol

    if START_SYMBOL in grammar:
        unreachable = unreachable - \
            reachable_nonterminals(grammar, START_SYMBOL)
        if start_symbol != START_SYMBOL:
            msg_start_symbol += " or " + START_SYMBOL

    for unreachable_nonterminal in unreachable:
        print(repr(unreachable_nonterminal) + ": unreachable from " + msg_start_symbol + ". Consider applying trim_grammar() on the grammar",
              file=sys.stderr)

    used_but_not_supported_opts = set()
    if len(supported_opts) > 0:
        used_but_not_supported_opts = opts_used(
            grammar).difference(supported_opts)
        for opt in used_but_not_supported_opts:
            print(
                "warning: option " +
                repr(opt) +
                " is not supported",
                file=sys.stderr)

    return used_nonterminals == defined_nonterminals and len(unreachable) == 0 

为了使语法适合is_valid_grammar(),以下函数可能很有用。函数trim_grammar()自动删除对于不再需要的非终端的规则。如果你添加了新的规则,这些规则删除了一些扩展,使得一些非终端变得过时,这很有用。

def trim_grammar(grammar: Grammar, start_symbol=START_SYMBOL) -> Grammar:
  """Create a copy of `grammar` where all unused and unreachable nonterminals are removed."""
    new_grammar = extend_grammar(grammar)
    defined_nonterminals, used_nonterminals = \
        def_used_nonterminals(grammar, start_symbol)
    if defined_nonterminals is None or used_nonterminals is None:
        return new_grammar

    unused = defined_nonterminals - used_nonterminals
    unreachable = unreachable_nonterminals(grammar, start_symbol)
    for nonterminal in unused | unreachable:
        del new_grammar[nonterminal]

    return new_grammar 
```</details>

让我们利用`is_valid_grammar()`。我们定义的语法通过了测试:

```py
assert is_valid_grammar(EXPR_GRAMMAR)
assert is_valid_grammar(CGI_GRAMMAR)
assert is_valid_grammar(URL_GRAMMAR) 

检查也可以应用于 EBNF 语法:

assert is_valid_grammar(EXPR_EBNF_GRAMMAR) 

尽管如此,这些测试并没有通过:

assert not is_valid_grammar({"<start>": ["<x>"], "<y>": ["1"]}) 
'<y>': defined, but not used. Consider applying trim_grammar() on the grammar
'<x>': used, but not defined
'<y>': unreachable from <start>. Consider applying trim_grammar() on the grammar

assert not is_valid_grammar({"<start>": "123"}) 
'<start>': expansion is not a list

assert not is_valid_grammar({"<start>": []}) 
'<start>': expansion list empty

assert not is_valid_grammar({"<start>": [1, 2, 3]}) 
'<start>': 1: not a string

#type: ignore注释避免了静态检查器将上述内容标记为错误)。

从现在开始,在定义语法时,我们总会使用is_valid_grammar()

经验教训

  • 语法是表达和生成语法上有效输入的有力工具。

  • 从语法生成的输入可以直接使用,或者用作基于变异的模糊测试的种子。

  • 语法可以通过字符类和运算符进行扩展,以简化编写过程。

下一步

由于它们为生成软件测试提供了一个很好的基础,因此在这本书中我们反复使用语法。作为预览,我们可以使用语法来 fuzz 配置:

<options> ::= <option>*
<option> ::= -h | --version | -v | -d | -i | --global-config <filename>

我们可以使用语法来进行模糊测试函数和 API 以及模糊测试图形用户界面:

<call-sequence> ::= <call>*
<call> ::= urlparse(<url>) | urlsplit(<url>)

我们可以将概率和约束分配给单个扩展:

<term>: 50% <factor> * <term> |  30% <factor> / <term> | 20% <factor>
<integer>: <digit>+ { <integer> >= 100 }

所有这些额外功能都特别有价值,因为我们能够

  1. 自动推断语法,无需手动指定,并且

  2. 引导它们向特定目标前进,例如覆盖率或关键功能;

我们也讨论了本书中所有技术。

然而,要达到这个目标,我们仍然有一些作业要做。特别是,我们首先必须学会如何

  • 创建一个高效的语法模糊器

背景

作为人类语言的基础之一,语法与人类语言一样历史悠久。生成语法的首次形式化是由公元前 350 年的 Dakṣiputra Pāṇini 完成的 [Dakṣiputra Pāṇini, 350 BCE]。作为表达数据和程序形式语言的一般手段,它们在计算机科学中的作用不容小觑。Chomsky [Chomsky et al, 1956] 的开创性工作引入了正则语言、上下文无关语法、上下文相关语法和通用语法的核心模型,这些模型自计算机科学中作为指定输入和编程语言的方法以来一直被使用(和教授)。

使用语法来生成测试输入可以追溯到 Burkhardt [Burkhardt et al, 1967],后来被 Hanford [Hanford et al, 1970] 和 Purdom [Purdom et al, 1972] 重新发现并应用。从那时起,语法测试最重要的用途一直是编译器测试。实际上,基于语法的测试是编译器和 Web 浏览器能够正常工作的一个重要原因:

  • CSmith工具 [Yang et al, 2011]专门针对 C 程序,从 C 语法开始,然后应用额外的步骤,例如引用之前定义的变量和函数或确保整数和类型安全。其作者们已经用它“找到并报告了 400 多个以前未知的编译器错误”。

  • 与本书共享两位作者的LangFuzz工作 [Holler et al, 2012],使用通用语法生成输出,并日夜不停地生成 JavaScript 程序并测试它们的解释器;截至今天,它已在 Mozilla Firefox、Google Chrome 和 Microsoft Edge 等浏览器中发现了超过 2,600 个错误。

  • EMI 项目 [Le et al, 2014] 使用语法来对 C 编译器进行压力测试,将已知的测试转换为在所有输入上语义等效的替代程序。这又导致了 C 编译器中超过 100 个错误的修复。

  • Grammarinator [Hodován 等人,2018] 是一个开源的语法模糊器(用 Python 编写!),使用流行的 ANTLR 格式作为语法规范。像 LangFuzz 一样,它使用语法进行解析和生成,并在 JerryScript 轻量级 JavaScript 引擎及其相关平台上发现了 100 多个问题。

  • Domato 是一个通用的语法生成引擎,专门用于模糊 DOM 输入。它已经在流行的网络浏览器中揭示了许多安全问题。

编译器和网络浏览器,当然,不仅是需要语法进行测试的领域,也是语法广为人知的领域。本书中的主张是,语法可以用来生成几乎 任何 输入,我们的目标是赋予你做到这一点的能力。

练习

练习 1:JSON 语法

查看一下 JSON 规范,并从中推导出一个语法:

  • 使用 字符类 来表达有效字符

  • 使用 EBNF 表达重复和可选部分

  • 假设

    • 一个字符串是一系列数字、ASCII 字母、标点符号和空格字符的序列,没有引号或转义符。

    • 空白只是一个空格。

  • 使用 is_valid_grammar() 确保语法有效。

将语法输入到 simple_grammar_fuzzer()。你是否遇到任何错误,为什么?

使用笔记本 来完成练习并查看解决方案。

练习 2:寻找错误

名称 simple_grammar_fuzzer() 并非偶然:它扩展语法的几种方式都有限制。如果你将 simple_grammar_fuzzer() 应用于上面定义的 nonterminal_grammarexpr_grammar,会发生什么?为什么?

使用笔记本 来完成练习并查看解决方案。

练习 3:带有正则表达式的语法

在扩展了正则表达式的 语法 中,我们可以使用特殊形式

/regex/

以包括正则表达式在扩展中。例如,我们可以有一个规则

<integer> ::= /[+-]?[0-9]+/

以快速表达一个整数是可选的符号,后跟一系列数字。

第一部分:将正则表达式转换为

编写一个转换器 convert_regex(r),它接受一个正则表达式 r 并创建一个等效的语法。支持以下正则表达式构造:

  • *+?() 应在 EBNF 中正常工作。

  • a|b 应转换为选择列表 [a, b]

  • . 应匹配任何字符(除了换行符)。

  • [abc] 应转换为 srange("abc")

  • [^abc] 应转换为 ASCII 字符集 除了 srange("abc")

  • [a-b] 应转换为 crange(a, b)

  • [^a-b] 应该转换为除了 crange(a, b) 的 ASCII 字符集。

示例:convert_regex(r"[0-9]+") 应该生成一个如下的文法

{
    "<start>": ["<s1>"],
    "<s1>": [ "<s2>", "<s1><s2>" ],
    "<s2>": crange('0', '9')
} 

使用笔记本 来完成练习并查看解决方案。

第二部分:识别和扩展正则表达式

编写一个转换器 convert_regex_grammar(g),它接收一个包含正则表达式形式的 EBNF 文法 g,并创建一个等效的 BNF 文法。支持上述正则表达式构造。

示例:convert_regex_grammar({ "<integer>" : "/[+-]?[0-9]+/" }) 应该生成一个如下的文法

{
    "<integer>": ["<s1><s3>"],
    "<s1>": [ "", "<s2>" ],
    "<s2>": srange("+-"),
    "<s3>": [ "<s4>", "<s4><s3>" ],
    "<s4>": crange('0', '9')
} 

可选:在正则表达式中支持转义:\c 转换为字面字符 c\/ 转换为 /(因此不会结束正则表达式);\\ 转换为 \

使用笔记本 来完成练习并查看解决方案。

练习 4:将文法定义为函数(高级)

为了获得更简洁的语法指定语法,可以使用 Python 构造,然后由一个额外的函数进行解析。例如,我们可以想象一个使用 | 作为分隔备选方案的语法定义:

使用笔记本 来完成练习并查看解决方案。

def expression_grammar_fn():
    start = "<expr>"
    expr = "<term> + <expr>" | "<term> - <expr>"
    term = "<factor> * <term>" | "<factor> / <term>" | "<factor>"
    factor = "+<factor>" | "-<factor>" | "(<expr>)" | "<integer>.<integer>" | "<integer>"
    integer = "<digit><integer>" | "<digit>"
    digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' 

如果我们执行 expression_grammar_fn(),这将产生一个错误。然而,expression_grammar_fn() 的目的不是被执行,而是作为 数据 使用,从这些数据中构建文法。

with ExpectError():
    expression_grammar_fn() 
Traceback (most recent call last):
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_8783/1271268731.py", line 2, in <module>
    expression_grammar_fn()
  File "/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_8783/3029408019.py", line 3, in expression_grammar_fn
    expr = "<term> + <expr>" | "<term> - <expr>"
           ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'str' (expected)

为了此目的,我们使用了 ast(抽象语法树)和 inspect(代码检查)模块。

import [ast](https://docs.python.org/3/library/ast.html)
import [inspect](https://docs.python.org/3/library/inspect.html) 

首先,我们获取 expression_grammar_fn() 的源代码...

source = inspect.getsource(expression_grammar_fn)
source 
'def expression_grammar_fn():\n    start = "<expr>"\n    expr = "<term> + <expr>" | "<term> - <expr>"\n    term = "<factor> * <term>" | "<factor> / <term>" | "<factor>"\n    factor = "+<factor>" | "-<factor>" | "(<expr>)" | "<integer>.<integer>" | "<integer>"\n    integer = "<digit><integer>" | "<digit>"\n    digit = \'0\' | \'1\' | \'2\' | \'3\' | \'4\' | \'5\' | \'6\' | \'7\' | \'8\' | \'9\'\n'

...然后将其解析为抽象语法树:

tree = ast.parse(source) 

我们现在可以解析树以查找运算符和备选方案。get_alternatives() 遍历树中的所有节点 op;如果节点看起来像二进制 (|) 操作,我们将进一步深入并递归。如果不是,我们就到达了一个单一的产生式,并尝试从产生式中获取表达式。我们根据我们想要如何表示产生式来定义 to_expr 参数。在这种情况下,我们用一个单独的字符串来表示一个单一的产生式。

def get_alternatives(op, to_expr=lambda o: o.s):
    if isinstance(op, ast.BinOp) and isinstance(op.op, ast.BitOr):
        return get_alternatives(op.left, to_expr) + [to_expr(op.right)]
    return [to_expr(op)] 

funct_parser() 接收一个函数的抽象语法树(例如,expression_grammar_fn()),并遍历所有赋值:

def funct_parser(tree, to_expr=lambda o: o.s):
    return {assign.targets[0].id: get_alternatives(assign.value, to_expr)
            for assign in tree.body[0].body} 

结果是一个我们正则格式的文法:

grammar = funct_parser(tree)
for symbol in grammar:
    print(symbol, "::=", grammar[symbol]) 
start ::= ['<expr>']
expr ::= ['<term> + <expr>', '<term> - <expr>']
term ::= ['<factor> * <term>', '<factor> / <term>', '<factor>']
factor ::= ['+<factor>', '-<factor>', '(<expr>)', '<integer>.<integer>', '<integer>']
integer ::= ['<digit><integer>', '<digit>']
digit ::= ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

/var/folders/n2/xd9445p97rb3xh7m1dfx8_4h0006ts/T/ipykernel_8783/1939255217.py:1: DeprecationWarning: Attribute s is deprecated and will be removed in Python 3.14; use value instead
  def funct_parser(tree, to_expr=lambda o: o.s):

第一部分 (a):一个单一函数

编写一个单独的函数 define_grammar(fn),它接收一个定义为函数的文法(例如 expression_grammar_fn()),并返回一个正则文法。

使用笔记本 来完成练习并查看解决方案。

第一部分 (b):替代表示法

我们注意到,我们之前设计的语法表示不允许简单地生成如 srange()crange() 这样的备选方案。此外,人们可能会发现表达式的字符串表示有限制。实际上,扩展我们的语法定义以支持如下语法是简单的:

def define_name(o):
    return o.id if isinstance(o, ast.Name) else o.s 
def define_expr(op):
    if isinstance(op, ast.BinOp) and isinstance(op.op, ast.Add):
        return (*define_expr(op.left), define_name(op.right))
    return (define_name(op),) 
def define_ex_grammar(fn):
    return define_grammar(fn, define_expr) 

语法:

@define_ex_grammar
def expression_grammar():
    start   = expr
    expr    = (term + '+' + expr
            |  term + '-' + expr)
    term    = (factor + '*' + term
            |  factor + '/' + term
            |  factor)
    factor  = ('+' + factor
            |  '-' + factor
            |  '(' + expr + ')'
            |  integer + '.' + integer
            |  integer)
    integer = (digit + integer
            |  digit)
    digit   = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

for symbol in expression_grammar:
    print(symbol, "::=", expression_grammar[symbol]) 

注意。 因此获得的语法数据结构比标准数据结构更详细。它将每个产生式表示为一个元组。

我们注意到,我们尚未在上面的语法中启用 srange()crange()。你将如何添加这些? (提示:将 define_expr() 包装起来以查找 ast.Call)

第二部分:扩展语法

引入一个操作符 *,它接受一个 (min, max) 对,其中 minmax 分别代表最小和最大重复次数。缺失的 min 值表示零;缺失的 max 值表示无穷大。

def identifier_grammar_fn():
    identifier = idchar * (1,) 

使用 * 操作符,我们可以泛化 EBNF 操作符——? 变为 (0,1),* 变为 (0,), 和 + 变为 (1,)。编写一个转换器,它接受使用 * 定义的扩展语法,解析它,并将其转换为 BNF。

使用笔记本 来完成练习并查看解决方案。

Creative Commons License 本项目的内容根据 Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际许可协议 许可。内容的一部分源代码,以及用于格式化和显示该内容的源代码,根据 MIT 许可协议 许可。 最后更改:2024-06-30 18:31:28+02:00 • 引用 • 印记

如何引用本作品

Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, 和 Christian Holler: "使用语法进行模糊测试". 在 Andreas Zeller, Rahul Gopinath, Marcel Böhme, Gordon Fraser, 和 Christian Holler 的 "模糊测试书籍", www.fuzzingbook.org/html/Grammars.html. Retrieved 2024-06-30 18:31:28+02:00.

@incollection{fuzzingbook2024:Grammars,
    author = {Andreas Zeller and Rahul Gopinath and Marcel B{\"o}hme and Gordon Fraser and Christian Holler},
    booktitle = {The Fuzzing Book},
    title = {Fuzzing with Grammars},
    year = {2024},
    publisher = {CISPA Helmholtz Center for Information Security},
    howpublished = {\url{https://www.fuzzingbook.org/html/Grammars.html}},
    note = {Retrieved 2024-06-30 18:31:28+02:00},
    url = {https://www.fuzzingbook.org/html/Grammars.html},
    urldate = {2024-06-30 18:31:28+02:00}
}

posted @ 2025-12-13 18:14  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报