diff --git a/slither.config.json b/slither.config.json index b25a906..18a659d 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|RegistryDeploySuites|BuildScript)\\.sol", + "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshotBase|VerifySnapshot)|RegistryDeploySuites|BuildScript)\\.sol", "detectors_to_exclude": "assembly" } diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index ec070e6..3f07fab 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -2,229 +2,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; -import {DeploySuite} from "./RainDeploySuitesBase.sol"; -import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {RainDeployVerifySnapshotBase} from "./RainDeployVerifySnapshotBase.sol"; import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; -/// Thrown when the deploy address recorded for a version is not the address its -/// own creation code derives. -/// @param suite The suite that failed. -/// @param storedAddress The address the suite records. -/// @param derivedAddress The address its creation code derives. -error StoredAddressMismatch(string suite, address storedAddress, address derivedAddress); - -/// Thrown when the deployed code hash recorded for a version is not the hash -/// its own creation code produces. -/// @param suite The suite that failed. -/// @param storedCodeHash The code hash the suite records. -/// @param derivedCodeHash The code hash its creation code produces. -error StoredCodeHashMismatch(string suite, bytes32 storedCodeHash, bytes32 derivedCodeHash); - -/// Thrown when the runtime code recorded for a version does not hash to the -/// code hash recorded beside it. -/// @param suite The suite that failed. -/// @param storedBytecodeHash The code hash the suite records. -/// @param runtimeCodeHash The hash of the runtime code the suite records. -error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash); - -/// Thrown when a file in the frozen record is declared by no released suite. -/// The record is append-only, so this never goes away by itself: a release the -/// declaration missed is a release the chain group never asks about, and the -/// chain group passing means nothing for it. -/// @param path The frozen record file no released suite declares. -error FrozenSnapshotNotReleased(string path); - -/// Thrown when a file in the frozen record declares no deployed address. The -/// record holds generated snapshots and nothing else, and `DEPLOYED_ADDRESS` is -/// what makes one the record of a deployment rather than a file that happens to -/// be in a release directory. Distinct from `FrozenSnapshotNotReleased`, which -/// is a declaration that is missing something — this is a record that cannot be -/// read at all, and reporting it as undeclared would send the reader after the -/// wrong thing. -/// @param path The record file with no `DEPLOYED_ADDRESS` declaration. -error FrozenSnapshotUnreadable(string path); - /// @title RainDeployVerifySnapshot -/// @notice Every deploy-pin assertion that needs no network, for every suite -/// a repo declares. Three groups, which catch different things and are -/// documented as such because it is easy to read the first as covering the -/// second. -/// -/// **Internal to the recorded set.** The address a suite's creation code -/// derives is the address it records, the code hash that creation code produces -/// is the code hash it records, and the runtime code it records hashes to that -/// same code hash. These are real derivations and they catch a set generated -/// inconsistently — a hand-edited constant, a snapshot regenerated for one -/// field and not the others, an address copied from the wrong tag. -/// -/// They CANNOT catch a snapshot of the wrong contract. A consistent snapshot of -/// the wrong thing satisfies all three, because all three only ask the recorded -/// bytes to agree with each other, and the wrong contract's bytes agree with -/// each other perfectly. -/// -/// **Anchored to source.** The candidate's recorded creation code is the -/// creation code this repo compiles. This is the only check in the whole suite -/// that catches a snapshot of the wrong contract, and it applies to the -/// candidate alone: a released tag is meant to have diverged from current -/// source, so anchoring one to source asserts something that is false by -/// design. -/// -/// That one is not defined here. It lives on `RainDeploySuitesBase`, because -/// `RainDeployBroadcast` runs it before it broadcasts and cannot reach anything -/// on this side — this inherits `Test`. Here it is a test; there it is the last -/// thing standing between a stale generated file and a permanent `CREATE2` -/// address on every chain a dispatch reaches. +/// @notice What a deploy repo inherits: every deploy-pin assertion that needs +/// no network, bound to that repo. `RainDeployVerifySnapshotBase` is where all +/// three groups are defined and documented; this adds the one test whose +/// subject is the repo's real frozen record on disk rather than anything the +/// inheriting contract declares. /// -/// **Anchored to the record.** Every file in the frozen record — the -/// append-only `src/generated//` directories — is declared by a released -/// suite. This is the one check that is about the DECLARATION rather than about -/// what a declared suite records, and it exists because everything anchored to -/// a chain reads `releasedSuites()`, which is a separate file from the record -/// it describes. A release missing from it is not caught anywhere else, by -/// anything: it simply stops being checked, and every check there is stays -/// green. -/// -/// None of the three can catch a suite that was never deployed, or that is no -/// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a -/// substitute for it — but the record check is what makes its scope complete, -/// because a release it is never handed is a release it cannot fail on. -abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { - /// Checks one suite against itself: derive from its creation code, then - /// require everything it records to agree with the derivation. - /// @param suite The suite to check. - function checkInternallyConsistent(DeploySuite memory suite) internal { - DerivedDeploy memory derived = deriveDeployment(suite); - - if (suite.storedDeployedAddress != derived.deployedAddress) { - revert StoredAddressMismatch(suite.suite, suite.storedDeployedAddress, derived.deployedAddress); - } - - if (suite.storedBytecodeHash != derived.bytecodeHash) { - revert StoredCodeHashMismatch(suite.suite, suite.storedBytecodeHash, derived.bytecodeHash); - } - - bytes32 runtimeCodeHash = keccak256(suite.storedRuntimeCode); - if (suite.storedBytecodeHash != runtimeCodeHash) { - revert StoredRuntimeCodeHashMismatch(suite.suite, suite.storedBytecodeHash, runtimeCodeHash); - } - } - - /// @dev The declaration a generated snapshot records its deploy address in. - /// Matched whole and from the START of its line, so what is being looked - /// for is the DECLARATION: it cannot be satisfied by characters that happen - /// to occur inside a hex payload, nor by a line that merely CONTAINS the - /// declaration text — a commented-out copy carrying some other address is - /// exactly the hand edit this whole group exists to catch, and it is at - /// file scope in every generated snapshot, so there is no indentation to - /// allow for. - string constant DEPLOYED_ADDRESS_DECLARATION = "address constant DEPLOYED_ADDRESS ="; - - /// The address a frozen record declares as its deploy address. - /// - /// `LibCodeGen` emits an address constant on ONE line — wrapping needs 120 - /// characters and this declaration occupies 88 — so the declaration is a - /// line, and its value is that line's last token with the type wrapper and - /// the terminator stripped. `address(0x...);` and a bare `0x...;` read the - /// same, so which wrapper the generator chose is not something this has to - /// know. - /// - /// That every generated snapshot HAS this declaration, second, of type - /// `address`, is pinned by `GeneratedSnapshotShapeTest` against the - /// compiler's own AST. Read from the text here rather than from that AST - /// because a record is reached by its PATH, which is what the walk returns, - /// while its artifact path is not something a caller can name — foundry - /// disambiguates those by whatever else happens to share the basename. - /// @param path The record file, for the error only. - /// @param record The record file's contents. - /// @return The address the record declares. - function recordedDeployedAddress(string memory path, string memory record) internal pure returns (address) { - string[] memory lines = vm.split(record, "\n"); - for (uint256 i = 0; i < lines.length; i++) { - if (vm.indexOf(lines[i], DEPLOYED_ADDRESS_DECLARATION) != 0) { - continue; - } - string[] memory tokens = vm.split(lines[i], " "); - string memory literal = tokens[tokens.length - 1]; - return vm.parseAddress(vm.replace(vm.replace(vm.replace(literal, "address(", ""), ")", ""), ";", "")); - } - revert FrozenSnapshotUnreadable(path); - } - - /// Checks the frozen record against the released declaration: every file in - /// the record is declared by a released suite. - /// - /// `releasedSuites()` is a generated file, and everything anchored to a - /// chain reads it. A frozen tag it does not name is therefore not a missing - /// entry that shows up as a failure somewhere — it is a release that drops - /// out of every check there is, silently and permanently, while the whole - /// suite stays green. The record is the only thing that can say it - /// happened, so the declaration is checked against the record. - /// - /// Emitting the declaration from the record is what makes the two agree in - /// the first place. This is what catches the ways they still come apart: a - /// hand edit to the generated file, a record directory that arrived out of - /// band, and a generated file nobody regenerated after the record moved. - /// Nothing in CI regenerates anything, so a stale generated file is caught - /// here or not at all. - /// - /// Matched against the RELEASED suites alone, deliberately. A release and - /// the rolling candidate are byte-identical from the moment the release is - /// cut until source next moves, so a match against every declared suite - /// would let the candidate declare a frozen release — and the candidate is - /// exactly what the chain group does not check. - /// - /// The match is by address: the address a file DECLARES against the address - /// a suite's creation code DERIVES. The derived side is a pure function of - /// the creation code, so a suite whose creation code derives the address a - /// file records IS that file's release. - /// - /// Nothing is matched by name, which would assert only that a convention - /// was followed. Nothing is matched by searching the file's text either: a - /// record is mostly two hex payloads thousands of digits long, and an - /// address that merely OCCURS somewhere in one of them says nothing about - /// what the file records. - /// @param paths The frozen record's files. - /// @param released The declared released suites. - function checkFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) internal view { - for (uint256 i = 0; i < paths.length; i++) { - address recorded = recordedDeployedAddress(paths[i], vm.readFile(paths[i])); - - bool declared = false; - for (uint256 j = 0; j < released.length; j++) { - if (recorded == LibRainDeploy.zoltuAddress(released[j].creationCode)) { - declared = true; - break; - } - } - - if (!declared) { - revert FrozenSnapshotNotReleased(paths[i]); - } - } - } - - /// Every declared suite MUST be internally consistent: what it records is - /// what its own creation code derives. - function testSnapshotInternallyConsistent() external { - DeploySuite[] memory suites = allSuites(); - for (uint256 i = 0; i < suites.length; i++) { - checkInternallyConsistent(suites[i]); - } - } - - /// EVERY candidate MUST be a snapshot of the contract this repo compiles, - /// not of some other contract that happens to be internally consistent. - /// - /// The check itself is `RainDeploySuitesBase.checkCandidatesAnchoredToSource` - /// rather than anything here, because `RainDeployBroadcast` runs the same - /// definition before it broadcasts. A second spelling on this side is a - /// spelling the deploy does not run, which is exactly the state this test - /// would otherwise be reporting green about. - function testSnapshotMatchesSource() external pure { - checkCandidatesAnchoredToSource(); - } - +/// The split is which contract carries that one test, and nothing else. A +/// consumer inherits this and gets all three groups, exactly as it does when +/// they are one contract. The base is for a contract whose declaration is a +/// FIXTURE — the record is not its subject, and see the base for why asking it +/// about the record asserts something false. +abstract contract RainDeployVerifySnapshot is RainDeployVerifySnapshotBase { /// Every release in the frozen record MUST be declared, so that the set the /// chain group checks is every release this repo has ever cut rather than /// the ones somebody remembered to list. @@ -238,6 +31,14 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { /// forever, this passes with no subject, and the one check standing between /// a release dropping out of everything and a green suite is inert. /// + /// That is also why the root is not a parameter and this is not `virtual`. + /// Every way of pointing it somewhere else is a way of making it inert + /// while it still reports green, so there is nothing for a caller to hand + /// it and nothing to override. A contract that must not be asked this — a + /// harness whose released declaration is a fixture — inherits + /// `RainDeployVerifySnapshotBase` instead, which is a narrower contract in + /// an inheritance list rather than an emptied test body. + /// /// Deliberately NOT also guarded by comparing the record's size against the /// declaration's. The two are emitted one-for-one by `writeReleasedSuitesLib` /// for a repo that generates its declaration from its record, but this is diff --git a/src/abstract/RainDeployVerifySnapshotBase.sol b/src/abstract/RainDeployVerifySnapshotBase.sol new file mode 100644 index 0000000..9b28647 --- /dev/null +++ b/src/abstract/RainDeployVerifySnapshotBase.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; +import {DeploySuite} from "./RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; + +/// Thrown when the deploy address recorded for a version is not the address its +/// own creation code derives. +/// @param suite The suite that failed. +/// @param storedAddress The address the suite records. +/// @param derivedAddress The address its creation code derives. +error StoredAddressMismatch(string suite, address storedAddress, address derivedAddress); + +/// Thrown when the deployed code hash recorded for a version is not the hash +/// its own creation code produces. +/// @param suite The suite that failed. +/// @param storedCodeHash The code hash the suite records. +/// @param derivedCodeHash The code hash its creation code produces. +error StoredCodeHashMismatch(string suite, bytes32 storedCodeHash, bytes32 derivedCodeHash); + +/// Thrown when the runtime code recorded for a version does not hash to the +/// code hash recorded beside it. +/// @param suite The suite that failed. +/// @param storedBytecodeHash The code hash the suite records. +/// @param runtimeCodeHash The hash of the runtime code the suite records. +error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash); + +/// Thrown when a file in the frozen record is declared by no released suite. +/// The record is append-only, so this never goes away by itself: a release the +/// declaration missed is a release the chain group never asks about, and the +/// chain group passing means nothing for it. +/// @param path The frozen record file no released suite declares. +error FrozenSnapshotNotReleased(string path); + +/// Thrown when a file in the frozen record declares no deployed address. The +/// record holds generated snapshots and nothing else, and `DEPLOYED_ADDRESS` is +/// what makes one the record of a deployment rather than a file that happens to +/// be in a release directory. Distinct from `FrozenSnapshotNotReleased`, which +/// is a declaration that is missing something — this is a record that cannot be +/// read at all, and reporting it as undeclared would send the reader after the +/// wrong thing. +/// @param path The record file with no `DEPLOYED_ADDRESS` declaration. +error FrozenSnapshotUnreadable(string path); + +/// @title RainDeployVerifySnapshotBase +/// @notice Every deploy-pin assertion that needs no network, for every suite +/// a repo declares. Three groups, which catch different things and are +/// documented as such because it is easy to read the first as covering the +/// second. +/// +/// **Internal to the recorded set.** The address a suite's creation code +/// derives is the address it records, the code hash that creation code produces +/// is the code hash it records, and the runtime code it records hashes to that +/// same code hash. These are real derivations and they catch a set generated +/// inconsistently — a hand-edited constant, a snapshot regenerated for one +/// field and not the others, an address copied from the wrong tag. +/// +/// They CANNOT catch a snapshot of the wrong contract. A consistent snapshot of +/// the wrong thing satisfies all three, because all three only ask the recorded +/// bytes to agree with each other, and the wrong contract's bytes agree with +/// each other perfectly. +/// +/// **Anchored to source.** The candidate's recorded creation code is the +/// creation code this repo compiles. This is the only check in the whole suite +/// that catches a snapshot of the wrong contract, and it applies to the +/// candidate alone: a released tag is meant to have diverged from current +/// source, so anchoring one to source asserts something that is false by +/// design. +/// +/// That one is not defined here. It lives on `RainDeploySuitesBase`, because +/// `RainDeployBroadcast` runs it before it broadcasts and cannot reach anything +/// on this side — this inherits `Test`. Here it is a test; there it is the last +/// thing standing between a stale generated file and a permanent `CREATE2` +/// address on every chain a dispatch reaches. +/// +/// **Anchored to the record.** Every file in the frozen record — the +/// append-only `src/generated//` directories — is declared by a released +/// suite. This is the one check that is about the DECLARATION rather than about +/// what a declared suite records, and it exists because everything anchored to +/// a chain reads `releasedSuites()`, which is a separate file from the record +/// it describes. A release missing from it is not caught anywhere else, by +/// anything: it simply stops being checked, and every check there is stays +/// green. +/// +/// None of the three can catch a suite that was never deployed, or that is no +/// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a +/// substitute for it — but the record check is what makes its scope complete, +/// because a release it is never handed is a release it cannot fail on. +/// +/// ## What this contract is, and what a deploy repo inherits instead +/// +/// This holds the third group's CHECK and not its BINDING. Everything defined +/// here takes its subject as an argument or from the inheriting contract's own +/// declaration, so a contract whose declaration is a fixture is a contract this +/// says true things about. `RainDeployVerifySnapshot` is this plus the one test +/// that binds the third group to the repo's real record on disk, and it is +/// what a deploy repo inherits — the whole of the split is which contract +/// carries that one test. +/// +/// The split exists because that test's subject is `LibRainDeploySnapshot`'s +/// single spelling of the record root and never the inheriting contract's +/// declaration, which makes it the one assertion here that is FALSE of a +/// fixture: a harness declaring exemplar suites, inheriting it, would be +/// asserting that this repo's real releases are declared by that exemplar. It +/// passes while the repo has released nothing and fails on the first real +/// release, having said nothing about the abstracts in between. +/// +/// A `virtual` root, or a `virtual` test to override, would be the same split +/// spelled as an opt-out, and an opt-out is what the record check cannot have: +/// see the essay on `testEveryFrozenSnapshotIsReleased` for why the root has +/// one spelling. Inheriting a narrower contract is a choice a reader sees in +/// the inheritance list; overriding a test to nothing is one they do not. +abstract contract RainDeployVerifySnapshotBase is RainDeployVerifyBase { + /// Checks one suite against itself: derive from its creation code, then + /// require everything it records to agree with the derivation. + /// @param suite The suite to check. + function checkInternallyConsistent(DeploySuite memory suite) internal { + DerivedDeploy memory derived = deriveDeployment(suite); + + if (suite.storedDeployedAddress != derived.deployedAddress) { + revert StoredAddressMismatch(suite.suite, suite.storedDeployedAddress, derived.deployedAddress); + } + + if (suite.storedBytecodeHash != derived.bytecodeHash) { + revert StoredCodeHashMismatch(suite.suite, suite.storedBytecodeHash, derived.bytecodeHash); + } + + bytes32 runtimeCodeHash = keccak256(suite.storedRuntimeCode); + if (suite.storedBytecodeHash != runtimeCodeHash) { + revert StoredRuntimeCodeHashMismatch(suite.suite, suite.storedBytecodeHash, runtimeCodeHash); + } + } + + /// @dev The declaration a generated snapshot records its deploy address in. + /// Matched whole and from the START of its line, so what is being looked + /// for is the DECLARATION: it cannot be satisfied by characters that happen + /// to occur inside a hex payload, nor by a line that merely CONTAINS the + /// declaration text — a commented-out copy carrying some other address is + /// exactly the hand edit this whole group exists to catch, and it is at + /// file scope in every generated snapshot, so there is no indentation to + /// allow for. + string constant DEPLOYED_ADDRESS_DECLARATION = "address constant DEPLOYED_ADDRESS ="; + + /// The address a frozen record declares as its deploy address. + /// + /// `LibCodeGen` emits an address constant on ONE line — wrapping needs 120 + /// characters and this declaration occupies 88 — so the declaration is a + /// line, and its value is that line's last token with the type wrapper and + /// the terminator stripped. `address(0x...);` and a bare `0x...;` read the + /// same, so which wrapper the generator chose is not something this has to + /// know. + /// + /// That every generated snapshot HAS this declaration, second, of type + /// `address`, is pinned by `GeneratedSnapshotShapeTest` against the + /// compiler's own AST. Read from the text here rather than from that AST + /// because a record is reached by its PATH, which is what the walk returns, + /// while its artifact path is not something a caller can name — foundry + /// disambiguates those by whatever else happens to share the basename. + /// @param path The record file, for the error only. + /// @param record The record file's contents. + /// @return The address the record declares. + function recordedDeployedAddress(string memory path, string memory record) internal pure returns (address) { + string[] memory lines = vm.split(record, "\n"); + for (uint256 i = 0; i < lines.length; i++) { + if (vm.indexOf(lines[i], DEPLOYED_ADDRESS_DECLARATION) != 0) { + continue; + } + string[] memory tokens = vm.split(lines[i], " "); + string memory literal = tokens[tokens.length - 1]; + return vm.parseAddress(vm.replace(vm.replace(vm.replace(literal, "address(", ""), ")", ""), ";", "")); + } + revert FrozenSnapshotUnreadable(path); + } + + /// Checks the frozen record against the released declaration: every file in + /// the record is declared by a released suite. + /// + /// `releasedSuites()` is a generated file, and everything anchored to a + /// chain reads it. A frozen tag it does not name is therefore not a missing + /// entry that shows up as a failure somewhere — it is a release that drops + /// out of every check there is, silently and permanently, while the whole + /// suite stays green. The record is the only thing that can say it + /// happened, so the declaration is checked against the record. + /// + /// Emitting the declaration from the record is what makes the two agree in + /// the first place. This is what catches the ways they still come apart: a + /// hand edit to the generated file, a record directory that arrived out of + /// band, and a generated file nobody regenerated after the record moved. + /// Nothing in CI regenerates anything, so a stale generated file is caught + /// here or not at all. + /// + /// Matched against the RELEASED suites alone, deliberately. A release and + /// the rolling candidate are byte-identical from the moment the release is + /// cut until source next moves, so a match against every declared suite + /// would let the candidate declare a frozen release — and the candidate is + /// exactly what the chain group does not check. + /// + /// The match is by address: the address a file DECLARES against the address + /// a suite's creation code DERIVES. The derived side is a pure function of + /// the creation code, so a suite whose creation code derives the address a + /// file records IS that file's release. + /// + /// Nothing is matched by name, which would assert only that a convention + /// was followed. Nothing is matched by searching the file's text either: a + /// record is mostly two hex payloads thousands of digits long, and an + /// address that merely OCCURS somewhere in one of them says nothing about + /// what the file records. + /// @param paths The frozen record's files. + /// @param released The declared released suites. + function checkFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) internal view { + for (uint256 i = 0; i < paths.length; i++) { + address recorded = recordedDeployedAddress(paths[i], vm.readFile(paths[i])); + + bool declared = false; + for (uint256 j = 0; j < released.length; j++) { + if (recorded == LibRainDeploy.zoltuAddress(released[j].creationCode)) { + declared = true; + break; + } + } + + if (!declared) { + revert FrozenSnapshotNotReleased(paths[i]); + } + } + } + + /// Every declared suite MUST be internally consistent: what it records is + /// what its own creation code derives. + function testSnapshotInternallyConsistent() external { + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + checkInternallyConsistent(suites[i]); + } + } + + /// EVERY candidate MUST be a snapshot of the contract this repo compiles, + /// not of some other contract that happens to be internally consistent. + /// + /// The check itself is `RainDeploySuitesBase.checkCandidatesAnchoredToSource` + /// rather than anything here, because `RainDeployBroadcast` runs the same + /// definition before it broadcasts. A second spelling on this side is a + /// spelling the deploy does not run, which is exactly the state this test + /// would otherwise be reporting green about. + function testSnapshotMatchesSource() external pure { + checkCandidatesAnchoredToSource(); + } +} diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index f4f55ad..d11b4b5 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -158,9 +158,9 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain /// `RainDeployVerifyChainCandidateTest` is the only other contract that /// reaches the matrix with a subject, at exactly one, and it forks on its own /// before calling, so a matrix that returned without forking is - /// indistinguishable there. `RegistryDeployChainTest`'s empty case reads as - /// satisfied under that guard, which is what makes one subject the length - /// that discriminates. + /// indistinguishable there. `RainDeployVerifyChainEmptyTest`'s empty case + /// reads as satisfied under that guard, which is what makes one subject the + /// length that discriminates. /// /// This contract is where it belongs because it already forks every /// supported network. Asserting it from the empty-set side would hand the diff --git a/test/src/abstract/RainDeployVerifyChainEmpty.t.sol b/test/src/abstract/RainDeployVerifyChainEmpty.t.sol new file mode 100644 index 0000000..d3b338c --- /dev/null +++ b/test/src/abstract/RainDeployVerifyChainEmpty.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; + +/// @title RainDeployVerifyChainEmptyTest +/// @notice A repo that has released nothing: the matrix has no subject, and +/// MUST reach no network at all. +/// +/// Forking seven endpoints to check nothing turns an outage into the failure of +/// an assertion with no subject, which is the one failure the chain group is +/// supposed to stay legible against. The pass is identical either way — a +/// matrix that forked all seven and found nothing to check on each of them +/// passes too — so the ABSENCE of a fork is the only thing that separates them +/// and it is what is asserted here. +/// +/// The empty set is DECLARED here rather than read off a repo that happens not +/// to have released yet. A deploy repo's own declaration is empty exactly once, +/// before its first release, and a test resting on that is a test that stops +/// asserting anything the day the repo does the thing it exists to do. Declared, +/// the subject is empty for as long as this contract exists. +/// +/// Its own contract for the reason `RainDeployVerifyChainCandidateTest` is its +/// own contract: the suites a contract inherits are the whole of what the +/// matrix runs over, and a contract has exactly one declaration, so a second +/// scope is a second contract. `RainDeployVerifyChainTest` cannot be it — it +/// declares two released suites so that the matrix has something to fail on. +contract RainDeployVerifyChainEmptyTest is RainDeployVerifyChain { + /// @inheritdoc RainDeploySuitesBase + /// @dev Nothing released. This is the whole fixture. + function releasedSuites() internal pure override returns (DeploySuite[] memory) { + return new DeploySuite[](0); + } + + /// @inheritdoc RainDeploySuitesBase + /// @dev A candidate, because a repo always compiles a current source and an + /// empty candidate list is a declaration `RainDeploySuitesBase` refuses. The + /// chain group never reads it — that scoping is + /// `RainDeployVerifyChainCandidateTest`'s subject — so what it is does not + /// matter here, only that this contract is a declaration a repo could + /// actually have. + function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](1); + candidates[0] = DeployCandidate({ + snapshot: DeploySuite({ + suite: "second-address-candidate", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), + storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// Nothing to check MUST NOT touch an RPC endpoint. + /// + /// `vm.activeFork()` reverts when nothing is selected, so the low-level call + /// failing IS "no network was reached". + /// + /// It runs the whole inherited entry point rather than handing the matrix an + /// empty array, so the derivation is inside what is asserted: a fork opened + /// while deriving would touch the same seven endpoints for the same nothing. + /// + /// The empty released set is asserted rather than assumed, so an edit that + /// gives this contract a subject fails naming the declaration it changed + /// instead of reporting that a matrix with nothing to check forked — which + /// would by then be describing a matrix that had something to check. + function testChainWithNothingToCheckForksNothing() external { + assertEq(releasedSuites().length, 0, "this fixture declares a release, so the matrix has a subject"); + + (bool activeBefore,) = address(vm).call(abi.encodeWithSignature("activeFork()")); + assertFalse(activeBefore, "a fork was selected before the call"); + + this.testSuitesLiveOnEverySupportedNetwork(); + + (bool activeAfter,) = address(vm).call(abi.encodeWithSignature("activeFork()")); + assertFalse(activeAfter, "the matrix forked a network with nothing to check"); + } +} diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol similarity index 96% rename from test/src/abstract/RainDeployVerifySnapshot.t.sol rename to test/src/abstract/RainDeployVerifySnapshotBase.t.sol index c3a2366..65421c4 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol @@ -7,11 +7,11 @@ import {CandidateSourceMismatch, DeployCandidate, DeploySuite} from "../../../sr import { FrozenSnapshotNotReleased, FrozenSnapshotUnreadable, - RainDeployVerifySnapshot, + RainDeployVerifySnapshotBase, StoredAddressMismatch, StoredCodeHashMismatch, StoredRuntimeCodeHashMismatch -} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; +} from "../../../src/abstract/RainDeployVerifySnapshotBase.sol"; import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; @@ -25,14 +25,27 @@ import { RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE } from "../../../src/generated/candidate/AddressRegistry.sol"; -/// @title RainDeployVerifySnapshotTest -/// @notice `RainDeployVerifySnapshot` inherited by a exemplar repo, so the +/// @title RainDeployVerifySnapshotBaseTest +/// @notice `RainDeployVerifySnapshotBase` inherited by a exemplar repo, so the /// inherited tests themselves are the passing case: `ExampleDeploySuites` /// declares two frozen releases and two candidates, and /// `testSnapshotInternallyConsistent` / /// `testSnapshotMatchesSource` run over them here exactly as they /// would in a consumer. /// +/// The base rather than `RainDeployVerifySnapshot`, because that one adds +/// `testEveryFrozenSnapshotIsReleased`, whose subject is THIS repo's real +/// frozen record and never the declaration of whatever inherits it. Every +/// exemplar suite here is a fixture, so inheriting that test would assert that +/// this repo's real releases are declared by the exemplar — a claim that is +/// vacuously true while the repo has released nothing and false from its first +/// release, which is not a thing this contract is about either way. The real +/// record is bound where the real declaration is, in `RegistryDeploySnapshotTest`. +/// +/// The record check ITSELF is exercised here, at every position and against +/// every shape of declaration, because `checkFrozenSnapshotsReleased` takes the +/// record as an argument and so can be handed one this contract builds. +/// /// The rest is what each group CATCHES, and — for the internal group — what it /// provably does not. Every case drives the same internal functions the /// inherited tests do, at a call depth `vm.expectRevert` lands at. @@ -43,7 +56,7 @@ import { /// with nothing to hand it. Its negative case is therefore a whole broken /// DECLARATION — `SourceMismatchDeploy` — which is also the shape a repo holding /// a stale generated file is actually in. -contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySnapshot { +contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVerifySnapshotBase { /// A declaration whose second candidate is a consistent snapshot of the /// wrong contract. /// diff --git a/test/src/abstract/RegistryDeployChain.t.sol b/test/src/abstract/RegistryDeployChain.t.sol index e48943c..33ddc77 100644 --- a/test/src/abstract/RegistryDeployChain.t.sol +++ b/test/src/abstract/RegistryDeployChain.t.sol @@ -9,54 +9,25 @@ import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.s /// @notice Whether every registry this repo has RELEASED is actually live, with /// the code that release froze, on every supported network. /// -/// It has released none. The chain group reads `releasedSuites()`, which is -/// empty until the first release is cut, so there is nothing here to check: this -/// forks nothing and passes. It gets a subject the moment a release is frozen -/// and declared, and from then on it is red until that release is live on every -/// supported network — which is why the deploy is dispatched before the tag is -/// pushed. +/// The chain group reads `releasedSuites()`, so this is red until every release +/// this repo has declared is live on every supported network — which is why the +/// deploy is dispatched before the tag is pushed. That failure is the check +/// working. "Nothing is deployed at the address `LibAddressRegistry` reads" is +/// the single most important fact about these pins, and no snapshot assertion +/// can discover it — a perfectly consistent set of pins for a contract that +/// exists nowhere passes every one of them. /// -/// That eventual failure is the check working. "Nothing is deployed at the -/// address `LibAddressRegistry` reads" is true today, it is the single most -/// important fact about these pins, and no snapshot assertion can discover it — -/// a perfectly consistent set of pins for a contract that exists nowhere passes -/// every one of them. +/// The assertion is inherited. There is nothing to write here, which is the +/// point: `RegistryDeploySuites` says which releases exist and +/// `RainDeployVerifyChain` says what is true of them. What the matrix does with +/// a declaration is the abstract's business and is pinned against fixtures — +/// `RainDeployVerifyChainTest` for a set it must check on every network, +/// `RainDeployVerifyChainCandidateTest` for the candidate it must ignore, and +/// `RainDeployVerifyChainEmptyTest` for a set with nothing in it, which it must +/// not fork for. /// /// It is a separate contract from `RegistryDeploySnapshotTest` precisely so that /// it says this and nothing more: a missing deployment or an unreachable /// endpoint fails here alone, leaving every snapshot assertion to answer for /// itself. -contract RegistryDeployChainTest is RegistryDeploySuites, RainDeployVerifyChain { - /// Nothing to check MUST NOT touch an RPC endpoint. This repo has released - /// nothing, so this is the branch every CI run takes: forking seven networks - /// to check nothing turns an outage into the failure of an assertion with - /// no subject, which is the one failure this contract exists to stay - /// legible against. - /// - /// The ABSENCE of a fork is what is asserted, because the pass is identical - /// either way — the matrix that forks all seven and finds nothing to check on - /// each of them passes too, and is the only thing this contract would have - /// done differently. `vm.activeFork()` reverts when nothing is selected, so - /// the low-level call failing IS "no network was reached". - /// - /// It runs the whole inherited entry point rather than handing the matrix an - /// empty array, so the derivation is inside what is asserted: a fork opened - /// while deriving would touch the same seven endpoints for the same nothing. - /// - /// The empty released set is asserted rather than assumed, because it is the - /// premise and not the property. The first release gives this contract a - /// subject and the matrix will fork for it — correctly — so this fails at - /// that release naming what actually changed, instead of reporting that a - /// matrix with nothing to check forked, which would by then be false. - function testChainWithNothingToCheckForksNothing() external { - assertEq(releasedSuites().length, 0, "this repo has released something, so the matrix has a subject"); - - (bool activeBefore,) = address(vm).call(abi.encodeWithSignature("activeFork()")); - assertFalse(activeBefore, "a fork was selected before the call"); - - this.testSuitesLiveOnEverySupportedNetwork(); - - (bool activeAfter,) = address(vm).call(abi.encodeWithSignature("activeFork()")); - assertFalse(activeAfter, "the matrix forked a network with nothing to check"); - } -} +contract RegistryDeployChainTest is RegistryDeploySuites, RainDeployVerifyChain {} diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index fcd8818..507cefd 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -39,13 +39,6 @@ contract LibRainDeploySnapshotTest is Test { /// driven by `regenerateFreezeFixture` instead. function noRegeneration() internal {} - /// External wrapper so `vm.expectRevert` lands at the right call depth, for - /// the guards that are about this repo's REAL record. - /// @param contractNames The contracts to freeze. - function externalFreeze(string[] memory contractNames) external { - LibRainDeploySnapshot.freeze(vm, LibRainDeploySnapshot.LIB_FS_ROOT, noRegeneration, contractNames); - } - /// External wrapper so `vm.expectRevert` lands at the right call depth, for /// the guards driven against a fixture record. /// @param root The record root to freeze into. @@ -1491,6 +1484,23 @@ contract LibRainDeploySnapshotTest is Test { ); } + /// Where the empty-release refusal is driven. Its own tree, for the reason + /// every freeze fixture has one: each of these cuts the SAME tag — the real + /// `deployTag(vm)`, which is the tag a freeze is about — so two of them + /// sharing a root would have whichever ran second refused as a re-cut + /// rather than by the guard under test. + /// + /// NOT `LIB_FS_ROOT`. This repo's real record holds a `/` from the + /// release it cut under this version, and `SnapshotAlreadyFrozen` is + /// checked before either guard below, so a freeze pointed at the real root + /// is refused for a reason that is not the one being driven. The tag stays + /// the real one; it is the ROOT that has to be a fixture's. + string constant EMPTY_RELEASE_FIXTURE_ROOT = "test/generated-freeze-empty"; + + /// Where the nothing-to-freeze refusal is driven. Its own tree, for the + /// reason `EMPTY_RELEASE_FIXTURE_ROOT` is its own tree. + string constant NOTHING_TO_FREEZE_FIXTURE_ROOT = "test/generated-freeze-nothing"; + /// A freeze that names no contracts MUST be refused. It would write /// nothing, report success, and leave `/` there — and an empty /// `/` is a frozen tag, so the real cut of that release could never @@ -1499,9 +1509,9 @@ contract LibRainDeploySnapshotTest is Test { string memory tag = LibRainDeploySnapshot.deployTag(vm); vm.expectRevert(abi.encodeWithSelector(EmptyRelease.selector, tag)); - this.externalFreeze(new string[](0)); + this.externalFreezeAt(EMPTY_RELEASE_FIXTURE_ROOT, new string[](0)); - assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); + assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(EMPTY_RELEASE_FIXTURE_ROOT, tag))); } /// A freeze that throws MUST leave NOTHING behind. Filesystem cheatcodes @@ -1511,20 +1521,36 @@ contract LibRainDeploySnapshotTest is Test { /// The exit from that state is deleting a directory this design calls /// append-only, so the ordering here is what keeps a failed release /// retryable at all. + /// + /// The record it is pointed at is a real one — a rolling snapshot is there + /// for another contract — so the only thing missing is the snapshot of the + /// contract this release names, which is the state a freeze reaches its + /// last guard in. A root with nothing under it at all would fail here for + /// want of a record rather than for want of one contract's file. function testFreezeLeavesNothingBehindWhenThereIsNothingToFreeze() external { string memory tag = LibRainDeploySnapshot.deployTag(vm); + writeRollingFixture(NOTHING_TO_FREEZE_FIXTURE_ROOT, FIXTURE_CONTRACT); + string[] memory contractNames = new string[](1); contractNames[0] = "NoSuchContract"; vm.expectRevert( abi.encodeWithSelector( NothingToFreeze.selector, - LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, contractNames[0]) + LibRainDeploySnapshot.pathForSnapshot( + NOTHING_TO_FREEZE_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, contractNames[0] + ) ) ); - this.externalFreeze(contractNames); + this.externalFreezeAt(NOTHING_TO_FREEZE_FIXTURE_ROOT, contractNames); + + // Read while the fixture is still there, asserted once it is gone. + bool cutExists = vm.exists(LibRainDeploySnapshot.dirForSnapshot(NOTHING_TO_FREEZE_FIXTURE_ROOT, tag)); - assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(NOTHING_TO_FREEZE_FIXTURE_ROOT, true); + + assertFalse(cutExists); } /// Where the freeze fixture's record is built. Its own tree, for the same