diff --git a/certora_autosetup/build_systems/foundry.py b/certora_autosetup/build_systems/foundry.py index f49c844d..d712a5ff 100644 --- a/certora_autosetup/build_systems/foundry.py +++ b/certora_autosetup/build_systems/foundry.py @@ -403,6 +403,23 @@ def holds_artifacts(artifacts_dir: Path) -> bool: child.is_dir() and child.name.endswith(".sol") for child in artifacts_dir.iterdir() ) + @staticmethod + def recorded_source(artifact: dict) -> Optional[str]: + """Foundry records it under `metadata.settings.compilationTarget`, as the single key + of a one-entry `{source: ContractName}` map, relative to the project. More than one + entry means the artifact covers several sources and names none of them, so it says + nothing about which project wrote it.""" + metadata = artifact.get("metadata") + if not isinstance(metadata, dict): + return None + settings = metadata.get("settings") + if not isinstance(settings, dict): + return None + target = settings.get("compilationTarget") + if not isinstance(target, dict) or len(target) != 1: + return None + return str(next(iter(target))) + def filter_artifacts(self, artifacts_dir: Path) -> List[Path]: """ Filter Foundry artifacts - all .json files except those in build-info/ directories. diff --git a/certora_autosetup/build_systems/hardhat.py b/certora_autosetup/build_systems/hardhat.py index 1585a56c..8c23149a 100644 --- a/certora_autosetup/build_systems/hardhat.py +++ b/certora_autosetup/build_systems/hardhat.py @@ -394,6 +394,16 @@ def holds_artifacts(artifacts_dir: Path) -> bool: return (any((artifacts_dir / "contracts").rglob("*.json")) or any((artifacts_dir / "build-info").glob("*.json"))) + @staticmethod + def recorded_source(artifact: dict) -> Optional[str]: + """Hardhat records it as a project-relative `sourceName`. The `_format` stamp is what + separates a real artifact from the `.dbg.json` sidecars and the solc standard-json + under `build-info/`, which sit in the same tree and carry no source of their own.""" + if artifact.get("_format") != "hh-sol-artifact-1": + return None + source_name = artifact.get("sourceName") + return source_name if isinstance(source_name, str) else None + def filter_artifacts(self, artifacts_dir: Path) -> List[Path]: """ Filter Hardhat artifacts - only contracts/, exclude .dbg.json and build-info/. diff --git a/certora_autosetup/build_systems/manager.py b/certora_autosetup/build_systems/manager.py index 63724c4f..23ee4c63 100644 --- a/certora_autosetup/build_systems/manager.py +++ b/certora_autosetup/build_systems/manager.py @@ -7,6 +7,7 @@ parsing and artifact filtering. """ +import json import os import sys from abc import ABC, abstractmethod @@ -128,6 +129,77 @@ def holds_artifacts(artifacts_dir: Path) -> bool: """ pass + @staticmethod + @abstractmethod + def recorded_source(artifact: dict) -> Optional[str]: + """ + The source path *artifact* records having been compiled from, or None. + + Every build system stamps this into its artifacts, under its own key and in its own + frame: some relative to the project that ran the build, some absolute. Callers get + the value as written and decide what to do with it; ``artifacts_belong_to`` is the + one that cares. None covers both a payload this build system did not write (a + sidecar or a build-info file caught by the same directory walk) and one that records + no source at all. + + Args: + artifact: Parsed JSON of a single artifact file + + Returns: + Source path as recorded, or None if this artifact records none + """ + pass + + @classmethod + def artifacts_belong_to(cls, config_dir: Path, artifacts_dir: Path, limit: int = 20) -> bool: + """ + Whether the artifacts in *artifacts_dir* were written by the project at *config_dir*. + + Two configs can name the same physical artifact directory — a root ``foundry.toml`` + with ``out = 'pkg/out'`` next to ``pkg/foundry.toml`` with the default ``out`` — and + only one of them ran. The artifacts settle it: the source path each one records + resolves against the project that produced it, so if none of them lands inside + *config_dir*, these are somebody else's artifacts and this is the wrong frame to + read them in. + + An absolute recorded path is tested for containment; a relative one is resolved + against *config_dir* and tested for existence. Sampling stops at the first artifact + that answers, so the common case reads one file. + + Artifacts that record no source at all (older Foundry, metadata stripped) answer + True: absence of evidence leaves the caller where it was. + + Args: + config_dir: Candidate project directory + artifacts_dir: Directory holding this build system's artifacts + limit: How many artifacts to read before giving up on finding a recorded source + + Returns: + True if these artifacts are this project's, or record nothing to judge by + """ + read = 0 + for json_file in artifacts_dir.rglob("*.json"): + if read >= limit: + break + try: + with json_file.open() as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + continue + if not isinstance(data, dict): + continue + source = cls.recorded_source(data) + if source is None: + continue + read += 1 + candidate = Path(source) + if candidate.is_absolute(): + if candidate.is_relative_to(config_dir): + return True + elif (config_dir / candidate).exists(): + return True + return read == 0 + @abstractmethod def filter_artifacts(self, artifacts_dir: Path) -> List[Path]: """ diff --git a/certora_autosetup/build_systems/truffle.py b/certora_autosetup/build_systems/truffle.py index a96db48d..61ecd203 100644 --- a/certora_autosetup/build_systems/truffle.py +++ b/certora_autosetup/build_systems/truffle.py @@ -103,6 +103,13 @@ def holds_artifacts(artifacts_dir: Path) -> bool: """Truffle writes one flat `.json` per contract into its build dir.""" return artifacts_dir.is_dir() and any(artifacts_dir.glob("*.json")) + @staticmethod + def recorded_source(artifact: dict) -> Optional[str]: + """Truffle records `sourcePath`, and unlike the others it is the absolute path the + source had on the machine that compiled it.""" + source_path = artifact.get("sourcePath") + return source_path if isinstance(source_path, str) else None + def filter_artifacts(self, artifacts_dir: Path) -> List[Path]: """Return Truffle's artifact JSONs — one flat `.json` per contract.""" return self._walk_and_filter_artifacts( diff --git a/certora_autosetup/utils/project_dir.py b/certora_autosetup/utils/project_dir.py index 61f81f29..f471e24d 100644 --- a/certora_autosetup/utils/project_dir.py +++ b/certora_autosetup/utils/project_dir.py @@ -9,6 +9,9 @@ monorepos vendor a per-package ``foundry.toml`` under ``modules/`` or ``lib/`` while the root config is what actually builds the tree, so the nearest config is often not the one that ran. See ``find_build_config_dir``. + +Reading the artifacts is the build systems' own business, so this module locates the +directory and asks the matching ``BuildSystemManager`` whether what is in it is theirs. """ import os @@ -16,16 +19,10 @@ from pathlib import Path from typing import Optional -# Build config filenames that mark a directory as a project root, in no particular order — -# presence of any one of them is enough to anchor there. Truffle has two: `truffle.js` is -# the v4 spelling, `truffle-config.js` everything since. -BUILD_CONFIG_FILENAMES = ( - "foundry.toml", - "hardhat.config.js", - "hardhat.config.ts", - "truffle-config.js", - "truffle.js", -) +from certora_autosetup.build_systems.foundry import FoundryManager +from certora_autosetup.build_systems.hardhat import HardhatManager +from certora_autosetup.build_systems.manager import BuildSystemManager +from certora_autosetup.build_systems.truffle import TruffleManager # Hardhat writes artifacts here unless the config sets `paths.artifacts`, and Truffle # unless it sets `contracts_build_directory`. Reading either override means running node @@ -65,31 +62,42 @@ def hardhat_artifact_dir(config_dir: Path) -> Optional[Path]: def truffle_artifact_dir(config_dir: Path) -> Optional[Path]: - """Where a Truffle project at *config_dir* writes artifacts, or None if it has no config.""" + """Where a Truffle project at *config_dir* writes artifacts, or None if it has no config. + + Truffle answers to two config names: ``truffle.js`` is the v4 spelling, ``truffle-config.js`` + everything since. + """ if (config_dir / "truffle-config.js").exists() or (config_dir / "truffle.js").exists(): return config_dir / TRUFFLE_DEFAULT_BUILD_DIR return None -def _artifact_dir_of(config_dir: Path) -> Optional[Path]: - """Where *config_dir*'s build system would put artifacts, or None if it holds no config. +def _artifact_dir_of(config_dir: Path) -> Optional[tuple[type[BuildSystemManager], Path]]: + """*config_dir*'s build system and where it would put artifacts, or None if it holds no + config. A directory holding several configs answers for the first of them, in the same order ``BuildSystemDetector`` ranks them: any of the three is enough to recognise the - directory as a project that got built, which is all the caller asks. + directory as a project that got built, which is all the caller asks. The manager comes + back with the path because the manager is what knows how to read what it writes. """ - return ( - foundry_artifact_dir(config_dir) - or hardhat_artifact_dir(config_dir) - or truffle_artifact_dir(config_dir) - ) + for manager, artifact_dir_of in ( + (FoundryManager, foundry_artifact_dir), + (HardhatManager, hardhat_artifact_dir), + (TruffleManager, truffle_artifact_dir), + ): + artifacts = artifact_dir_of(config_dir) + if artifacts is not None: + return manager, artifacts + return None def find_build_config_dir(contract_path: Path, root: Path) -> Path: """Return the directory whose build system actually produced *contract_path*'s artifacts. - Walks up from the contract's own directory to *root*, and returns the nearest ancestor - that both holds a build config **and** has its artifact directory on disk. Falling back + Walks up from the contract's own directory to *root*, and returns the nearest ancestor that + holds a build config, has its artifact directory on disk, and whose build system recognises + those artifacts as its own project's (``BuildSystemManager.artifacts_belong_to``). Falling back to *root* when nothing qualifies is deliberate: a build config alone does not mean that project is the one that got built. Monorepos routinely vendor per-package ``foundry.toml`` files under ``modules/`` or ``lib/`` while the root config builds the whole tree into a @@ -120,9 +128,11 @@ def find_build_config_dir(contract_path: Path, root: Path) -> Path: current = absolute_contract.resolve().parent while True: - artifacts = _artifact_dir_of(current) - if artifacts is not None and artifacts.is_dir(): - return current + found = _artifact_dir_of(current) + if found is not None: + manager, artifacts = found + if artifacts.is_dir() and manager.artifacts_belong_to(current, artifacts): + return current if current == root: return root current = current.parent diff --git a/tests/test_project_dir.py b/tests/test_project_dir.py index 99f036f6..be388f16 100644 --- a/tests/test_project_dir.py +++ b/tests/test_project_dir.py @@ -5,8 +5,12 @@ has to be found by walking up from the contract rather than by looking where the run began. """ +import json from pathlib import Path +from certora_autosetup.build_systems.foundry import FoundryManager +from certora_autosetup.build_systems.hardhat import HardhatManager +from certora_autosetup.build_systems.truffle import TruffleManager from certora_autosetup.utils.project_dir import ( describe_build_config_dir, find_build_config_dir, @@ -97,6 +101,155 @@ def test_no_artifacts_anywhere_falls_back_to_root(tmp_path: Path) -> None: assert find_build_config_dir(contract, tmp_path) == tmp_path.resolve() +def _artifact(out_dir: Path, source: str, name: str) -> None: + """Write a Foundry artifact that records the source it was compiled from.""" + d = out_dir / Path(source).name + d.mkdir(parents=True, exist_ok=True) + (d / f"{name}.json").write_text( + '{"metadata": {"settings": {"compilationTarget": {"%s": "%s"}}}}' % (source, name) + ) + + +def test_shared_out_dir_resolves_to_the_project_that_wrote_it(tmp_path: Path) -> None: + # Two configs naming the same physical artifact directory: the root builds the tree with + # out = 'pkg/out', and pkg/ carries its own foundry.toml with the default out = 'out'. Only + # the root ran, and its artifacts say so by recording paths relative to the root, so pkg/ is + # the wrong frame to read them in even though it has a config and a populated out/ beside it. + (tmp_path / "foundry.toml").write_text("[profile.default]\nsrc = 'pkg/src'\nout = 'pkg/out'\n") + pkg = tmp_path / "pkg" + (pkg / "src").mkdir(parents=True) + (pkg / "foundry.toml").write_text("[profile.default]\nsrc = 'src'\nout = 'out'\n") + contract = pkg / "src" / "Widget.sol" + contract.write_text("contract Widget {}") + _artifact(pkg / "out", "pkg/src/Widget.sol", "Widget") + + assert find_build_config_dir(contract, tmp_path) == tmp_path.resolve() + + +def test_nested_project_that_wrote_its_own_artifacts_still_wins(tmp_path: Path) -> None: + # The counterpart: the nested project really did build, and its artifacts record paths + # relative to itself. Anchoring there is right, and the shared-out check must not undo it. + (tmp_path / "package.json").write_text("{}") + pkg = tmp_path / "pkg" + (pkg / "src").mkdir(parents=True) + (pkg / "foundry.toml").write_text("[profile.default]\n") + contract = pkg / "src" / "Widget.sol" + contract.write_text("contract Widget {}") + _artifact(pkg / "out", "src/Widget.sol", "Widget") + + assert find_build_config_dir(contract, tmp_path) == pkg.resolve() + + +def test_artifacts_without_recorded_sources_keep_the_nearest_built_config(tmp_path: Path) -> None: + # Older Foundry, or metadata stripped: nothing says which project wrote these, and absence + # of evidence should not move the anchor. + (tmp_path / "package.json").write_text("{}") + pkg = tmp_path / "pkg" + (pkg / "src").mkdir(parents=True) + (pkg / "foundry.toml").write_text("[profile.default]\n") + contract = pkg / "src" / "Widget.sol" + contract.write_text("contract Widget {}") + (pkg / "out" / "Widget.sol").mkdir(parents=True) + (pkg / "out" / "Widget.sol" / "Widget.json").write_text('{"abi": []}') + + assert find_build_config_dir(contract, tmp_path) == pkg.resolve() + + +def _truffle_artifact(build_dir: Path, name: str, source: Path) -> None: + """Write a Truffle artifact. Its `sourcePath` is the absolute path the source had on the + machine that compiled it, which is what separates it from the other two build systems.""" + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / f"{name}.json").write_text( + json.dumps({"contractName": name, "sourcePath": str(source), "bytecode": "0x60"}) + ) + + +def test_truffle_shared_build_dir_resolves_to_the_project_that_wrote_it(tmp_path: Path) -> None: + # The Truffle spelling of the shared-artifact-directory problem. Both configs answer for the + # same default build/contracts/, only the root ran, and its artifacts name sources under the + # root — so pkg/ is holding somebody else's output. + (tmp_path / "truffle-config.js").write_text("module.exports = {};") + pkg = tmp_path / "pkg" + (pkg / "contracts").mkdir(parents=True) + (pkg / "truffle-config.js").write_text("module.exports = {};") + contract = pkg / "contracts" / "Widget.sol" + contract.write_text("contract Widget {}") + root_source = tmp_path / "contracts" / "Widget.sol" + root_source.parent.mkdir(parents=True) + root_source.write_text("contract Widget {}") + _truffle_artifact(pkg / "build" / "contracts", "Widget", root_source) + + assert find_build_config_dir(contract, tmp_path) == tmp_path.resolve() + + +def test_truffle_project_that_wrote_its_own_artifacts_still_wins(tmp_path: Path) -> None: + # The counterpart, so the absolute-path test is containment and not a blanket rejection. + (tmp_path / "package.json").write_text("{}") + pkg = tmp_path / "pkg" + (pkg / "contracts").mkdir(parents=True) + (pkg / "truffle-config.js").write_text("module.exports = {};") + contract = pkg / "contracts" / "Widget.sol" + contract.write_text("contract Widget {}") + _truffle_artifact(pkg / "build" / "contracts", "Widget", contract) + + assert find_build_config_dir(contract, tmp_path) == pkg.resolve() + + +def test_an_absolute_source_outside_the_candidate_is_not_ownership(tmp_path: Path) -> None: + # An absolute recorded path has to be tested for containment. Joining it onto the candidate + # would discard the candidate entirely under pathlib, so a source that exists somewhere else + # on disk would read as proof that this directory built it. + root = tmp_path / "repo" + (root / "contracts").mkdir(parents=True) + (root / "truffle-config.js").write_text("module.exports = {};") + contract = root / "contracts" / "Widget.sol" + contract.write_text("contract Widget {}") + elsewhere = tmp_path / "elsewhere" / "Widget.sol" + elsewhere.parent.mkdir(parents=True) + elsewhere.write_text("contract Widget {}") + _truffle_artifact(root / "build" / "contracts", "Widget", elsewhere) + + assert TruffleManager.artifacts_belong_to(root, root / "build" / "contracts") is False + + +def test_hardhat_sidecars_are_not_read_as_source_records(tmp_path: Path) -> None: + # `.dbg.json` and build-info/ sit in the same tree as the artifacts and record no source of + # their own; the `_format` stamp is what tells them apart. + artifacts = tmp_path / "artifacts" + (artifacts / "contracts" / "Widget.sol").mkdir(parents=True) + (artifacts / "contracts" / "Widget.sol" / "Widget.dbg.json").write_text( + '{"buildInfo": "../../build-info/1234.json"}' + ) + (artifacts / "build-info").mkdir(parents=True) + (artifacts / "build-info" / "1234.json").write_text('{"solcVersion": "0.8.20"}') + + assert HardhatManager.recorded_source(json.loads('{"buildInfo": "x"}')) is None + # Nothing in the tree records a source, so the directory cannot say whose it is. + assert HardhatManager.artifacts_belong_to(tmp_path, artifacts) is True + + +def test_hardhat_artifact_names_its_source(tmp_path: Path) -> None: + artifacts = tmp_path / "artifacts" + (artifacts / "contracts" / "Widget.sol").mkdir(parents=True) + (artifacts / "contracts" / "Widget.sol" / "Widget.json").write_text( + json.dumps({"_format": "hh-sol-artifact-1", "sourceName": "contracts/Widget.sol"}) + ) + (tmp_path / "contracts").mkdir() + (tmp_path / "contracts" / "Widget.sol").write_text("contract Widget {}") + + assert HardhatManager.artifacts_belong_to(tmp_path, artifacts) is True + assert HardhatManager.artifacts_belong_to(tmp_path / "pkg", artifacts) is False + + +def test_foundry_artifact_covering_several_sources_names_none(tmp_path: Path) -> None: + # compilationTarget with more than one entry does not identify a single source, so it says + # nothing about which project wrote the artifact. + both = {"a/A.sol": "A", "b/B.sol": "B"} + assert FoundryManager.recorded_source({"metadata": {"settings": {"compilationTarget": both}}}) is None + assert FoundryManager.recorded_source({"abi": []}) is None + assert TruffleManager.recorded_source({"contractName": "Widget"}) is None + + def test_honors_a_custom_foundry_out_dir(tmp_path: Path) -> None: project = tmp_path / "sub" (project / "src").mkdir(parents=True)