differential-coverage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: differential_coverage
3
+ Version: 0.1.0
4
+ Summary: Compute differential coverage for fuzzer runs.
5
+ Author-email: Valentin Huber <contact@valentinhuber.me>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/valentinhuber/differential_coverage
8
+ Project-URL: Documentation, https://github.com/valentinhuber/differential_coverage#readme
9
+ Keywords: fuzzing,coverage,differential-coverage
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.19.1; extra == "dev"
17
+ Requires-Dist: pre-commit>=4.3.0; extra == "dev"
18
+ Requires-Dist: pytest>=9.0.2; extra == "dev"
19
+ Requires-Dist: ruff>=0.15.0; extra == "dev"
20
+
21
+ # Differential Coverage
22
+
23
+ Absolute coverage numbers aren't painting the whole picture: Different fuzzers may reach the same total coverage, but cover different parts of the program. Fuzzer (A) may reach more coverage than fuzzer (B), but (B) may reach coverage that (A) doesn't reach — so it's still valuable.
24
+
25
+ ## Definition
26
+
27
+ Differential coverage is a measure for this. It was proposed by Leonelli et al. in their paper on TwinFuzz[^1]. This implementation relies on the formulas from the SBFT’25 Competition Report[^2]. There, Crump et al. define a fuzzers overall score compared to others as
28
+
29
+ $$
30
+ relscore(f,b,s,e) = \left|f_0\in F|e\notin \text{cov}(b,t,s)\forall t\in \text{trials}(f_0,b)\right|
31
+ \times\frac
32
+ {\left|\{t\in \text{trials}(f,b)\ |\ e \in \text{cov}(b,t,s)\}\right|}
33
+ {\left|\{t\in \text{trials}(f,b)\ |\ \text{cov}(b,t,s)\neq \emptyset\}\right|}
34
+ $$
35
+
36
+ The score of a fuzzer is then
37
+
38
+ $$
39
+ \text{score}(f,b,s)=\sum_{e\in E}\text{relscore}(f,b,s,e)
40
+ $$
41
+
42
+ ## Explanation
43
+ This can be simplified to the following:
44
+
45
+ $$
46
+ \text{differential coverage}(f,e) = \text{number of fuzzers that never hit }e
47
+ \times\frac
48
+ {\text{number of trials of }f\text{ that hit }e}
49
+ {\text{number of trials of }f\text{ with non-empty cov}}
50
+ $$
51
+ $$
52
+ \text{score}(f) = \text{sum of differential coverage}(f,e)\text{ over all edges e}
53
+ $$
54
+
55
+ ## Usage
56
+ Assume `<input_dir>` is a directory of directories for each fuzzer, where each fuzzer subdirectory contains coverage files for each trial. Currently, the output format of `afl-showmap` is supported (lines with `edge_id:count`, where we only care if `count > 0`).
57
+
58
+ PRs for other data formats welcome :)
59
+
60
+ ### Command Line Interface
61
+ ```
62
+ differential_coverage <input_dir>
63
+ ```
64
+
65
+ #### Example
66
+ [`tests/sample_coverage`](./tests/sample_coverage/) provides sample coverage data to explain differential coverage. It contains 3 fuzzers and 3 edges:
67
+ - Edge 1 is always hit by all fuzzers
68
+ - Edge 2 is always hit by `fuzzer_c`, sometimes hit by `fuzzer_a`, and never hit by `fuzzer_b`
69
+ - Edge 3 is always hit by `fuzzer_b` and `fuzzer_c`, but only sometimes by `fuzzer_a`
70
+
71
+ ```
72
+ differential_coverage tests/sample_coverage
73
+ ```
74
+ then produces
75
+ ```
76
+ fuzzer_c: 1.00
77
+ fuzzer_a: 0.50
78
+ fuzzer_b: 0.00
79
+ ```
80
+ ### API
81
+
82
+ ```python
83
+ from pathlib import Path
84
+ from differential_coverage import (
85
+ calculate_scores_for_campaign,
86
+ calculate_differential_coverage_scores,
87
+ )
88
+
89
+ # From a campaign directory (one subdir per fuzzer, each with coverage files):
90
+ scores = read_campaign_and_calculate_score(Path("path/to/campaign_dir"))
91
+ # -> dict[str, float]: fuzzer name -> score
92
+
93
+ # From in-memory campaign data (fuzzer -> trial_id -> edge_id -> count):
94
+ campaign = {...} # dict[str, dict[str, dict[int, int]]]
95
+ scores = calculate_differential_coverage_scores(campaign)
96
+ # -> dict[str, float]: fuzzer name -> score
97
+ ```
98
+
99
+ ## Installation
100
+ ```bash
101
+ pip install .
102
+ ```
103
+
104
+ ## Development
105
+ Install dev dependencies and set up the pre-commit hook (runs a couple of checks before committing):
106
+ ```bash
107
+ pip install -e ".[dev]"
108
+ pre-commit install
109
+ ```
110
+
111
+ [^1]: TWINFUZZ: Differential Testing of Video Hardware Acceleration Stacks, https://www.ndss-symposium.org/ndss-paper/twinfuzz-differential-testing-of-video-hardware-acceleration-stacks/
112
+
113
+ [^2]: SBFT’25 Competition Report — Fuzzing Track, https://ieeexplore.ieee.org/document/11086561
@@ -0,0 +1,93 @@
1
+ # Differential Coverage
2
+
3
+ Absolute coverage numbers aren't painting the whole picture: Different fuzzers may reach the same total coverage, but cover different parts of the program. Fuzzer (A) may reach more coverage than fuzzer (B), but (B) may reach coverage that (A) doesn't reach — so it's still valuable.
4
+
5
+ ## Definition
6
+
7
+ Differential coverage is a measure for this. It was proposed by Leonelli et al. in their paper on TwinFuzz[^1]. This implementation relies on the formulas from the SBFT’25 Competition Report[^2]. There, Crump et al. define a fuzzers overall score compared to others as
8
+
9
+ $$
10
+ relscore(f,b,s,e) = \left|f_0\in F|e\notin \text{cov}(b,t,s)\forall t\in \text{trials}(f_0,b)\right|
11
+ \times\frac
12
+ {\left|\{t\in \text{trials}(f,b)\ |\ e \in \text{cov}(b,t,s)\}\right|}
13
+ {\left|\{t\in \text{trials}(f,b)\ |\ \text{cov}(b,t,s)\neq \emptyset\}\right|}
14
+ $$
15
+
16
+ The score of a fuzzer is then
17
+
18
+ $$
19
+ \text{score}(f,b,s)=\sum_{e\in E}\text{relscore}(f,b,s,e)
20
+ $$
21
+
22
+ ## Explanation
23
+ This can be simplified to the following:
24
+
25
+ $$
26
+ \text{differential coverage}(f,e) = \text{number of fuzzers that never hit }e
27
+ \times\frac
28
+ {\text{number of trials of }f\text{ that hit }e}
29
+ {\text{number of trials of }f\text{ with non-empty cov}}
30
+ $$
31
+ $$
32
+ \text{score}(f) = \text{sum of differential coverage}(f,e)\text{ over all edges e}
33
+ $$
34
+
35
+ ## Usage
36
+ Assume `<input_dir>` is a directory of directories for each fuzzer, where each fuzzer subdirectory contains coverage files for each trial. Currently, the output format of `afl-showmap` is supported (lines with `edge_id:count`, where we only care if `count > 0`).
37
+
38
+ PRs for other data formats welcome :)
39
+
40
+ ### Command Line Interface
41
+ ```
42
+ differential_coverage <input_dir>
43
+ ```
44
+
45
+ #### Example
46
+ [`tests/sample_coverage`](./tests/sample_coverage/) provides sample coverage data to explain differential coverage. It contains 3 fuzzers and 3 edges:
47
+ - Edge 1 is always hit by all fuzzers
48
+ - Edge 2 is always hit by `fuzzer_c`, sometimes hit by `fuzzer_a`, and never hit by `fuzzer_b`
49
+ - Edge 3 is always hit by `fuzzer_b` and `fuzzer_c`, but only sometimes by `fuzzer_a`
50
+
51
+ ```
52
+ differential_coverage tests/sample_coverage
53
+ ```
54
+ then produces
55
+ ```
56
+ fuzzer_c: 1.00
57
+ fuzzer_a: 0.50
58
+ fuzzer_b: 0.00
59
+ ```
60
+ ### API
61
+
62
+ ```python
63
+ from pathlib import Path
64
+ from differential_coverage import (
65
+ calculate_scores_for_campaign,
66
+ calculate_differential_coverage_scores,
67
+ )
68
+
69
+ # From a campaign directory (one subdir per fuzzer, each with coverage files):
70
+ scores = read_campaign_and_calculate_score(Path("path/to/campaign_dir"))
71
+ # -> dict[str, float]: fuzzer name -> score
72
+
73
+ # From in-memory campaign data (fuzzer -> trial_id -> edge_id -> count):
74
+ campaign = {...} # dict[str, dict[str, dict[int, int]]]
75
+ scores = calculate_differential_coverage_scores(campaign)
76
+ # -> dict[str, float]: fuzzer name -> score
77
+ ```
78
+
79
+ ## Installation
80
+ ```bash
81
+ pip install .
82
+ ```
83
+
84
+ ## Development
85
+ Install dev dependencies and set up the pre-commit hook (runs a couple of checks before committing):
86
+ ```bash
87
+ pip install -e ".[dev]"
88
+ pre-commit install
89
+ ```
90
+
91
+ [^1]: TWINFUZZ: Differential Testing of Video Hardware Acceleration Stacks, https://www.ndss-symposium.org/ndss-paper/twinfuzz-differential-testing-of-video-hardware-acceleration-stacks/
92
+
93
+ [^2]: SBFT’25 Competition Report — Fuzzing Track, https://ieeexplore.ieee.org/document/11086561
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.10.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "differential_coverage"
7
+ version = "0.1.0"
8
+ description = "Compute differential coverage for fuzzer runs."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Valentin Huber", email = "contact@valentinhuber.me" }]
13
+ keywords = ["fuzzing", "coverage", "differential-coverage"]
14
+ classifiers = [
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["mypy>=1.19.1", "pre-commit>=4.3.0", "pytest>=9.0.2", "ruff>=0.15.0"]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/valentinhuber/differential_coverage"
26
+ Documentation = "https://github.com/valentinhuber/differential_coverage#readme"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.pytest.ini_options]
32
+ pythonpath = ["src"]
33
+
34
+ [project.scripts]
35
+ differential_coverage = "differential_coverage:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Compute relscore (as defined in SBFT'25) from coverage in afl-showmap format (id:count per line).
4
+ Input: directory of subdirectories (one per fuzzer), each containing coverage files.
5
+ """
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+
11
+ def read_afl_showmap_file(file: Path) -> dict[int, int]:
12
+ """Parse one afl-showmap file; return dict of edge ids to counts."""
13
+ edges = {}
14
+ for i, line in enumerate(file.read_text().strip().splitlines()):
15
+ line = line.strip()
16
+ if not line:
17
+ continue
18
+ split = line.split(":")
19
+ if len(split) != 2:
20
+ raise ValueError(f"Invalid line {file}:{i}: {line}")
21
+ id, count = split
22
+ edges[int(id)] = int(count)
23
+ return edges
24
+
25
+
26
+ def read_fuzzer_dir(path: Path) -> dict[str, dict[int, int]]:
27
+ """Read all afl-showmap files in a directory; return dict of trial id to dict of edge ids to counts."""
28
+ trials = {}
29
+ for file in path.iterdir():
30
+ if file.is_file():
31
+ trials[file.name] = read_afl_showmap_file(file)
32
+ else:
33
+ raise ValueError(f"Invalid file: {file}")
34
+ return trials
35
+
36
+
37
+ def read_campaign_dir(path: Path) -> dict[str, dict[str, dict[int, int]]]:
38
+ """Read all fuzzer directories in a campaign directory; return dict of fuzzer name to dict of trial id to dict of edge ids to counts."""
39
+ campaigns = {}
40
+ for fuzzer_dir in path.iterdir():
41
+ if fuzzer_dir.is_dir():
42
+ campaigns[fuzzer_dir.name] = read_fuzzer_dir(fuzzer_dir)
43
+ else:
44
+ raise ValueError(f"Invalid file: {fuzzer_dir}")
45
+ return campaigns
46
+
47
+
48
+ def calculate_fuzzer_score(
49
+ trials: dict[str, dict[int, int]],
50
+ all_edges: set[int],
51
+ fuzzers_that_never_hit_edge: dict[int, set[str]],
52
+ ) -> float:
53
+ score = 0.0
54
+ for e in all_edges:
55
+ fuzzers_that_never_hit_e = len(fuzzers_that_never_hit_edge[e])
56
+ trials_that_hit_e = len([trial for trial in trials.values() if trial.get(e, 0)])
57
+ trials_with_non_empty_cov = len(
58
+ [trial for trial in trials.values() if any(trial.values())]
59
+ )
60
+ score += (
61
+ fuzzers_that_never_hit_e * trials_that_hit_e / trials_with_non_empty_cov
62
+ )
63
+ return score
64
+
65
+
66
+ def calculate_differential_coverage_scores(
67
+ campaign: dict[str, dict[str, dict[int, int]]],
68
+ ) -> dict[str, float]:
69
+ """
70
+ differential_coverage(f,e) = (# fuzzers that never hit e) * (# trials of f that hit e) / (# trials of f with non-empty cov).
71
+ score(f) = sum of differential_coverage(f,e) over all edges e.
72
+ Returns scores for each fuzzer run.
73
+ """
74
+ all_edges = set(
75
+ edge
76
+ for fuzzer in campaign.values()
77
+ for trial in fuzzer.values()
78
+ for edge in trial.keys()
79
+ )
80
+
81
+ fuzzers_that_never_hit_edge: dict[int, set[str]] = {
82
+ edge: set(
83
+ fuzzer
84
+ for fuzzer, trials in campaign.items()
85
+ if not any(trial.get(edge, 0) for trial in trials.values())
86
+ )
87
+ for edge in all_edges
88
+ }
89
+
90
+ scores = {
91
+ fuzzer: calculate_fuzzer_score(trials, all_edges, fuzzers_that_never_hit_edge)
92
+ for fuzzer, trials in campaign.items()
93
+ }
94
+ return scores
95
+
96
+
97
+ def read_campaign_and_calculate_score(root: Path) -> dict[str, float]:
98
+ if not root.is_dir():
99
+ raise ValueError(f"Not a directory: {root}")
100
+
101
+ campaign_data = read_campaign_dir(root)
102
+ return calculate_differential_coverage_scores(campaign_data)
103
+
104
+
105
+ def main() -> None:
106
+ p = argparse.ArgumentParser(
107
+ description="Compute differential coverage from afl-showmap coverage dirs."
108
+ )
109
+ p.add_argument(
110
+ "dir",
111
+ type=Path,
112
+ help="Directory containing one subdir per fuzzer with coverage files",
113
+ )
114
+ args = p.parse_args()
115
+
116
+ root = args.dir.resolve()
117
+ scores = read_campaign_and_calculate_score(root)
118
+ for fuzzer, score in sorted(scores.items(), key=lambda x: x[1], reverse=True):
119
+ print(f"{fuzzer}: {score:.2f}")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: differential_coverage
3
+ Version: 0.1.0
4
+ Summary: Compute differential coverage for fuzzer runs.
5
+ Author-email: Valentin Huber <contact@valentinhuber.me>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/valentinhuber/differential_coverage
8
+ Project-URL: Documentation, https://github.com/valentinhuber/differential_coverage#readme
9
+ Keywords: fuzzing,coverage,differential-coverage
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.19.1; extra == "dev"
17
+ Requires-Dist: pre-commit>=4.3.0; extra == "dev"
18
+ Requires-Dist: pytest>=9.0.2; extra == "dev"
19
+ Requires-Dist: ruff>=0.15.0; extra == "dev"
20
+
21
+ # Differential Coverage
22
+
23
+ Absolute coverage numbers aren't painting the whole picture: Different fuzzers may reach the same total coverage, but cover different parts of the program. Fuzzer (A) may reach more coverage than fuzzer (B), but (B) may reach coverage that (A) doesn't reach — so it's still valuable.
24
+
25
+ ## Definition
26
+
27
+ Differential coverage is a measure for this. It was proposed by Leonelli et al. in their paper on TwinFuzz[^1]. This implementation relies on the formulas from the SBFT’25 Competition Report[^2]. There, Crump et al. define a fuzzers overall score compared to others as
28
+
29
+ $$
30
+ relscore(f,b,s,e) = \left|f_0\in F|e\notin \text{cov}(b,t,s)\forall t\in \text{trials}(f_0,b)\right|
31
+ \times\frac
32
+ {\left|\{t\in \text{trials}(f,b)\ |\ e \in \text{cov}(b,t,s)\}\right|}
33
+ {\left|\{t\in \text{trials}(f,b)\ |\ \text{cov}(b,t,s)\neq \emptyset\}\right|}
34
+ $$
35
+
36
+ The score of a fuzzer is then
37
+
38
+ $$
39
+ \text{score}(f,b,s)=\sum_{e\in E}\text{relscore}(f,b,s,e)
40
+ $$
41
+
42
+ ## Explanation
43
+ This can be simplified to the following:
44
+
45
+ $$
46
+ \text{differential coverage}(f,e) = \text{number of fuzzers that never hit }e
47
+ \times\frac
48
+ {\text{number of trials of }f\text{ that hit }e}
49
+ {\text{number of trials of }f\text{ with non-empty cov}}
50
+ $$
51
+ $$
52
+ \text{score}(f) = \text{sum of differential coverage}(f,e)\text{ over all edges e}
53
+ $$
54
+
55
+ ## Usage
56
+ Assume `<input_dir>` is a directory of directories for each fuzzer, where each fuzzer subdirectory contains coverage files for each trial. Currently, the output format of `afl-showmap` is supported (lines with `edge_id:count`, where we only care if `count > 0`).
57
+
58
+ PRs for other data formats welcome :)
59
+
60
+ ### Command Line Interface
61
+ ```
62
+ differential_coverage <input_dir>
63
+ ```
64
+
65
+ #### Example
66
+ [`tests/sample_coverage`](./tests/sample_coverage/) provides sample coverage data to explain differential coverage. It contains 3 fuzzers and 3 edges:
67
+ - Edge 1 is always hit by all fuzzers
68
+ - Edge 2 is always hit by `fuzzer_c`, sometimes hit by `fuzzer_a`, and never hit by `fuzzer_b`
69
+ - Edge 3 is always hit by `fuzzer_b` and `fuzzer_c`, but only sometimes by `fuzzer_a`
70
+
71
+ ```
72
+ differential_coverage tests/sample_coverage
73
+ ```
74
+ then produces
75
+ ```
76
+ fuzzer_c: 1.00
77
+ fuzzer_a: 0.50
78
+ fuzzer_b: 0.00
79
+ ```
80
+ ### API
81
+
82
+ ```python
83
+ from pathlib import Path
84
+ from differential_coverage import (
85
+ calculate_scores_for_campaign,
86
+ calculate_differential_coverage_scores,
87
+ )
88
+
89
+ # From a campaign directory (one subdir per fuzzer, each with coverage files):
90
+ scores = read_campaign_and_calculate_score(Path("path/to/campaign_dir"))
91
+ # -> dict[str, float]: fuzzer name -> score
92
+
93
+ # From in-memory campaign data (fuzzer -> trial_id -> edge_id -> count):
94
+ campaign = {...} # dict[str, dict[str, dict[int, int]]]
95
+ scores = calculate_differential_coverage_scores(campaign)
96
+ # -> dict[str, float]: fuzzer name -> score
97
+ ```
98
+
99
+ ## Installation
100
+ ```bash
101
+ pip install .
102
+ ```
103
+
104
+ ## Development
105
+ Install dev dependencies and set up the pre-commit hook (runs a couple of checks before committing):
106
+ ```bash
107
+ pip install -e ".[dev]"
108
+ pre-commit install
109
+ ```
110
+
111
+ [^1]: TWINFUZZ: Differential Testing of Video Hardware Acceleration Stacks, https://www.ndss-symposium.org/ndss-paper/twinfuzz-differential-testing-of-video-hardware-acceleration-stacks/
112
+
113
+ [^2]: SBFT’25 Competition Report — Fuzzing Track, https://ieeexplore.ieee.org/document/11086561
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/differential_coverage/__init__.py
4
+ src/differential_coverage.egg-info/PKG-INFO
5
+ src/differential_coverage.egg-info/SOURCES.txt
6
+ src/differential_coverage.egg-info/dependency_links.txt
7
+ src/differential_coverage.egg-info/entry_points.txt
8
+ src/differential_coverage.egg-info/requires.txt
9
+ src/differential_coverage.egg-info/top_level.txt
10
+ tests/test_sample_coverage.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ differential_coverage = differential_coverage:main
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ mypy>=1.19.1
4
+ pre-commit>=4.3.0
5
+ pytest>=9.0.2
6
+ ruff>=0.15.0
@@ -0,0 +1,13 @@
1
+ from pathlib import Path
2
+ from differential_coverage import read_campaign_and_calculate_score
3
+
4
+
5
+ def test_sample_coverage() -> None:
6
+ scores = read_campaign_and_calculate_score(
7
+ (Path(__file__).parent / "sample_coverage").resolve()
8
+ )
9
+ assert scores == {
10
+ "fuzzer_c": 1.0,
11
+ "fuzzer_a": 0.5,
12
+ "fuzzer_b": 0.0,
13
+ }