fuzzprep 0.1.0__py3-none-any.whl

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.
Files changed (56) hide show
  1. fuzzprep/__init__.py +3 -0
  2. fuzzprep/__main__.py +4 -0
  3. fuzzprep/agents/harness_builder/SKILL.md +156 -0
  4. fuzzprep/agents/library_builder/SKILL.md +147 -0
  5. fuzzprep/agents/scripts/check_build.sh +87 -0
  6. fuzzprep/agents/scripts/check_build_in_container.sh +73 -0
  7. fuzzprep/agents/scripts/check_dockerfile_from_scratch.sh +33 -0
  8. fuzzprep/cli.py +1109 -0
  9. fuzzprep/core/__init__.py +0 -0
  10. fuzzprep/core/agent_stream.py +304 -0
  11. fuzzprep/core/files.py +35 -0
  12. fuzzprep/core/paths.py +25 -0
  13. fuzzprep/core/reporting.py +218 -0
  14. fuzzprep/core/repos.py +217 -0
  15. fuzzprep/core/resources.py +41 -0
  16. fuzzprep/core/subprocesses.py +197 -0
  17. fuzzprep/feature_extractor/__init__.py +0 -0
  18. fuzzprep/feature_extractor/benchmark_yaml.py +92 -0
  19. fuzzprep/feature_extractor/extraction.py +184 -0
  20. fuzzprep/feature_extractor/models.py +96 -0
  21. fuzzprep/feature_extractor/native/.clang-format +1 -0
  22. fuzzprep/feature_extractor/native/CMakeLists.txt +90 -0
  23. fuzzprep/feature_extractor/native/include/feature_extractor.hpp +136 -0
  24. fuzzprep/feature_extractor/native/src/extraction_action.cpp +294 -0
  25. fuzzprep/feature_extractor/native/src/json_writer.cpp +154 -0
  26. fuzzprep/feature_extractor/native/src/macro_callbacks.cpp +75 -0
  27. fuzzprep/feature_extractor/native/src/main.cpp +130 -0
  28. fuzzprep/feature_extractor/native_build.py +99 -0
  29. fuzzprep/library_builder/__init__.py +0 -0
  30. fuzzprep/library_builder/agents.py +472 -0
  31. fuzzprep/library_builder/analysis.py +145 -0
  32. fuzzprep/library_builder/build_parameters.py +158 -0
  33. fuzzprep/library_builder/dependency_resolution.py +139 -0
  34. fuzzprep/library_builder/environments/__init__.py +0 -0
  35. fuzzprep/library_builder/environments/base.py +89 -0
  36. fuzzprep/library_builder/environments/gate.py +96 -0
  37. fuzzprep/library_builder/environments/local.py +205 -0
  38. fuzzprep/library_builder/environments/oss_fuzz.py +485 -0
  39. fuzzprep/library_builder/environments/verification.py +125 -0
  40. fuzzprep/library_builder/exploration.py +217 -0
  41. fuzzprep/library_builder/generation.py +325 -0
  42. fuzzprep/library_builder/harness_explorer.py +257 -0
  43. fuzzprep/library_builder/models.py +169 -0
  44. fuzzprep/library_builder/package_names.json +33 -0
  45. fuzzprep/library_builder/package_names.py +40 -0
  46. fuzzprep/library_builder/scripts.py +389 -0
  47. fuzzprep/library_builder/stats.py +102 -0
  48. fuzzprep/library_builder/symbol_patterns.json +65 -0
  49. fuzzprep/library_builder/timeouts.py +14 -0
  50. fuzzprep/library_builder/workspace.py +250 -0
  51. fuzzprep-0.1.0.dist-info/METADATA +255 -0
  52. fuzzprep-0.1.0.dist-info/RECORD +56 -0
  53. fuzzprep-0.1.0.dist-info/WHEEL +4 -0
  54. fuzzprep-0.1.0.dist-info/entry_points.txt +3 -0
  55. fuzzprep-0.1.0.dist-info/licenses/LICENSE +202 -0
  56. fuzzprep-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from fuzzprep.core.repos import RepoSource
7
+ from fuzzprep.library_builder.models import (
8
+ AnalysisResult,
9
+ AutotoolsSetup,
10
+ BuildSystem,
11
+ Language,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+ _C_HEADER_EXTENSIONS: frozenset[str] = frozenset({".h", ".hpp", ".hxx", ".hh"})
16
+ _C_SOURCE_EXTENSIONS: frozenset[str] = frozenset({".c"})
17
+ # `.C` is C++ by convention, and Path.suffix keeps the case the file was stored with.
18
+ _CPP_SOURCE_EXTENSIONS: frozenset[str] = frozenset({".cc", ".cpp", ".cxx", ".c++", ".C"})
19
+ _VCS_DIRS: frozenset[str] = frozenset({".git", ".hg", ".svn"})
20
+ _READ_CHUNK_BYTES = 1 << 20
21
+
22
+ # Bare Makefile is the last-resort signal: a project shipping meson.build and a convenience
23
+ # top-level Makefile is a meson project.
24
+ _BUILD_SYSTEM_CHECKS: list[tuple[BuildSystem, list[str]]] = [
25
+ (BuildSystem.CMAKE, ["CMakeLists.txt"]),
26
+ (BuildSystem.AUTOTOOLS, ["configure.ac", "configure.in", "configure"]),
27
+ (BuildSystem.MESON, ["meson.build"]),
28
+ (BuildSystem.MAKEFILE, ["Makefile", "makefile"]),
29
+ ]
30
+
31
+
32
+ class UnsupportedRepositoryError(Exception):
33
+ """Repository has no recognizable C/C++ signals."""
34
+
35
+
36
+ def analyze(source: RepoSource) -> AnalysisResult:
37
+ """Run deterministic static analysis on a repository directory."""
38
+ build_system, build_files = _detect_build_system(source.source_path)
39
+ has_headers, c_lines, cpp_lines = _scan_sources(source.source_path)
40
+ warnings: list[str] = []
41
+ language = _select_language(c_lines, cpp_lines, warnings)
42
+
43
+ if not build_files and not has_headers:
44
+ raise UnsupportedRepositoryError(
45
+ f"No C/C++ signals found in {source.source_path}: "
46
+ "no recognized build system files and no C/C++ headers."
47
+ )
48
+
49
+ if build_system == BuildSystem.UNKNOWN:
50
+ warnings.append(f"No recognized build system found in {source.source_path}.")
51
+
52
+ if not has_headers:
53
+ warnings.append(f"No C/C++ header files found in {source.source_path}.")
54
+
55
+ autotools_setup = (
56
+ _detect_autotools_setup(source.source_path)
57
+ if build_system == BuildSystem.AUTOTOOLS
58
+ else None
59
+ )
60
+
61
+ return AnalysisResult(
62
+ project_name=source.project_name,
63
+ source_path=source.source_path,
64
+ build_system=build_system,
65
+ language=language,
66
+ clone_url=source.clone_url,
67
+ repo_ref=source.repo_ref,
68
+ warnings=warnings,
69
+ autotools_setup=autotools_setup,
70
+ )
71
+
72
+
73
+ def _detect_autotools_setup(root: Path) -> AutotoolsSetup:
74
+ """Detect how to bootstrap autotools for this repository.
75
+
76
+ Priority: an existing configure > autogen.sh > bootstrap > autoreconf from configure.ac.
77
+ Only reached once the tree is known to be autotools, so a bare `bootstrap` here is the
78
+ gnulib-style wrapper rather than some other project's setup script.
79
+ """
80
+ if (root / "configure").exists():
81
+ return AutotoolsSetup.CONFIGURE
82
+ if (root / "autogen.sh").exists():
83
+ return AutotoolsSetup.AUTOGEN
84
+ if (root / "bootstrap").exists():
85
+ return AutotoolsSetup.BOOTSTRAP
86
+ return AutotoolsSetup.AUTORECONF
87
+
88
+
89
+ def _detect_build_system(root: Path) -> tuple[BuildSystem, list[Path]]:
90
+ """Detect the primary build system in priority order."""
91
+ for build_system, filenames in _BUILD_SYSTEM_CHECKS:
92
+ found = [root / name for name in filenames if (root / name).exists()]
93
+ if found:
94
+ return build_system, found
95
+ return BuildSystem.UNKNOWN, []
96
+
97
+
98
+ def _scan_sources(root: Path) -> tuple[bool, int, int]:
99
+ """Walk root once for header presence and C/C++ source line counts.
100
+
101
+ Returns whether a C/C++ header exists, the C line count, and the C++ line count. Files under
102
+ a VCS directory do not count. Headers stay out of the line counts because `.h` belongs to
103
+ both languages.
104
+ """
105
+ has_headers = False
106
+ c_lines = 0
107
+ cpp_lines = 0
108
+ for path in root.rglob("*"):
109
+ if any(part in _VCS_DIRS for part in path.relative_to(root).parts) or not path.is_file():
110
+ continue
111
+ suffix = path.suffix
112
+ if suffix in _C_HEADER_EXTENSIONS:
113
+ has_headers = True
114
+ elif suffix in _C_SOURCE_EXTENSIONS:
115
+ c_lines += _count_lines(path)
116
+ elif suffix in _CPP_SOURCE_EXTENSIONS:
117
+ cpp_lines += _count_lines(path)
118
+ return has_headers, c_lines, cpp_lines
119
+
120
+
121
+ def _count_lines(path: Path) -> int:
122
+ """Count the lines in a file, or 0 when it cannot be read."""
123
+ try:
124
+ with path.open("rb") as handle:
125
+ return sum(
126
+ chunk.count(b"\n") for chunk in iter(lambda: handle.read(_READ_CHUNK_BYTES), b"")
127
+ )
128
+ except OSError as error:
129
+ logger.debug("Skipping %s while counting lines: %s", path, error)
130
+ return 0
131
+
132
+
133
+ def _select_language(c_lines: int, cpp_lines: int, warnings: list[str]) -> Language:
134
+ """Pick the dominant language from the C and C++ source line counts.
135
+
136
+ Falls back to C++ with a warning when the tree holds neither: C++ is the safe guess, since a
137
+ C library compiled by a C++ driver links while the reverse does not.
138
+ """
139
+ logger.debug("C LoC: %d, CPP LoC %d", c_lines, cpp_lines)
140
+ if c_lines == 0 and cpp_lines == 0:
141
+ warnings.append(
142
+ "Could not determine the source language (no C or C++ sources); assuming C++."
143
+ )
144
+ return Language.CPP
145
+ return Language.CPP if cpp_lines > c_lines else Language.C
@@ -0,0 +1,158 @@
1
+ """Compiler settings used while preparing and emitting a local fuzzing build."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+
10
+ _DEFAULT_HARNESS_FLAGS = "-fsanitize=fuzzer"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BuildParameters:
15
+ """Separate compiler settings for library and final-harness compilation."""
16
+
17
+ cc: str
18
+ cxx: str
19
+ library_cflags: str
20
+ library_cxxflags: str
21
+ harness_cflags: str
22
+ harness_cxxflags: str
23
+ # Build-system-level configure options: cmake cache variables, meson options, autotools
24
+ # --enable-* switches, make variables. Baked into the generated script rather than passed
25
+ # through the environment, since cmake and meson have no environment equivalent.
26
+ library_configure_args: tuple[str, ...] = ()
27
+
28
+ @classmethod
29
+ def from_args(cls, args: object) -> BuildParameters:
30
+ """Resolve CLI arguments against the caller's existing compiler environment."""
31
+ return cls(
32
+ cc=_argument_or_environment(args, "cc", "CC", "clang"),
33
+ cxx=_argument_or_environment(args, "cxx", "CXX", "clang++"),
34
+ library_cflags=_argument_or_environment(args, "library_cflags", "CFLAGS", ""),
35
+ library_cxxflags=_argument_or_environment(args, "library_cxxflags", "CXXFLAGS", ""),
36
+ harness_cflags=_argument_or_default(args, "harness_cflags", _DEFAULT_HARNESS_FLAGS),
37
+ harness_cxxflags=_argument_or_default(args, "harness_cxxflags", _DEFAULT_HARNESS_FLAGS),
38
+ library_configure_args=_repeated_argument(args, "library_configure_args"),
39
+ )
40
+
41
+ @classmethod
42
+ def defaults(cls) -> BuildParameters:
43
+ """The settings a run with no CLI arguments would use.
44
+
45
+ For a caller that builds outside a `generate` invocation and still needs the compiler
46
+ resolution rules the CLI applies.
47
+ """
48
+ return cls.from_args(_NO_ARGUMENTS)
49
+
50
+ @contextmanager
51
+ def library_environment(self) -> Iterator[None]:
52
+ """Expose library settings to build commands and repair agents."""
53
+ with _temporary_environment(
54
+ {
55
+ "CC": self.cc,
56
+ "CXX": self.cxx,
57
+ "CFLAGS": self.library_cflags,
58
+ "CXXFLAGS": self.library_cxxflags,
59
+ }
60
+ ):
61
+ yield
62
+
63
+ @contextmanager
64
+ def harness_environment(self) -> Iterator[None]:
65
+ """Expose final-harness settings while validating and emitting its compiler."""
66
+ with _temporary_environment(
67
+ {
68
+ "CC": self.cc,
69
+ "CXX": self.cxx,
70
+ "CFLAGS": self.harness_cflags,
71
+ "CXXFLAGS": self.harness_cxxflags,
72
+ }
73
+ ):
74
+ yield
75
+
76
+ def to_dict(self) -> dict[str, str | list[str]]:
77
+ """Return the effective settings suitable for published run metadata."""
78
+ return {
79
+ "cc": self.cc,
80
+ "cxx": self.cxx,
81
+ "library_cflags": self.library_cflags,
82
+ "library_cxxflags": self.library_cxxflags,
83
+ "harness_cflags": self.harness_cflags,
84
+ "harness_cxxflags": self.harness_cxxflags,
85
+ "library_configure_args": list(self.library_configure_args),
86
+ }
87
+
88
+
89
+ # A sentinel with no attributes, so every getattr in from_args misses and each field falls
90
+ # back to the environment-or-default rules.
91
+ _NO_ARGUMENTS = object()
92
+
93
+
94
+ def _argument_or_environment(args: object, argument: str, environment: str, default: str) -> str:
95
+ value = getattr(args, argument, None)
96
+ return value if isinstance(value, str) else os.environ.get(environment, default)
97
+
98
+
99
+ def _argument_or_default(args: object, argument: str, default: str) -> str:
100
+ value = getattr(args, argument, None)
101
+ return value if isinstance(value, str) else default
102
+
103
+
104
+ def _repeated_argument(args: object, argument: str) -> tuple[str, ...]:
105
+ value = getattr(args, argument, None)
106
+ if not isinstance(value, list):
107
+ return ()
108
+ return tuple(str(item) for item in value)
109
+
110
+
111
+ _COMPILER_ENVIRONMENT_NAMES = ("CC", "CXX", "CFLAGS", "CXXFLAGS")
112
+
113
+
114
+ @contextmanager
115
+ def neutral_compiler_environment() -> Iterator[None]:
116
+ """Unset CC/CXX/CFLAGS/CXXFLAGS for the duration.
117
+
118
+ For anything that runs both the library build and the harness compile in one invocation:
119
+ the build gate, and a repair agent, which reaches the gate through check_build.sh and so
120
+ passes on whatever its own shell exports. Each generated script bakes in its own settings,
121
+ so with nothing exported both get the right ones. Leaving one stage's environment in place
122
+ applies its flags to both, and the harness flags in particular (`-fsanitize=fuzzer`
123
+ supplies its own main) make cmake's compiler check and autotools' configure link test fail.
124
+ """
125
+ original = {name: os.environ.pop(name, None) for name in _COMPILER_ENVIRONMENT_NAMES}
126
+ try:
127
+ yield
128
+ finally:
129
+ for name, previous in original.items():
130
+ if previous is not None:
131
+ os.environ[name] = previous
132
+
133
+
134
+ @contextmanager
135
+ def compile_commands_capture_environment() -> Iterator[None]:
136
+ """Ask CMake to write build/compile_commands.json for the duration.
137
+
138
+ CMake reads this as the default for the cache entry, so the flag never has to appear in the
139
+ generated build_library.sh -- capture stays something the harness applies, and the shipped
140
+ script is unaffected. Meson's Ninja backend writes the file unconditionally; Make and
141
+ Autotools have no equivalent and rely on the gate's `bear` wrap instead.
142
+ """
143
+ with _temporary_environment({"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"}):
144
+ yield
145
+
146
+
147
+ @contextmanager
148
+ def _temporary_environment(values: dict[str, str]) -> Iterator[None]:
149
+ original = {name: os.environ.get(name) for name in values}
150
+ os.environ.update(values)
151
+ try:
152
+ yield
153
+ finally:
154
+ for name, previous in original.items():
155
+ if previous is None:
156
+ os.environ.pop(name, None)
157
+ else:
158
+ os.environ[name] = previous
@@ -0,0 +1,139 @@
1
+ """Single dependency-resolution/merge point for library_builder.
2
+
3
+ Every dependency, whatever discovered it, becomes a `LibraryDependency` tagged with a
4
+ `DependencySource` and reaches the persisted state through one `merge()`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from enum import StrEnum
13
+ from pathlib import Path
14
+
15
+
16
+ class DependencySource(StrEnum):
17
+ LINKER = "linker"
18
+ LIBRARY_AGENT = "library_agent"
19
+ HARNESS_AGENT = "harness_agent"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LibraryDependency:
24
+ """One dependency, possibly only partially resolved. Only `source` is required."""
25
+
26
+ source: DependencySource
27
+ name: str | None = None
28
+ apt_package: str | None = None
29
+
30
+
31
+ @dataclass
32
+ class DependencyState:
33
+ """Persisted dependency-resolution state — the on-disk shape of state.json."""
34
+
35
+ version: int = 1
36
+ apt_packages: list[str] = field(default_factory=list)
37
+ sources: dict[str, list[str]] = field(default_factory=dict)
38
+
39
+
40
+ def load_state(path: Path) -> DependencyState:
41
+ """Load state.json for a project; return an empty DependencyState if absent/malformed."""
42
+ if not path.exists():
43
+ return DependencyState()
44
+ try:
45
+ data = json.loads(path.read_text())
46
+ except (json.JSONDecodeError, OSError):
47
+ return DependencyState()
48
+
49
+ state = DependencyState()
50
+ if isinstance(data.get("apt_packages"), list):
51
+ state.apt_packages = [str(p) for p in data["apt_packages"]]
52
+ if isinstance(data.get("sources"), dict):
53
+ state.sources = {
54
+ k: [str(p) for p in v] for k, v in data["sources"].items() if isinstance(v, list)
55
+ }
56
+ return state
57
+
58
+
59
+ def save_state(path: Path, state: DependencyState) -> None:
60
+ """Write state.json for a project."""
61
+ path.write_text(
62
+ json.dumps(
63
+ {
64
+ "version": state.version,
65
+ "apt_packages": state.apt_packages,
66
+ "sources": state.sources,
67
+ },
68
+ indent=2,
69
+ )
70
+ )
71
+
72
+
73
+ def merge(state: DependencyState, dependencies: list[LibraryDependency]) -> None:
74
+ """Union dependencies into state in-place, deduplicating while preserving order.
75
+
76
+ The only function permitted to mutate DependencyState.apt_packages/sources, and
77
+ idempotent: merging the same list twice leaves the same state as merging it once.
78
+ """
79
+ new_apt: list[str] = []
80
+ new_apt_by_source: dict[str, list[str]] = {}
81
+
82
+ for dep in dependencies:
83
+ if dep.apt_package is not None:
84
+ new_apt.append(dep.apt_package)
85
+ new_apt_by_source.setdefault(dep.source.value, []).append(dep.apt_package)
86
+
87
+ state.apt_packages = list(dict.fromkeys(state.apt_packages + new_apt))
88
+ for source_tag, apt_names in new_apt_by_source.items():
89
+ existing = state.sources.get(source_tag, [])
90
+ state.sources[source_tag] = list(dict.fromkeys(existing + apt_names))
91
+
92
+
93
+ def from_deterministic_probe(
94
+ missing_system_libs: list[str], transitive_link_flags: list[str]
95
+ ) -> list[LibraryDependency]:
96
+ """Translate the deterministic harness-link probe's outputs into LibraryDependency entries.
97
+
98
+ Unions the libs the linker reported missing with the bare names in transitive_link_flags,
99
+ which it resolved silently because the exploration host already had them. Each name is
100
+ translated on its own, so a system library — which translate() drops without recording it
101
+ as unknown — is excluded rather than misclassified.
102
+ """
103
+ from fuzzprep.library_builder.harness_explorer import lib_names_from_link_flags
104
+ from fuzzprep.library_builder.package_names import translate
105
+
106
+ names_from_flags = lib_names_from_link_flags(transitive_link_flags)
107
+ names = list(dict.fromkeys(missing_system_libs + names_from_flags))
108
+
109
+ dependencies: list[LibraryDependency] = []
110
+ for name in names:
111
+ translation = translate([name])
112
+ if not (translation.apt_packages or translation.unknown_libs):
113
+ continue # a system library, which translate() drops silently
114
+ dependencies.append(
115
+ LibraryDependency(
116
+ source=DependencySource.LINKER,
117
+ name=name,
118
+ apt_package=translation.apt_packages[0] if translation.apt_packages else None,
119
+ )
120
+ )
121
+ return dependencies
122
+
123
+
124
+ def from_agent_report(
125
+ missing_libs: list[str],
126
+ missing_apt_packages: list[str],
127
+ *,
128
+ source: DependencySource,
129
+ ) -> list[LibraryDependency]:
130
+ """Wrap an agent's self-reported dependency lists into LibraryDependency entries.
131
+
132
+ Zips the two lists positionally: index i's name and apt package are only *assumed* to
133
+ describe the same dependency. Nothing guarantees that when an agent reports more than one
134
+ dependency in a run — a known limitation.
135
+ """
136
+ return [
137
+ LibraryDependency(source=source, name=name, apt_package=apt)
138
+ for name, apt in itertools.zip_longest(missing_libs, missing_apt_packages)
139
+ ]
File without changes
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ from fuzzprep.library_builder.timeouts import DEFAULT_BUILD_TIMEOUT_SECONDS
8
+
9
+ if TYPE_CHECKING:
10
+ from fuzzprep.library_builder.build_parameters import BuildParameters
11
+ from fuzzprep.library_builder.models import (
12
+ AnalysisResult,
13
+ BuildExplorationResult,
14
+ HarnessExplorationResult,
15
+ Language,
16
+ )
17
+
18
+
19
+ class Environment(Enum):
20
+ LOCAL = "local"
21
+ OSS_FUZZ = "oss-fuzz"
22
+
23
+ @property
24
+ def harness_probe_command(self) -> list[str]:
25
+ """How to recompile the harnesses in this environment.
26
+
27
+ The generated scripts are environment-independent; entering them is not. OSS-Fuzz goes
28
+ through the base image's `compile`, which assembles what the image only half-provides:
29
+ it resolves SANITIZER_FLAGS into CFLAGS/CXXFLAGS, and exports
30
+ LIB_FUZZING_ENGINE=-fsanitize=fuzzer in place of the deprecated archive path the
31
+ image's ENV names. Running compile_harnesses.sh directly there fails on that missing
32
+ archive, or — if only the engine flag is patched — links an uninstrumented harness.
33
+
34
+ `compile` runs build.sh, so it re-enters the library build too. build_library.sh's
35
+ skip-if-already-built guard keeps that cheap enough to repeat per discovery attempt,
36
+ and the real link line is what makes discovery faithful: sanitizer flags change which
37
+ symbols come out undefined.
38
+
39
+ Hence --base-image accepts OSS-Fuzz-style images only, and OssFuzzExecutor checks for
40
+ `compile` before probing.
41
+ """
42
+ if self is Environment.OSS_FUZZ:
43
+ return ["bash", "-c", "compile"]
44
+ return ["bash", "compile_harnesses.sh"]
45
+
46
+
47
+ class EnvironmentUnavailableError(Exception):
48
+ """The selected environment itself is not usable (e.g. Docker daemon unreachable).
49
+
50
+ Not a build failure: callers must not route it to agent fallback, since no edit to a
51
+ build script fixes an unavailable environment.
52
+ """
53
+
54
+ def __init__(self, message: str, environment: Environment) -> None:
55
+ super().__init__(message)
56
+ self.environment = environment
57
+
58
+
59
+ class EnvironmentExecutor(Protocol):
60
+ """Runs the two generate pipeline stages in a specific target environment."""
61
+
62
+ def run_library_build(
63
+ self,
64
+ analysis: AnalysisResult,
65
+ workdir: Path,
66
+ *,
67
+ timeout: int = DEFAULT_BUILD_TIMEOUT_SECONDS,
68
+ parameters: BuildParameters | None = None,
69
+ ) -> BuildExplorationResult: ...
70
+
71
+ def run_harness_compile( # noqa: PLR0913 -- paths and build configuration are independent inputs
72
+ self,
73
+ install_dir: Path,
74
+ workdir: Path,
75
+ language: Language,
76
+ *,
77
+ extra_include_paths: list[str] | None = None,
78
+ extra_library_paths: list[str] | None = None,
79
+ parameters: BuildParameters | None = None,
80
+ library_llm_used: bool = False,
81
+ ) -> HarnessExplorationResult: ...
82
+
83
+ def check_availability(self, parameters: BuildParameters | None = None) -> None:
84
+ """Raise EnvironmentUnavailableError if this environment cannot be used.
85
+
86
+ `parameters` is the run's resolved compiler settings, since what an environment needs
87
+ can depend on them: the local host must provide the harness toolchain itself.
88
+ """
89
+ ...
@@ -0,0 +1,96 @@
1
+ """Applying the shared build gate to a stage's probe result.
2
+
3
+ Both executors reach the same conclusion the same way, so the decision lives here once:
4
+ run the gate when the probe found something worth gating, and otherwise report the command
5
+ that would reproduce the failure without paying to rerun it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ from pathlib import Path
12
+
13
+ from fuzzprep.library_builder import workspace
14
+ from fuzzprep.library_builder.build_parameters import (
15
+ compile_commands_capture_environment,
16
+ neutral_compiler_environment,
17
+ )
18
+ from fuzzprep.library_builder.environments import verification
19
+ from fuzzprep.library_builder.environments.base import Environment
20
+ from fuzzprep.library_builder.models import HarnessExplorationResult
21
+
22
+
23
+ def apply_to_harness_result( # noqa: PLR0913 -- 4 keyword-only inputs, each independently meaningful
24
+ harness_result: HarnessExplorationResult,
25
+ workdir: Path,
26
+ *,
27
+ environment: Environment,
28
+ project_name: str,
29
+ library_llm_used: bool = False,
30
+ bypass_scratch_validation: bool = False,
31
+ ) -> HarnessExplorationResult:
32
+ """Gate a harness probe's outcome with agents/scripts/check_build.sh.
33
+
34
+ Skipped when the probe already failed or had nothing to link against: the gate would only
35
+ reconfirm the same failure at the cost of a full rebuild. Its command is still reported, so
36
+ the diagnostic says how to reproduce it.
37
+
38
+ The gate rebuilds the library from nothing only when something changed since the last
39
+ cold build, which is exactly when a repair agent produced this one. On the deterministic
40
+ lane explore() already deleted build/ and install/ before building, so a rebuild here
41
+ would repeat that build to reach the same state -- the run's single largest avoidable
42
+ cost. bypass_scratch_validation drops the rebuild on the agent lane too, trading the
43
+ from-scratch guarantee for speed at the caller's explicit request.
44
+ """
45
+ keep_artifacts = verification.gate_keeps_artifacts(
46
+ library_llm_used=library_llm_used,
47
+ bypass_scratch_validation=bypass_scratch_validation,
48
+ )
49
+ command = verification.verification_command(
50
+ workdir,
51
+ environment=environment,
52
+ project_name=project_name,
53
+ keep_artifacts=keep_artifacts,
54
+ )
55
+ if not harness_result.static_libs or not harness_result.succeeded:
56
+ return dataclasses.replace(
57
+ harness_result, command=command, gate_keeps_artifacts=keep_artifacts
58
+ )
59
+
60
+ # The gate builds the library and compiles harnesses in one invocation, so no stage's
61
+ # compiler environment may be in effect: each generated script bakes in its own.
62
+ with neutral_compiler_environment(), compile_commands_capture_environment():
63
+ result = verification.run_verification(
64
+ workdir,
65
+ environment=environment,
66
+ project_name=project_name,
67
+ keep_artifacts=keep_artifacts,
68
+ )
69
+ _localize_compile_commands(workdir, environment=environment)
70
+ return dataclasses.replace(
71
+ harness_result,
72
+ succeeded=result.passed,
73
+ command=result.command,
74
+ stdout=result.stdout,
75
+ stderr=result.stderr,
76
+ exit_code=0 if result.passed else 1,
77
+ duration_seconds=result.duration_seconds,
78
+ gate_keeps_artifacts=keep_artifacts,
79
+ )
80
+
81
+
82
+ def _localize_compile_commands(workdir: Path, *, environment: Environment) -> None:
83
+ """Rewrite a container-built capture's /src paths back to the host workspace.
84
+
85
+ Done here because here is where the container build happens, so every lane -- deterministic
86
+ or agent-repaired -- gets the same treatment. Keyed to the library-build result instead, it
87
+ was skipped whenever a repair agent produced the build, publishing container paths.
88
+ """
89
+ if environment is not Environment.OSS_FUZZ:
90
+ return
91
+ # Imported here: oss_fuzz imports this module, so a module-level import would be circular.
92
+ from fuzzprep.library_builder.environments.oss_fuzz import rewrite_compile_commands_paths
93
+
94
+ captured = workspace.find_compile_commands(workdir)
95
+ if captured is not None:
96
+ rewrite_compile_commands_paths(captured, workdir)