From 4ef449092ad521aa9372575788410a515df6b6a9 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Tue, 15 Sep 2026 15:32:55 +0000 Subject: [PATCH 1/4] Refuse a deploy suite key that spells the key list separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UnknownDeploymentSuite` carries the declared keys so a caller who does not already know them is told what they are. `suiteNames()` joins them on ", " and nothing constrained key contents, so the two suite registry keyed `a,b` and `c` rendered `a,b, c` — the same list a three suite registry keyed `a`, `b` and `c` reports. The reader was told a different number of suites exist than do and was sent after `b`, which is declared nowhere. `allSuites()` now refuses a key carrying any character of the separator, in the same pass that refuses a duplicate, so every reader refuses it at once and no lookup can answer with a list that reads back as a set the registry does not hold. The join reads the separator from the constant the check does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- README.md | 4 +- src/abstract/RainDeploySuitesBase.sol | 47 +++++++++++++++- test/concrete/SeparatorKeyDeploySuites.sol | 58 ++++++++++++++++++++ test/concrete/SpaceKeyDeploySuites.sol | 57 +++++++++++++++++++ test/src/abstract/RainDeploySuitesBase.t.sol | 57 ++++++++++++++++++- 5 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 test/concrete/SeparatorKeyDeploySuites.sol create mode 100644 test/concrete/SpaceKeyDeploySuites.sol diff --git a/README.md b/README.md index 25209aa..c0c6510 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ Suites are a **registry the abstract iterates**, not a chain of `else if`. Adding a suite is adding an array entry. A mistyped `DEPLOYMENT_SUITE` reports the valid keys built from that same array, so the error cannot fall behind the suites it describes, and keys are checked unique because the key is what selects -what gets broadcast. +what gets broadcast. A key may not carry a character of the `", "` that list is +joined with, because one that does renders as two keys and sends a reader who +did not already know the answer after one that is declared nowhere. Every suite is individually selectable, including a frozen release — which is how a snapshot from before a network existed reaches that network. diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol index 6308fad..e3d91f0 100644 --- a/src/abstract/RainDeploySuitesBase.sol +++ b/src/abstract/RainDeploySuitesBase.sol @@ -14,6 +14,27 @@ error DuplicateDeploySuite(string suite); /// @param validSuites The declared keys, comma separated. error UnknownDeploymentSuite(string requested, string validSuites); +/// @dev What `suiteNames()` joins the declared keys with, and so the characters +/// a key may not itself contain. +string constant SUITE_NAME_SEPARATOR = ", "; + +/// Thrown when a key contains a character the key list is joined with. +/// +/// The list `UnknownDeploymentSuite` carries is that join, so one such key +/// renders as two: a caller reading the list back is told a different number of +/// suites exist than do and is sent after keys that are declared nowhere. A +/// list that cannot be read back is the hardcoded string the registry exists to +/// replace, spelled differently. +/// +/// EVERY character of the separator, not only the comma that splits it: this +/// list is revert data a human reads, and a key that opens or closes on the +/// separator's space renders indistinguishably from one that does not. "No +/// character of the separator" is one rule tied to the constant the join uses, +/// where "no comma, and no leading or trailing space" is two rules that can +/// drift from it and from each other. +/// @param suite The key that cannot be read back out of the list. +error UnreadableDeploySuiteKey(string suite); + /// Thrown when a declaration names no candidate at all. /// /// The source anchor is the ONLY check that catches a snapshot of the wrong @@ -233,6 +254,13 @@ abstract contract RainDeploySuitesBase { /// One pairwise pass over the whole set, so a candidate colliding with /// another candidate is caught by the same code that catches a candidate /// colliding with a release — there is no second rule to keep in step. + /// + /// Keys are checked readable back out of `suiteNames()` in the same pass + /// and for the same reason. A registry whose reported key list parses to a + /// different set than it holds is ambiguous to the only party that ever + /// reads it, and refusing it here refuses it on every reader at once — + /// including `suiteByName`, so the ambiguous list is never the thing a + /// failed lookup answers with. /// @return Every declared suite. function allSuites() internal pure returns (DeploySuite[] memory) { DeploySuite[] memory released = releasedSuites(); @@ -246,7 +274,17 @@ abstract contract RainDeploySuitesBase { suites[released.length + i] = candidates[i].snapshot; } + bytes memory separator = bytes(SUITE_NAME_SEPARATOR); for (uint256 i = 0; i < suites.length; i++) { + bytes memory key = bytes(suites[i].suite); + for (uint256 k = 0; k < key.length; k++) { + for (uint256 s = 0; s < separator.length; s++) { + if (key[k] == separator[s]) { + revert UnreadableDeploySuiteKey(suites[i].suite); + } + } + } + for (uint256 j = i + 1; j < suites.length; j++) { if (keccak256(bytes(suites[i].suite)) == keccak256(bytes(suites[j].suite))) { revert DuplicateDeploySuite(suites[i].suite); @@ -257,13 +295,18 @@ abstract contract RainDeploySuitesBase { return suites; } - /// Every declared key, comma separated, for the unknown-suite error. + /// Every declared key, `SUITE_NAME_SEPARATOR` separated, for the + /// unknown-suite error. + /// + /// Splitting the result on the separator recovers exactly the keys, because + /// `allSuites` refuses a declaration whose keys could put a character of + /// the separator anywhere but between two of them. /// @return The declared keys. function suiteNames() internal pure returns (string memory) { DeploySuite[] memory suites = allSuites(); string memory names; for (uint256 i = 0; i < suites.length; i++) { - names = i == 0 ? suites[i].suite : string.concat(names, ", ", suites[i].suite); + names = i == 0 ? suites[i].suite : string.concat(names, SUITE_NAME_SEPARATOR, suites[i].suite); } return names; } diff --git a/test/concrete/SeparatorKeyDeploySuites.sol b/test/concrete/SeparatorKeyDeploySuites.sol new file mode 100644 index 0000000..600615f --- /dev/null +++ b/test/concrete/SeparatorKeyDeploySuites.sol @@ -0,0 +1,58 @@ +// 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 {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.sol"; +import {MockDeployableV2} from "./MockDeployableV2.sol"; +import {LibRainDeploy} from "../../src/lib/LibRainDeploy.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../src/generated/candidate/AddressRegistry.sol"; + +/// @title SeparatorKeyDeploySuites +/// A TWO suite declaration whose first key carries the separator's comma, so +/// the list renders `a,b, c` and reads as the THREE keys `a`, `b` and `c` — +/// `b` being declared nowhere and named back to a caller as valid. +/// +/// The comma alone, no space, so the rule's comma is what has to refuse this. +/// A key spelling the whole separator is the case the issue reports and is +/// refused by either half of the rule, which is exactly why it cannot tell the +/// two halves apart. +contract SeparatorKeyDeploySuites is ExternalDeploySuites { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory) { + DeploySuite[] memory suites = new DeploySuite[](1); + suites[0] = DeploySuite({ + suite: "a,b", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + return suites; + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuites() internal pure override returns (DeployCandidate[] memory) { + DeployCandidate[] memory candidates = new DeployCandidate[](1); + candidates[0] = DeployCandidate({ + snapshot: DeploySuite({ + suite: "c", + 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 + }); + return candidates; + } +} diff --git a/test/concrete/SpaceKeyDeploySuites.sol b/test/concrete/SpaceKeyDeploySuites.sol new file mode 100644 index 0000000..11e8430 --- /dev/null +++ b/test/concrete/SpaceKeyDeploySuites.sol @@ -0,0 +1,57 @@ +// 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 {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.sol"; +import {MockDeployableV2} from "./MockDeployableV2.sol"; +import {LibRainDeploy} from "../../src/lib/LibRainDeploy.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../src/generated/candidate/AddressRegistry.sol"; + +/// @title SpaceKeyDeploySuites +/// A declaration whose second key opens with the separator's space rather than +/// its comma, so the list renders `a, b` — two keys, the right count, and a +/// reader who takes the second one as `b` is sent after a key that does not +/// exist by the one character the rendering cannot show them. +/// +/// The comma alone would let this through, which is why the rule is every +/// character of the separator and not the one that splits it. +contract SpaceKeyDeploySuites is ExternalDeploySuites { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory) { + DeploySuite[] memory suites = new DeploySuite[](1); + suites[0] = DeploySuite({ + suite: "a", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + return suites; + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuites() internal pure override returns (DeployCandidate[] memory) { + DeployCandidate[] memory candidates = new DeployCandidate[](1); + candidates[0] = DeployCandidate({ + snapshot: DeploySuite({ + suite: " b", + 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 + }); + return candidates; + } +} diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol index 50dcaa2..20873f9 100644 --- a/test/src/abstract/RainDeploySuitesBase.t.sol +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -8,13 +8,16 @@ import { DeploySuite, DuplicateDeploySuite, NoDeployCandidates, - UnknownDeploymentSuite + UnknownDeploymentSuite, + UnreadableDeploySuiteKey } from "../../../src/abstract/RainDeploySuitesBase.sol"; import {ExampleDeploy} from "../../concrete/ExampleDeploy.sol"; import {CollidingCandidateDeploySuites} from "../../concrete/CollidingCandidateDeploySuites.sol"; import {DuplicateDeploySuites} from "../../concrete/DuplicateDeploySuites.sol"; import {NoCandidateDeploySuites} from "../../concrete/NoCandidateDeploySuites.sol"; import {SameLengthKeyDeploySuites} from "../../concrete/SameLengthKeyDeploySuites.sol"; +import {SeparatorKeyDeploySuites} from "../../concrete/SeparatorKeyDeploySuites.sol"; +import {SpaceKeyDeploySuites} from "../../concrete/SpaceKeyDeploySuites.sol"; /// @title RainDeploySuitesBaseTest /// @notice The registry itself: one declaration, keyed lookup, and the two ways @@ -103,6 +106,58 @@ contract RainDeploySuitesBaseTest is Test { sSuites.externalSuiteByName(""); } + /// A key carrying the separator's COMMA MUST be refused, on every reader. + /// + /// The list exists so a caller who does NOT already know the valid keys is + /// told them. Joined on `", "` with nothing said about key contents, the + /// two suite registry `a,b` and `c` renders `a,b, c`, which is what a three + /// suite registry keyed `a`, `b` and `c` says: the reader is told a + /// different number of suites exist than do, and `b`, declared nowhere, is + /// handed to them as valid. The registry is refused rather than rendered, + /// because a list that reads back as a different set than the one it + /// describes is the hardcoded string this whole registry replaces. + function testSeparatorKeyIsRefused() external { + SeparatorKeyDeploySuites separator = new SeparatorKeyDeploySuites(); + + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, "a,b")); + separator.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, "a,b")); + separator.externalSuiteNames(); + + // The key the declaration DOES name, refused too. What is wrong is the + // declaration, not any one lookup against it, so there is no key that + // reaches a suite through it. + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, "a,b")); + separator.externalSuiteByName("a,b"); + + // And the phantom: an unknown key gets the refusal rather than an + // `UnknownDeploymentSuite` whose valid list names `b`. + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, "a,b")); + separator.externalSuiteByName("b"); + } + + /// A key carrying the separator's SPACE without its comma MUST be refused + /// too. `a` and ` b` render `a, b`, which splits into the right NUMBER of + /// keys and still sends a reader after `b`, which does not exist — the one + /// character between the two being the one a rendering cannot show them. A + /// rule written against the comma alone answers this with that list. + /// + /// The offending key is the CANDIDATE here and the released suite in the + /// comma case, so a check that ran over either side alone is caught. + function testSpaceKeyIsRefused() external { + SpaceKeyDeploySuites spaced = new SpaceKeyDeploySuites(); + + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, " b")); + spaced.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, " b")); + spaced.externalSuiteNames(); + + vm.expectRevert(abi.encodeWithSelector(UnreadableDeploySuiteKey.selector, " b")); + spaced.externalSuiteByName("b"); + } + /// The reported key list MUST be exactly the registry, in order. function testSuiteNamesIsTheRegistry() external view { assertEq( From 1e938195c238f9854865ea8d13405eae8a952218 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Tue, 15 Sep 2026 20:36:29 +0000 Subject: [PATCH 2/4] Hold a deploy suite key to an alphabet the key list reads back as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `suiteNames()` joins the declared keys on `", "` and nothing constrained what a key may contain, so `UnknownDeploymentSuite` rendered a two suite registry keyed `a,b` and `c` as `a,b, c` — indistinguishable from a three suite registry keyed `a`, `b` and `c`, and `b` is declared nowhere. The error exists so a caller who does not already know the valid keys is told them; a list that cannot be read back as the set it names is the hardcoded string the registry replaces. Keys are now held to a name of lowercase letters and hyphens, optionally an at sign and a tag of those plus digits and underscores, neither half empty and at most one at sign. That is what a repo declares by hand and what `LibRainDeploySnapshot` generates, so no declaration needs an exemption. The empty key falls to the same rule rather than to a second one beside it: `EmptyDeploySuiteKey(uint256 index)` from #202 becomes `InvalidDeploySuiteKey(uint256 index, string suite)`, keeping the index and gaining the key every refusal that is not the empty one needs. Closes #203. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- README.md | 13 +- src/abstract/RainDeploySuitesBase.sol | 118 ++++++++++++---- test/abstract/ExampleDeploySuites.sol | 2 +- test/abstract/ExternalDeploySuites.sol | 6 + test/concrete/EmptyKeyDeploySuites.sol | 2 +- test/concrete/NoCandidateDeploySuites.sol | 2 +- test/concrete/SeparatorKeyDeploySuites.sol | 14 +- test/concrete/SpaceKeyDeploySuites.sol | 57 -------- test/src/abstract/RainDeployBroadcast.t.sol | 10 +- test/src/abstract/RainDeploySuitesBase.t.sol | 130 ++++++++++++++---- test/src/abstract/RainDeployVerifyChain.t.sol | 18 +-- .../RainDeployVerifyChainCandidate.t.sol | 2 +- .../RainDeployVerifySnapshotBase.t.sol | 18 ++- 13 files changed, 246 insertions(+), 146 deletions(-) delete mode 100644 test/concrete/SpaceKeyDeploySuites.sol diff --git a/README.md b/README.md index e00dd76..e4dde3d 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,15 @@ Suites are a **registry the abstract iterates**, not a chain of `else if`. Adding a suite is adding an array entry. A mistyped `DEPLOYMENT_SUITE` reports the valid keys built from that same array, so the error cannot fall behind the suites it describes, and keys are checked unique because the key is what selects -what gets broadcast. They are checked **non-empty** for the same reason from the -other side: the empty string is what an unset `DEPLOYMENT_SUITE` arrives as, so -leaving it declarable would let a dispatch with the suite input blank select -something instead of reporting that it was told nothing. +what gets broadcast. They are also checked against an **alphabet**: kebab case, +optionally followed by `@` and a release tag, which is the shape a repo writes +by hand and the shape a cut release generates. A key outside it is one the +reported list cannot be read back as — a comma or a space in a key renders as +two keys and sends a reader after one that is declared nowhere — and the empty +key falls to the same rule, which matters most: the empty string is what an +unset `DEPLOYMENT_SUITE` arrives as, so leaving it declarable would let a +dispatch with the suite input blank select something instead of reporting that +it was told nothing. Every suite is individually selectable, including a frozen release — which is how a snapshot from before a network existed reaches that network. diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol index 8977bd7..419b32f 100644 --- a/src/abstract/RainDeploySuitesBase.sol +++ b/src/abstract/RainDeploySuitesBase.sol @@ -7,24 +7,46 @@ pragma solidity ^0.8.25; /// @param suite The key declared more than once. error DuplicateDeploySuite(string suite); -/// Thrown when a suite declares an EMPTY key. +/// Thrown when a suite declares a key outside the key alphabet. /// -/// The empty string is the absent-sentinel: `RainDeployBroadcast.run()` reads -/// `DEPLOYMENT_SUITE` through `vm.envOr` with `string("")` as the default, so a -/// dispatch that left the suite input blank asks the registry for exactly this -/// key. A declaration free to answer it turns "told nothing" into a selection, -/// and `CREATE2` under a zero salt puts those bytes at their own permanent -/// address on every chain that dispatch reached. +/// A key is a NAME, or a name and a release TAG joined by an at sign: the name +/// lowercase letters and hyphens, the tag those plus digits and underscores, +/// neither half empty and at most one at sign. Digits and underscores after the +/// at sign only, which is where a tag needs them and where nothing else does. /// -/// Reserved on the DECLARATION rather than beside the substitution, for the -/// reason `NoDeployCandidates` is: a rule bound in the consumer is a rule most +/// That is the kebab case a repo declares by hand, and it is what +/// `LibRainDeploySnapshot` emits a released entry as — the candidate key the +/// release was cut from, the at sign, and the record directory's tag, which +/// `isTag` already holds to `X_Y_Z` — so a generated declaration needs no +/// exemption from the rule a hand written one is held to. The at sign cannot +/// appear in a name, so that emission carries exactly one however many releases +/// a repo cuts. +/// +/// The key list is why an alphabet exists at all. `suiteNames()` joins the +/// declared keys on `", "` for `UnknownDeploymentSuite`, which exists so a +/// caller who does NOT already know the valid keys is told them; a key free to +/// carry either of those characters renders as two, so the reader is told a +/// different number of suites exist than do and is sent after a key that is +/// declared nowhere. A list that cannot be read back as the set it names is the +/// hardcoded string the registry exists to replace, spelled differently. +/// +/// The EMPTY key is refused by the same rule, and is the one refusal with a +/// broadcast behind it: it is the value `RainDeployBroadcast.run()` substitutes +/// for an absent `DEPLOYMENT_SUITE`, so a declaration allowed to answer it +/// turns "told nothing" into a selection, and `CREATE2` under a zero salt puts +/// those bytes at their own permanent address on every chain that dispatch +/// reached. An alphabet admitting no zero byte key already says that; a length +/// check beside it would be a second rule for one property, free to drift. +/// +/// Held on the DECLARATION rather than beside the substitution, for the reason +/// `NoDeployCandidates` is: a rule bound in the consumer is a rule most /// consumers do not run, and here every `suiteByName` caller pays for it rather -/// than only `run()`. Uniqueness does not already cover it — a lone empty key -/// collides with nothing. +/// than only `run()`. /// @param index Position in `allSuites()`: the released suites in declaration -/// order, then the candidates. The key itself names nothing, so the position is +/// order, then the candidates. An empty key names nothing, so the position is /// the only thing that can. -error EmptyDeploySuiteKey(uint256 index); +/// @param suite The refused key. +error InvalidDeploySuiteKey(uint256 index, string suite); /// Thrown when `DEPLOYMENT_SUITE` names no declared suite. Carries the valid /// keys, because the whole point of a registry is that the answer is not one @@ -79,11 +101,11 @@ error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, byte /// make that comparison derived-against-derived, and a guard that compares a /// value to itself is not a guard. struct DeploySuite { - /// The key. Unique across every suite a repo declares, and never empty: it - /// is what `DEPLOYMENT_SUITE` selects for broadcasting, and the label every - /// verification error names. The empty string is the value an unset - /// `DEPLOYMENT_SUITE` arrives as, so it is reserved rather than declarable - /// — see `EmptyDeploySuiteKey`. + /// The key. Unique across every suite a repo declares, and held to the key + /// alphabet — see `InvalidDeploySuiteKey`. It is what + /// `DEPLOYMENT_SUITE` selects for broadcasting, the label every + /// verification error names, and what the valid-key list an unknown suite + /// reports has to read back as. /// /// A repo with one contract and several frozen releases gives each release /// its own key, because each is separately deployable — a chain added after @@ -253,6 +275,46 @@ abstract contract RainDeploySuitesBase { } } + /// Refuses a key the registry cannot carry — see `InvalidDeploySuiteKey`. + /// + /// Held against the WHOLE key rather than against a name a released key is + /// derived from, because `releasedSuites()` declares finished keys: a rule + /// that only knew the name half could not be asked about a released entry + /// at all, and this is the one place every key a repo declares is read. + /// @param index Position in `allSuites()`, for the refusal to name. + /// @param suite The key to check. + function checkSuiteKey(uint256 index, string memory suite) internal pure { + bytes memory key = bytes(suite); + // Where the tag starts, and zero until an `@` is seen. Zero is not a + // position a tag can start at, because an `@` opening the key leaves + // the name half empty and is refused below. + uint256 tagStart = 0; + + for (uint256 i = 0; i < key.length; i++) { + bytes1 char = key[i]; + if (char == "@") { + if (i == 0 || tagStart != 0) { + revert InvalidDeploySuiteKey(index, suite); + } + tagStart = i + 1; + } else { + bool nameAlphabet = (char >= "a" && char <= "z") || char == "-"; + bool tagAlphabet = (char >= "0" && char <= "9") || char == "_"; + if (!(nameAlphabet || (tagStart != 0 && tagAlphabet))) { + revert InvalidDeploySuiteKey(index, suite); + } + } + } + + // The two ways a half can be empty, which the loop cannot see because + // it only ever refuses a byte that IS there: the empty key, where a + // tagStart still at zero meets a length of zero, and a key whose `@` is + // its last byte. + if (tagStart == key.length) { + revert InvalidDeploySuiteKey(index, suite); + } + } + /// Every suite this repo declares: the released ones followed by the /// candidates. This is the verification set and the deploy registry, which /// are the same set because they are the same declaration. @@ -261,13 +323,13 @@ abstract contract RainDeploySuitesBase { /// pay for the check and neither can be handed a registry that is ambiguous /// or that answers the absent-sentinel. One pass over the whole set, so a /// candidate colliding with another candidate is caught by the same code - /// that catches a candidate colliding with a release, and an empty key is - /// refused wherever in the declaration it was spelled — there is no second - /// rule to keep in step. + /// that catches a candidate colliding with a release, and a key the + /// alphabet refuses is refused wherever in the declaration it was spelled — + /// there is no second rule to keep in step. /// - /// Unique AND non-empty, because neither implies the other: a lone empty - /// key collides with nothing, and it is the one key `run()` can be handed - /// by accident. + /// Unique AND in the alphabet, because neither implies the other: a lone + /// unreadable key collides with nothing, and two keys can be spelled + /// perfectly and still be the same key. /// @return Every declared suite. function allSuites() internal pure returns (DeploySuite[] memory) { DeploySuite[] memory released = releasedSuites(); @@ -282,9 +344,7 @@ abstract contract RainDeploySuitesBase { } for (uint256 i = 0; i < suites.length; i++) { - if (bytes(suites[i].suite).length == 0) { - revert EmptyDeploySuiteKey(i); - } + checkSuiteKey(i, suites[i].suite); for (uint256 j = i + 1; j < suites.length; j++) { if (keccak256(bytes(suites[i].suite)) == keccak256(bytes(suites[j].suite))) { revert DuplicateDeploySuite(suites[i].suite); @@ -296,6 +356,10 @@ abstract contract RainDeploySuitesBase { } /// Every declared key, comma separated, for the unknown-suite error. + /// + /// Splitting the result on `", "` recovers exactly the declared keys, + /// because the alphabet `allSuites` holds them to carries neither of those + /// two characters anywhere but between two keys. /// @return The declared keys. function suiteNames() internal pure returns (string memory) { DeploySuite[] memory suites = allSuites(); diff --git a/test/abstract/ExampleDeploySuites.sol b/test/abstract/ExampleDeploySuites.sol index ad68d50..49e3a59 100644 --- a/test/abstract/ExampleDeploySuites.sol +++ b/test/abstract/ExampleDeploySuites.sol @@ -46,7 +46,7 @@ abstract contract ExampleDeploySuites is RainDeploySuitesBase { function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { suites = new DeploySuite[](2); suites[0] = DeploySuite({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", creationCode: ADDRESS_REGISTRY_CREATION_CODE, storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, diff --git a/test/abstract/ExternalDeploySuites.sol b/test/abstract/ExternalDeploySuites.sol index a05de4d..368fd6b 100644 --- a/test/abstract/ExternalDeploySuites.sol +++ b/test/abstract/ExternalDeploySuites.sol @@ -30,6 +30,12 @@ abstract contract ExternalDeploySuites is RainDeploySuitesBase { return suiteNames(); } + /// @param index Position in `allSuites()`, for the refusal to name. + /// @param suite The key to check. + function externalCheckSuiteKey(uint256 index, string memory suite) external pure { + checkSuiteKey(index, suite); + } + /// @return The declared candidates, refusing an empty list. function externalCheckedCandidateSuites() external pure returns (DeployCandidate[] memory) { return checkedCandidateSuites(); diff --git a/test/concrete/EmptyKeyDeploySuites.sol b/test/concrete/EmptyKeyDeploySuites.sol index 01d54d6..8019e3e 100644 --- a/test/concrete/EmptyKeyDeploySuites.sol +++ b/test/concrete/EmptyKeyDeploySuites.sol @@ -34,7 +34,7 @@ contract EmptyKeyDeploySuites is ExternalDeploySuites { function releasedSuites() internal pure override returns (DeploySuite[] memory) { DeploySuite[] memory suites = new DeploySuite[](1); suites[0] = DeploySuite({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", creationCode: ADDRESS_REGISTRY_CREATION_CODE, storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, diff --git a/test/concrete/NoCandidateDeploySuites.sol b/test/concrete/NoCandidateDeploySuites.sol index df1de45..8ae321f 100644 --- a/test/concrete/NoCandidateDeploySuites.sol +++ b/test/concrete/NoCandidateDeploySuites.sol @@ -24,7 +24,7 @@ contract NoCandidateDeploySuites is ExternalDeploySuites { function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { suites = new DeploySuite[](1); suites[0] = DeploySuite({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", creationCode: ADDRESS_REGISTRY_CREATION_CODE, storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, diff --git a/test/concrete/SeparatorKeyDeploySuites.sol b/test/concrete/SeparatorKeyDeploySuites.sol index 600615f..8dafc8f 100644 --- a/test/concrete/SeparatorKeyDeploySuites.sol +++ b/test/concrete/SeparatorKeyDeploySuites.sol @@ -14,14 +14,14 @@ import { } from "../../src/generated/candidate/AddressRegistry.sol"; /// @title SeparatorKeyDeploySuites -/// A TWO suite declaration whose first key carries the separator's comma, so -/// the list renders `a,b, c` and reads as the THREE keys `a`, `b` and `c` — -/// `b` being declared nowhere and named back to a caller as valid. +/// A TWO suite declaration whose RELEASED key carries the comma +/// `suiteNames()` joins on, so the list renders `a,b, c` and reads back as the +/// THREE keys `a`, `b` and `c` — `b` being declared nowhere and handed to a +/// caller as valid. /// -/// The comma alone, no space, so the rule's comma is what has to refuse this. -/// A key spelling the whole separator is the case the issue reports and is -/// refused by either half of the rule, which is exactly why it cannot tell the -/// two halves apart. +/// The offending key is the released one, where `EmptyKeyDeploySuites` puts its +/// own at the candidate, so between them a check that ran over either side of +/// the registry alone is caught. contract SeparatorKeyDeploySuites is ExternalDeploySuites { /// @inheritdoc RainDeploySuitesBase function releasedSuites() internal pure override returns (DeploySuite[] memory) { diff --git a/test/concrete/SpaceKeyDeploySuites.sol b/test/concrete/SpaceKeyDeploySuites.sol deleted file mode 100644 index 11e8430..0000000 --- a/test/concrete/SpaceKeyDeploySuites.sol +++ /dev/null @@ -1,57 +0,0 @@ -// 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 {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.sol"; -import {MockDeployableV2} from "./MockDeployableV2.sol"; -import {LibRainDeploy} from "../../src/lib/LibRainDeploy.sol"; -import { - BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, - CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, - DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, - RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE -} from "../../src/generated/candidate/AddressRegistry.sol"; - -/// @title SpaceKeyDeploySuites -/// A declaration whose second key opens with the separator's space rather than -/// its comma, so the list renders `a, b` — two keys, the right count, and a -/// reader who takes the second one as `b` is sent after a key that does not -/// exist by the one character the rendering cannot show them. -/// -/// The comma alone would let this through, which is why the rule is every -/// character of the separator and not the one that splits it. -contract SpaceKeyDeploySuites is ExternalDeploySuites { - /// @inheritdoc RainDeploySuitesBase - function releasedSuites() internal pure override returns (DeploySuite[] memory) { - DeploySuite[] memory suites = new DeploySuite[](1); - suites[0] = DeploySuite({ - suite: "a", - creationCode: ADDRESS_REGISTRY_CREATION_CODE, - storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, - storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, - storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, - artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", - dependencies: new address[](0) - }); - return suites; - } - - /// @inheritdoc RainDeploySuitesBase - function candidateSuites() internal pure override returns (DeployCandidate[] memory) { - DeployCandidate[] memory candidates = new DeployCandidate[](1); - candidates[0] = DeployCandidate({ - snapshot: DeploySuite({ - suite: " b", - 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 - }); - return candidates; - } -} diff --git a/test/src/abstract/RainDeployBroadcast.t.sol b/test/src/abstract/RainDeployBroadcast.t.sol index 04f81e6..71d86f8 100644 --- a/test/src/abstract/RainDeployBroadcast.t.sol +++ b/test/src/abstract/RainDeployBroadcast.t.sol @@ -148,7 +148,7 @@ contract RainDeployBroadcastTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "", - "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" + "address-registry@0_0_1, second-address, address-registry-candidate, second-address-candidate" ) ); sDeploy.run(); @@ -159,7 +159,7 @@ contract RainDeployBroadcastTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "address-registry", - "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" + "address-registry@0_0_1, second-address, address-registry-candidate, second-address-candidate" ) ); sDeploy.run(); @@ -431,11 +431,11 @@ contract RainDeployBroadcastTest is Test { /// make that comparison derived-against-derived. function testSelectedSuiteCarriesTheRecordedPins() external view { assertEq( - sDeploy.externalSuiteByName("address-registry-0-0-1").storedDeployedAddress, - LibRainDeploy.zoltuAddress(sDeploy.externalSuiteByName("address-registry-0-0-1").creationCode) + sDeploy.externalSuiteByName("address-registry@0_0_1").storedDeployedAddress, + LibRainDeploy.zoltuAddress(sDeploy.externalSuiteByName("address-registry@0_0_1").creationCode) ); assertEq( - sDeploy.externalSuiteByName("address-registry-0-0-1").artifactPath, + sDeploy.externalSuiteByName("address-registry@0_0_1").artifactPath, "src/concrete/AddressRegistry.sol:AddressRegistry" ); } diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol index 6949580..f4b3b30 100644 --- a/test/src/abstract/RainDeploySuitesBase.t.sol +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -7,7 +7,7 @@ import {Test} from "forge-std-1.16.2/src/Test.sol"; import { DeploySuite, DuplicateDeploySuite, - EmptyDeploySuiteKey, + InvalidDeploySuiteKey, NoDeployCandidates, UnknownDeploymentSuite } from "../../../src/abstract/RainDeploySuitesBase.sol"; @@ -17,6 +17,7 @@ import {DuplicateDeploySuites} from "../../concrete/DuplicateDeploySuites.sol"; import {EmptyKeyDeploySuites} from "../../concrete/EmptyKeyDeploySuites.sol"; import {NoCandidateDeploySuites} from "../../concrete/NoCandidateDeploySuites.sol"; import {SameLengthKeyDeploySuites} from "../../concrete/SameLengthKeyDeploySuites.sol"; +import {SeparatorKeyDeploySuites} from "../../concrete/SeparatorKeyDeploySuites.sol"; import {ShortestKeyDeploySuites} from "../../concrete/ShortestKeyDeploySuites.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; @@ -46,7 +47,7 @@ contract RainDeploySuitesBaseTest is Test { DeploySuite[] memory suites = sSuites.externalAllSuites(); assertEq(suites.length, 4); - assertEq(suites[0].suite, "address-registry-0-0-1"); + assertEq(suites[0].suite, "address-registry@0_0_1"); assertEq(suites[1].suite, "second-address"); assertEq(suites[2].suite, "address-registry-candidate"); assertEq(suites[3].suite, "second-address-candidate"); @@ -68,12 +69,10 @@ contract RainDeploySuitesBaseTest is Test { } /// Two suites that record the SAME creation code MUST still be selectable - /// apart. `address-registry-0-0-1` and `address-registry-candidate` are the - /// same bytes at the same address, so the key is the only thing that - /// distinguishes them — and it has to, because they are separately - /// deployable records. + /// apart: they are separately deployable records, so the key is the only + /// thing that distinguishes them. function testSuitesSharingCreationCodeSelectApart() external view { - DeploySuite memory released = sSuites.externalSuiteByName("address-registry-0-0-1"); + DeploySuite memory released = sSuites.externalSuiteByName("address-registry@0_0_1"); DeploySuite memory candidate = sSuites.externalSuiteByName("address-registry-candidate"); assertEq(keccak256(released.creationCode), keccak256(candidate.creationCode)); @@ -89,7 +88,7 @@ contract RainDeploySuitesBaseTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "mock-deployable", - "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" + "address-registry@0_0_1, second-address, address-registry-candidate, second-address-candidate" ) ); sSuites.externalSuiteByName("mock-deployable"); @@ -102,7 +101,7 @@ contract RainDeploySuitesBaseTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "", - "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" + "address-registry@0_0_1, second-address, address-registry-candidate, second-address-candidate" ) ); sSuites.externalSuiteByName(""); @@ -121,7 +120,10 @@ contract RainDeploySuitesBaseTest is Test { /// /// Refused where the key rules already are rather than at the substitution, /// so the guarantee holds for every `suiteByName` caller and not only for - /// `run()` — the argument `NoDeployCandidates` is already made of. + /// `run()` — the argument `NoDeployCandidates` is already made of. It is + /// the key ALPHABET that refuses it, which admits no key of no bytes; a + /// length check beside that alphabet would be a second rule for one + /// property, and the two could disagree. /// /// The reported index is asserted, not just the refusal. It is 1, the /// CANDIDATE, behind a released suite that is keyed properly: a check that @@ -131,34 +133,35 @@ contract RainDeploySuitesBaseTest is Test { function testEmptySuiteKeyReverts() external { EmptyKeyDeploySuites empty = new EmptyKeyDeploySuites(); - vm.expectRevert(abi.encodeWithSelector(EmptyDeploySuiteKey.selector, 1)); + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 1, "")); empty.externalAllSuites(); - vm.expectRevert(abi.encodeWithSelector(EmptyDeploySuiteKey.selector, 1)); + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 1, "")); empty.externalSuiteNames(); // The selection an unset `DEPLOYMENT_SUITE` makes. Without the refusal // this returns the candidate — `MockDeployableV2`, a real deployable // entry — rather than reporting the valid set. - vm.expectRevert(abi.encodeWithSelector(EmptyDeploySuiteKey.selector, 1)); + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 1, "")); empty.externalSuiteByName(""); // And for a key that IS spelled: one bad entry makes the whole registry // unreadable, exactly as a duplicate does, rather than leaving the // sibling entries quietly selectable out of a declaration nobody can // safely dispatch from. - vm.expectRevert(abi.encodeWithSelector(EmptyDeploySuiteKey.selector, 1)); - empty.externalSuiteByName("address-registry-0-0-1"); + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 1, "")); + empty.externalSuiteByName("address-registry@0_0_1"); } /// A ONE BYTE key MUST be an ordinary key. /// - /// The rule is that a key is not the absent-sentinel, and the sentinel is - /// the empty string exactly. Every other declaration in this repo spells - /// its keys at six bytes or more, so a refusal written against any other - /// short-key threshold passes all of them while refusing a declaration that - /// is entirely legal — and the repo would find that out from the consumer - /// that chose short keys, at the point it could no longer deploy. + /// The alphabet is about what a key may SAY, and carries no length floor + /// above the one byte it takes to say anything. Every other declaration in + /// this repo spells its keys at six bytes or more, so a refusal written + /// against any other short-key threshold passes all of them while refusing + /// a declaration that is entirely legal — and the repo would find that out + /// from the consumer that chose short keys, at the point it could no longer + /// deploy. function testShortestKeysSelectApart() external { ShortestKeyDeploySuites shortest = new ShortestKeyDeploySuites(); @@ -180,7 +183,7 @@ contract RainDeploySuitesBaseTest is Test { function testSuiteNamesIsTheRegistry() external view { assertEq( sSuites.externalSuiteNames(), - "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" + "address-registry@0_0_1, second-address, address-registry-candidate, second-address-candidate" ); } @@ -236,7 +239,7 @@ contract RainDeploySuitesBaseTest is Test { // cannot answer "no such suite" either, because it has no valid set to // report and the answer would send the reader after a typo. vm.expectRevert(abi.encodeWithSelector(NoDeployCandidates.selector)); - none.externalSuiteByName("address-registry-0-0-1"); + none.externalSuiteByName("address-registry@0_0_1"); // And at the source of the refusal itself, which is what the // source-anchored check reads through — a loop over an empty list @@ -297,4 +300,85 @@ contract RainDeploySuitesBaseTest is Test { ); sameLength.externalSuiteByName("same-length-qqq"); } + + /// A key carrying the comma the key list is joined on MUST be refused, on + /// every reader. + /// + /// That list exists so a caller who does NOT already know the valid keys is + /// told them. Joined on `", "`, the TWO suite registry keyed `a,b` and `c` + /// renders `a,b, c` — which is what a THREE suite registry keyed `a`, `b` + /// and `c` says. The reader is told a different number of suites exist than + /// do, and `b`, declared nowhere, is handed to them as valid. The + /// declaration is refused rather than rendered, because a list that reads + /// back as a different set than the one it names is the hardcoded string + /// this registry replaces, spelled differently. + /// + /// The offending key is the RELEASED one here and the CANDIDATE in + /// `testEmptySuiteKeyReverts`, so between them a check that ran over either + /// side of the registry alone is caught. + function testSeparatorKeyIsRefused() external { + SeparatorKeyDeploySuites separated = new SeparatorKeyDeploySuites(); + + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); + separated.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); + separated.externalSuiteNames(); + + // The key this declaration DOES name, refused with it: what is wrong is + // the declaration, not any one lookup against it. + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); + separated.externalSuiteByName("c"); + + // And the phantom the rendering invents. A registry that rendered this + // answers `b` with a list that names `b` as valid. + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); + separated.externalSuiteByName("b"); + } + + /// The keys the alphabet ACCEPTS. + /// + /// A table rather than a declaration contract apiece: a fixture is sixty + /// lines to say one string, and the fixtures are what pin that the rule + /// runs on every reader. What is left to pin is WHICH keys it decides which + /// way, and the accepting half is what keeps the rule from being narrowed + /// into refusing the release keys `LibRainDeploySnapshot` generates. + function testSuiteKeyAlphabetAccepts() external view { + string[7] memory accepted = + ["address-registry", "a", "a-b-c", "address-registry@0_1_10", "tofu-token-decimals@0_1_0", "a@z", "a@-_9"]; + + for (uint256 i = 0; i < accepted.length; i++) { + sSuites.externalCheckSuiteKey(i, accepted[i]); + } + } + + /// The keys the alphabet REFUSES, each naming its own position back. + /// + /// The index is asserted with the key on every entry. It is the only handle + /// on which suite is at fault when the key itself is empty, and a refusal + /// reporting a fixed position would still pass a test that only asked + /// whether it reverted. + function testSuiteKeyAlphabetRefuses() external { + string[14] memory refused = [ + "", + "@", + "@0_1_10", + "address-registry@", + "address-registry@0_1_10@0_1_11", + "Address-Registry", + "address registry", + "address,registry", + "address_registry", + "address-registry-0-0-1", + "address-registry@0_1_7-RC", + "address.registry", + "address/registry", + unicode"addréss-registry" + ]; + + for (uint256 i = 0; i < refused.length; i++) { + vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, i, refused[i])); + sSuites.externalCheckSuiteKey(i, refused[i]); + } + } } diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 88369f0..2502d1b 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -106,7 +106,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); @@ -129,7 +129,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); @@ -213,7 +213,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); @@ -249,7 +249,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain // check itself passes on all of them. DerivedDeploy[] memory derived = new DerivedDeploy[](1); derived[0] = DerivedDeploy({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, bytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH }); @@ -278,7 +278,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_BYTECODE_HASH, keccak256(hex"6001") @@ -295,7 +295,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain vm.createSelectFork(LibRainDeploy.BASE); DerivedDeploy memory derived = DerivedDeploy({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, bytecodeHash: bytes32(uint256(1)) }); @@ -304,7 +304,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.BASE, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS, bytes32(uint256(1)), ADDRESS_REGISTRY_BYTECODE_HASH @@ -355,7 +355,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, hex""); DerivedDeploy memory derived = DerivedDeploy({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, bytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH }); @@ -364,7 +364,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain abi.encodeWithSelector( NotDeployedOnNetwork.selector, networks[i], - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); diff --git a/test/src/abstract/RainDeployVerifyChainCandidate.t.sol b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol index 837a7b9..aca55c5 100644 --- a/test/src/abstract/RainDeployVerifyChainCandidate.t.sol +++ b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol @@ -40,7 +40,7 @@ contract RainDeployVerifyChainCandidateTest is RainDeployVerifyChain { function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { suites = new DeploySuite[](1); suites[0] = DeploySuite({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", creationCode: ADDRESS_REGISTRY_CREATION_CODE, storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, diff --git a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol index c58cbe1..3f275b0 100644 --- a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol @@ -438,11 +438,11 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri /// undeclared and make the check unusable the moment a repo releases twice. function testFrozenSnapshotCheckReachesEveryReleasedSuite() external view { DeploySuite[] memory released = releasedSuites(); - // `second-address` first, `address-registry-0-0-1` second. + // `second-address` first, `address-registry@0_0_1` second. DeploySuite[] memory reordered = new DeploySuite[](2); reordered[0] = released[1]; reordered[1] = released[0]; - assertEq(reordered[1].suite, "address-registry-0-0-1"); + assertEq(reordered[1].suite, "address-registry@0_0_1"); this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), reordered); } @@ -472,7 +472,7 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri /// @return The consistent `0_0_1` suite. function consistentSuite() internal pure returns (DeploySuite memory) { return DeploySuite({ - suite: "address-registry-0-0-1", + suite: "address-registry@0_0_1", creationCode: ADDRESS_REGISTRY_CREATION_CODE, storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, @@ -492,7 +492,7 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri vm.expectRevert( abi.encodeWithSelector( StoredAddressMismatch.selector, - "address-registry-0-0-1", + "address-registry@0_0_1", address(0xdead), ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) @@ -510,7 +510,7 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri vm.expectRevert( abi.encodeWithSelector( StoredCodeHashMismatch.selector, - "address-registry-0-0-1", + "address-registry@0_0_1", bytes32(uint256(1)), ADDRESS_REGISTRY_BYTECODE_HASH ) @@ -530,7 +530,7 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri vm.expectRevert( abi.encodeWithSelector( StoredRuntimeCodeHashMismatch.selector, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_BYTECODE_HASH, keccak256(hex"00") ) @@ -598,9 +598,7 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri /// Two suites that record the SAME creation code MUST both derive, which /// is the ordinary state of a repo between a release and the next source - /// change. `address-registry-0-0-1` and `address-registry-candidate` are - /// the same bytes and therefore the same address, and the whole set still - /// passes. + /// change. function testSuitesSharingCreationCodeAllDerive() external { DeploySuite[] memory suites = allSuites(); assertEq(suites.length, 4); @@ -633,7 +631,7 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri vm.expectRevert( abi.encodeWithSelector( ZoltuDerivationMismatch.selector, - "address-registry-0-0-1", + "address-registry@0_0_1", ADDRESS_REGISTRY_DEPLOYED_ADDRESS, LibRainDeploy.ZOLTU_FACTORY ) From c3bef6b5ea682e8d7ce044a3f4feca00647a6fec Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Tue, 15 Sep 2026 22:42:53 +0000 Subject: [PATCH 3/4] Cut the added comments: key alphabet essays, fixture and test narration Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- src/abstract/RainDeploySuitesBase.sol | 71 ++----------------- test/abstract/ExternalDeploySuites.sol | 2 - test/concrete/SeparatorKeyDeploySuites.sol | 9 --- test/src/abstract/RainDeploySuitesBase.t.sol | 48 +------------ .../RainDeployVerifySnapshotBase.t.sol | 4 -- 5 files changed, 8 insertions(+), 126 deletions(-) diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol index 419b32f..51e14e7 100644 --- a/src/abstract/RainDeploySuitesBase.sol +++ b/src/abstract/RainDeploySuitesBase.sol @@ -7,44 +7,11 @@ pragma solidity ^0.8.25; /// @param suite The key declared more than once. error DuplicateDeploySuite(string suite); -/// Thrown when a suite declares a key outside the key alphabet. -/// -/// A key is a NAME, or a name and a release TAG joined by an at sign: the name -/// lowercase letters and hyphens, the tag those plus digits and underscores, -/// neither half empty and at most one at sign. Digits and underscores after the -/// at sign only, which is where a tag needs them and where nothing else does. -/// -/// That is the kebab case a repo declares by hand, and it is what -/// `LibRainDeploySnapshot` emits a released entry as — the candidate key the -/// release was cut from, the at sign, and the record directory's tag, which -/// `isTag` already holds to `X_Y_Z` — so a generated declaration needs no -/// exemption from the rule a hand written one is held to. The at sign cannot -/// appear in a name, so that emission carries exactly one however many releases -/// a repo cuts. -/// -/// The key list is why an alphabet exists at all. `suiteNames()` joins the -/// declared keys on `", "` for `UnknownDeploymentSuite`, which exists so a -/// caller who does NOT already know the valid keys is told them; a key free to -/// carry either of those characters renders as two, so the reader is told a -/// different number of suites exist than do and is sent after a key that is -/// declared nowhere. A list that cannot be read back as the set it names is the -/// hardcoded string the registry exists to replace, spelled differently. -/// -/// The EMPTY key is refused by the same rule, and is the one refusal with a -/// broadcast behind it: it is the value `RainDeployBroadcast.run()` substitutes -/// for an absent `DEPLOYMENT_SUITE`, so a declaration allowed to answer it -/// turns "told nothing" into a selection, and `CREATE2` under a zero salt puts -/// those bytes at their own permanent address on every chain that dispatch -/// reached. An alphabet admitting no zero byte key already says that; a length -/// check beside it would be a second rule for one property, free to drift. -/// -/// Held on the DECLARATION rather than beside the substitution, for the reason -/// `NoDeployCandidates` is: a rule bound in the consumer is a rule most -/// consumers do not run, and here every `suiteByName` caller pays for it rather -/// than only `run()`. +/// Thrown when a suite declares a key outside the key alphabet: a name of +/// lowercase letters and hyphens, optionally followed by an at sign and a tag +/// that may also carry digits and underscores, with neither half empty. /// @param index Position in `allSuites()`: the released suites in declaration -/// order, then the candidates. An empty key names nothing, so the position is -/// the only thing that can. +/// order, then the candidates. /// @param suite The refused key. error InvalidDeploySuiteKey(uint256 index, string suite); @@ -102,10 +69,7 @@ error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, byte /// value to itself is not a guard. struct DeploySuite { /// The key. Unique across every suite a repo declares, and held to the key - /// alphabet — see `InvalidDeploySuiteKey`. It is what - /// `DEPLOYMENT_SUITE` selects for broadcasting, the label every - /// verification error names, and what the valid-key list an unknown suite - /// reports has to read back as. + /// alphabet — see `InvalidDeploySuiteKey`. /// /// A repo with one contract and several frozen releases gives each release /// its own key, because each is separately deployable — a chain added after @@ -276,18 +240,10 @@ abstract contract RainDeploySuitesBase { } /// Refuses a key the registry cannot carry — see `InvalidDeploySuiteKey`. - /// - /// Held against the WHOLE key rather than against a name a released key is - /// derived from, because `releasedSuites()` declares finished keys: a rule - /// that only knew the name half could not be asked about a released entry - /// at all, and this is the one place every key a repo declares is read. /// @param index Position in `allSuites()`, for the refusal to name. /// @param suite The key to check. function checkSuiteKey(uint256 index, string memory suite) internal pure { bytes memory key = bytes(suite); - // Where the tag starts, and zero until an `@` is seen. Zero is not a - // position a tag can start at, because an `@` opening the key leaves - // the name half empty and is refused below. uint256 tagStart = 0; for (uint256 i = 0; i < key.length; i++) { @@ -306,10 +262,7 @@ abstract contract RainDeploySuitesBase { } } - // The two ways a half can be empty, which the loop cannot see because - // it only ever refuses a byte that IS there: the empty key, where a - // tagStart still at zero meets a length of zero, and a key whose `@` is - // its last byte. + // The empty key and a trailing at sign: the loop only refuses bytes that are there. if (tagStart == key.length) { revert InvalidDeploySuiteKey(index, suite); } @@ -323,13 +276,7 @@ abstract contract RainDeploySuitesBase { /// pay for the check and neither can be handed a registry that is ambiguous /// or that answers the absent-sentinel. One pass over the whole set, so a /// candidate colliding with another candidate is caught by the same code - /// that catches a candidate colliding with a release, and a key the - /// alphabet refuses is refused wherever in the declaration it was spelled — - /// there is no second rule to keep in step. - /// - /// Unique AND in the alphabet, because neither implies the other: a lone - /// unreadable key collides with nothing, and two keys can be spelled - /// perfectly and still be the same key. + /// that catches a candidate colliding with a release. /// @return Every declared suite. function allSuites() internal pure returns (DeploySuite[] memory) { DeploySuite[] memory released = releasedSuites(); @@ -356,10 +303,6 @@ abstract contract RainDeploySuitesBase { } /// Every declared key, comma separated, for the unknown-suite error. - /// - /// Splitting the result on `", "` recovers exactly the declared keys, - /// because the alphabet `allSuites` holds them to carries neither of those - /// two characters anywhere but between two keys. /// @return The declared keys. function suiteNames() internal pure returns (string memory) { DeploySuite[] memory suites = allSuites(); diff --git a/test/abstract/ExternalDeploySuites.sol b/test/abstract/ExternalDeploySuites.sol index 368fd6b..bb134ff 100644 --- a/test/abstract/ExternalDeploySuites.sol +++ b/test/abstract/ExternalDeploySuites.sol @@ -30,8 +30,6 @@ abstract contract ExternalDeploySuites is RainDeploySuitesBase { return suiteNames(); } - /// @param index Position in `allSuites()`, for the refusal to name. - /// @param suite The key to check. function externalCheckSuiteKey(uint256 index, string memory suite) external pure { checkSuiteKey(index, suite); } diff --git a/test/concrete/SeparatorKeyDeploySuites.sol b/test/concrete/SeparatorKeyDeploySuites.sol index 8dafc8f..9ce8dab 100644 --- a/test/concrete/SeparatorKeyDeploySuites.sol +++ b/test/concrete/SeparatorKeyDeploySuites.sol @@ -13,15 +13,6 @@ import { RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE } from "../../src/generated/candidate/AddressRegistry.sol"; -/// @title SeparatorKeyDeploySuites -/// A TWO suite declaration whose RELEASED key carries the comma -/// `suiteNames()` joins on, so the list renders `a,b, c` and reads back as the -/// THREE keys `a`, `b` and `c` — `b` being declared nowhere and handed to a -/// caller as valid. -/// -/// The offending key is the released one, where `EmptyKeyDeploySuites` puts its -/// own at the candidate, so between them a check that ran over either side of -/// the registry alone is caught. contract SeparatorKeyDeploySuites is ExternalDeploySuites { /// @inheritdoc RainDeploySuitesBase function releasedSuites() internal pure override returns (DeploySuite[] memory) { diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol index f4b3b30..ca0ce2d 100644 --- a/test/src/abstract/RainDeploySuitesBase.t.sol +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -68,9 +68,6 @@ contract RainDeploySuitesBaseTest is Test { } } - /// Two suites that record the SAME creation code MUST still be selectable - /// apart: they are separately deployable records, so the key is the only - /// thing that distinguishes them. function testSuitesSharingCreationCodeSelectApart() external view { DeploySuite memory released = sSuites.externalSuiteByName("address-registry@0_0_1"); DeploySuite memory candidate = sSuites.externalSuiteByName("address-registry-candidate"); @@ -120,10 +117,7 @@ contract RainDeploySuitesBaseTest is Test { /// /// Refused where the key rules already are rather than at the substitution, /// so the guarantee holds for every `suiteByName` caller and not only for - /// `run()` — the argument `NoDeployCandidates` is already made of. It is - /// the key ALPHABET that refuses it, which admits no key of no bytes; a - /// length check beside that alphabet would be a second rule for one - /// property, and the two could disagree. + /// `run()` — the argument `NoDeployCandidates` is already made of. /// /// The reported index is asserted, not just the refusal. It is 1, the /// CANDIDATE, behind a released suite that is keyed properly: a check that @@ -154,14 +148,6 @@ contract RainDeploySuitesBaseTest is Test { } /// A ONE BYTE key MUST be an ordinary key. - /// - /// The alphabet is about what a key may SAY, and carries no length floor - /// above the one byte it takes to say anything. Every other declaration in - /// this repo spells its keys at six bytes or more, so a refusal written - /// against any other short-key threshold passes all of them while refusing - /// a declaration that is entirely legal — and the repo would find that out - /// from the consumer that chose short keys, at the point it could no longer - /// deploy. function testShortestKeysSelectApart() external { ShortestKeyDeploySuites shortest = new ShortestKeyDeploySuites(); @@ -301,21 +287,6 @@ contract RainDeploySuitesBaseTest is Test { sameLength.externalSuiteByName("same-length-qqq"); } - /// A key carrying the comma the key list is joined on MUST be refused, on - /// every reader. - /// - /// That list exists so a caller who does NOT already know the valid keys is - /// told them. Joined on `", "`, the TWO suite registry keyed `a,b` and `c` - /// renders `a,b, c` — which is what a THREE suite registry keyed `a`, `b` - /// and `c` says. The reader is told a different number of suites exist than - /// do, and `b`, declared nowhere, is handed to them as valid. The - /// declaration is refused rather than rendered, because a list that reads - /// back as a different set than the one it names is the hardcoded string - /// this registry replaces, spelled differently. - /// - /// The offending key is the RELEASED one here and the CANDIDATE in - /// `testEmptySuiteKeyReverts`, so between them a check that ran over either - /// side of the registry alone is caught. function testSeparatorKeyIsRefused() external { SeparatorKeyDeploySuites separated = new SeparatorKeyDeploySuites(); @@ -325,24 +296,13 @@ contract RainDeploySuitesBaseTest is Test { vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); separated.externalSuiteNames(); - // The key this declaration DOES name, refused with it: what is wrong is - // the declaration, not any one lookup against it. vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); separated.externalSuiteByName("c"); - // And the phantom the rendering invents. A registry that rendered this - // answers `b` with a list that names `b` as valid. vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); separated.externalSuiteByName("b"); } - /// The keys the alphabet ACCEPTS. - /// - /// A table rather than a declaration contract apiece: a fixture is sixty - /// lines to say one string, and the fixtures are what pin that the rule - /// runs on every reader. What is left to pin is WHICH keys it decides which - /// way, and the accepting half is what keeps the rule from being narrowed - /// into refusing the release keys `LibRainDeploySnapshot` generates. function testSuiteKeyAlphabetAccepts() external view { string[7] memory accepted = ["address-registry", "a", "a-b-c", "address-registry@0_1_10", "tofu-token-decimals@0_1_0", "a@z", "a@-_9"]; @@ -352,12 +312,6 @@ contract RainDeploySuitesBaseTest is Test { } } - /// The keys the alphabet REFUSES, each naming its own position back. - /// - /// The index is asserted with the key on every entry. It is the only handle - /// on which suite is at fault when the key itself is empty, and a refusal - /// reporting a fixed position would still pass a test that only asked - /// whether it reverted. function testSuiteKeyAlphabetRefuses() external { string[14] memory refused = [ "", diff --git a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol index 3f275b0..0acb497 100644 --- a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol @@ -438,7 +438,6 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri /// undeclared and make the check unusable the moment a repo releases twice. function testFrozenSnapshotCheckReachesEveryReleasedSuite() external view { DeploySuite[] memory released = releasedSuites(); - // `second-address` first, `address-registry@0_0_1` second. DeploySuite[] memory reordered = new DeploySuite[](2); reordered[0] = released[1]; reordered[1] = released[0]; @@ -596,9 +595,6 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri sMismatch.externalCheckCandidatesAnchoredToSource(); } - /// Two suites that record the SAME creation code MUST both derive, which - /// is the ordinary state of a repo between a release and the next source - /// change. function testSuitesSharingCreationCodeAllDerive() external { DeploySuite[] memory suites = allSuites(); assertEq(suites.length, 4); From fc3108cd818935e44579ca8dd92dabb227ee0d8c Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 00:23:19 +0000 Subject: [PATCH 4/4] docs: put back the key alphabet rule and the reasons behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c3bef6b cut the whole of `InvalidDeploySuiteKey`'s NatSpec down to a one sentence summary of the alphabet. What went with it, off a published abstract that deriving repos read: that a key carries at most one at sign and digits and underscores only after it; that the released keys `LibRainDeploySnapshot` generates satisfy the rule without an exemption; WHY an alphabet exists at all, which is that `suiteNames()` joins the keys on `", "` and a list that reads back as a different set is the hardcoded string this registry replaces; that the empty key is refused by the same rule because it is what `RainDeployBroadcast.run()` substitutes for an absent `DEPLOYMENT_SUITE`, where `CREATE2` under a zero salt makes the mistake permanent; and why the rule is held on the declaration rather than beside the substitution. The tests lost the same arguments where they are pinned: why there is no length floor, why the index is asserted with every refusal, and what the `a,b` fixture demonstrates — that `externalSuiteByName("b")` asks after a key the rendering invents, which is not a typo. Left cut: the `@param` lines on the test wrapper, the inline comment that spells the two array indices below it back, and the `SeparatorKeyDeploySuites` title block, whose argument now stands once on the test that makes it rather than twice. `checkSuiteKey` keeps c3bef6b's shorter form of the empty-half comment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- src/abstract/RainDeploySuitesBase.sol | 66 +++++++++++++++++-- test/src/abstract/RainDeploySuitesBase.t.sol | 44 ++++++++++++- .../RainDeployVerifySnapshotBase.t.sol | 3 + 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol index 51e14e7..594ce21 100644 --- a/src/abstract/RainDeploySuitesBase.sol +++ b/src/abstract/RainDeploySuitesBase.sol @@ -7,11 +7,44 @@ pragma solidity ^0.8.25; /// @param suite The key declared more than once. error DuplicateDeploySuite(string suite); -/// Thrown when a suite declares a key outside the key alphabet: a name of -/// lowercase letters and hyphens, optionally followed by an at sign and a tag -/// that may also carry digits and underscores, with neither half empty. +/// Thrown when a suite declares a key outside the key alphabet. +/// +/// A key is a NAME, or a name and a release TAG joined by an at sign: the name +/// lowercase letters and hyphens, the tag those plus digits and underscores, +/// neither half empty and at most one at sign. Digits and underscores after the +/// at sign only, which is where a tag needs them and where nothing else does. +/// +/// That is the kebab case a repo declares by hand, and it is what +/// `LibRainDeploySnapshot` emits a released entry as — the candidate key the +/// release was cut from, the at sign, and the record directory's tag, which +/// `isTag` already holds to `X_Y_Z` — so a generated declaration needs no +/// exemption from the rule a hand written one is held to. The at sign cannot +/// appear in a name, so that emission carries exactly one however many releases +/// a repo cuts. +/// +/// The key list is why an alphabet exists at all. `suiteNames()` joins the +/// declared keys on `", "` for `UnknownDeploymentSuite`, which exists so a +/// caller who does NOT already know the valid keys is told them; a key free to +/// carry either of those characters renders as two, so the reader is told a +/// different number of suites exist than do and is sent after a key that is +/// declared nowhere. A list that cannot be read back as the set it names is the +/// hardcoded string the registry exists to replace, spelled differently. +/// +/// The EMPTY key is refused by the same rule, and is the one refusal with a +/// broadcast behind it: it is the value `RainDeployBroadcast.run()` substitutes +/// for an absent `DEPLOYMENT_SUITE`, so a declaration allowed to answer it +/// turns "told nothing" into a selection, and `CREATE2` under a zero salt puts +/// those bytes at their own permanent address on every chain that dispatch +/// reached. An alphabet admitting no zero byte key already says that; a length +/// check beside it would be a second rule for one property, free to drift. +/// +/// Held on the DECLARATION rather than beside the substitution, for the reason +/// `NoDeployCandidates` is: a rule bound in the consumer is a rule most +/// consumers do not run, and here every `suiteByName` caller pays for it rather +/// than only `run()`. /// @param index Position in `allSuites()`: the released suites in declaration -/// order, then the candidates. +/// order, then the candidates. An empty key names nothing, so the position is +/// the only thing that can. /// @param suite The refused key. error InvalidDeploySuiteKey(uint256 index, string suite); @@ -69,7 +102,10 @@ error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, byte /// value to itself is not a guard. struct DeploySuite { /// The key. Unique across every suite a repo declares, and held to the key - /// alphabet — see `InvalidDeploySuiteKey`. + /// alphabet — see `InvalidDeploySuiteKey`. It is what + /// `DEPLOYMENT_SUITE` selects for broadcasting, the label every + /// verification error names, and what the valid-key list an unknown suite + /// reports has to read back as. /// /// A repo with one contract and several frozen releases gives each release /// its own key, because each is separately deployable — a chain added after @@ -240,10 +276,18 @@ abstract contract RainDeploySuitesBase { } /// Refuses a key the registry cannot carry — see `InvalidDeploySuiteKey`. + /// + /// Held against the WHOLE key rather than against a name a released key is + /// derived from, because `releasedSuites()` declares finished keys: a rule + /// that only knew the name half could not be asked about a released entry + /// at all, and this is the one place every key a repo declares is read. /// @param index Position in `allSuites()`, for the refusal to name. /// @param suite The key to check. function checkSuiteKey(uint256 index, string memory suite) internal pure { bytes memory key = bytes(suite); + // Where the tag starts, and zero until an `@` is seen. Zero is not a + // position a tag can start at, because an `@` opening the key leaves + // the name half empty and is refused below. uint256 tagStart = 0; for (uint256 i = 0; i < key.length; i++) { @@ -276,7 +320,13 @@ abstract contract RainDeploySuitesBase { /// pay for the check and neither can be handed a registry that is ambiguous /// or that answers the absent-sentinel. One pass over the whole set, so a /// candidate colliding with another candidate is caught by the same code - /// that catches a candidate colliding with a release. + /// that catches a candidate colliding with a release, and a key the + /// alphabet refuses is refused wherever in the declaration it was spelled — + /// there is no second rule to keep in step. + /// + /// Unique AND in the alphabet, because neither implies the other: a lone + /// unreadable key collides with nothing, and two keys can be spelled + /// perfectly and still be the same key. /// @return Every declared suite. function allSuites() internal pure returns (DeploySuite[] memory) { DeploySuite[] memory released = releasedSuites(); @@ -303,6 +353,10 @@ abstract contract RainDeploySuitesBase { } /// Every declared key, comma separated, for the unknown-suite error. + /// + /// Splitting the result on `", "` recovers exactly the declared keys, + /// because the alphabet `allSuites` holds them to carries neither of those + /// two characters anywhere but between two keys. /// @return The declared keys. function suiteNames() internal pure returns (string memory) { DeploySuite[] memory suites = allSuites(); diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol index ca0ce2d..ece036e 100644 --- a/test/src/abstract/RainDeploySuitesBase.t.sol +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -68,6 +68,9 @@ contract RainDeploySuitesBaseTest is Test { } } + /// Two suites that record the SAME creation code MUST still be selectable + /// apart: they are separately deployable records, so the key is the only + /// thing that distinguishes them. function testSuitesSharingCreationCodeSelectApart() external view { DeploySuite memory released = sSuites.externalSuiteByName("address-registry@0_0_1"); DeploySuite memory candidate = sSuites.externalSuiteByName("address-registry-candidate"); @@ -117,7 +120,10 @@ contract RainDeploySuitesBaseTest is Test { /// /// Refused where the key rules already are rather than at the substitution, /// so the guarantee holds for every `suiteByName` caller and not only for - /// `run()` — the argument `NoDeployCandidates` is already made of. + /// `run()` — the argument `NoDeployCandidates` is already made of. It is + /// the key ALPHABET that refuses it, which admits no key of no bytes; a + /// length check beside that alphabet would be a second rule for one + /// property, and the two could disagree. /// /// The reported index is asserted, not just the refusal. It is 1, the /// CANDIDATE, behind a released suite that is keyed properly: a check that @@ -148,6 +154,14 @@ contract RainDeploySuitesBaseTest is Test { } /// A ONE BYTE key MUST be an ordinary key. + /// + /// The alphabet is about what a key may SAY, and carries no length floor + /// above the one byte it takes to say anything. Every other declaration in + /// this repo spells its keys at six bytes or more, so a refusal written + /// against any other short-key threshold passes all of them while refusing + /// a declaration that is entirely legal — and the repo would find that out + /// from the consumer that chose short keys, at the point it could no longer + /// deploy. function testShortestKeysSelectApart() external { ShortestKeyDeploySuites shortest = new ShortestKeyDeploySuites(); @@ -287,6 +301,21 @@ contract RainDeploySuitesBaseTest is Test { sameLength.externalSuiteByName("same-length-qqq"); } + /// A key carrying the comma the key list is joined on MUST be refused, on + /// every reader. + /// + /// That list exists so a caller who does NOT already know the valid keys is + /// told them. Joined on `", "`, the TWO suite registry keyed `a,b` and `c` + /// renders `a,b, c` — which is what a THREE suite registry keyed `a`, `b` + /// and `c` says. The reader is told a different number of suites exist than + /// do, and `b`, declared nowhere, is handed to them as valid. The + /// declaration is refused rather than rendered, because a list that reads + /// back as a different set than the one it names is the hardcoded string + /// this registry replaces, spelled differently. + /// + /// The offending key is the RELEASED one here and the CANDIDATE in + /// `testEmptySuiteKeyReverts`, so between them a check that ran over either + /// side of the registry alone is caught. function testSeparatorKeyIsRefused() external { SeparatorKeyDeploySuites separated = new SeparatorKeyDeploySuites(); @@ -296,13 +325,22 @@ contract RainDeploySuitesBaseTest is Test { vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); separated.externalSuiteNames(); + // The key this declaration DOES name, refused with it: what is wrong is + // the declaration, not any one lookup against it. vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); separated.externalSuiteByName("c"); + // And the phantom the rendering invents. A registry that rendered this + // answers `b` with a list that names `b` as valid. vm.expectRevert(abi.encodeWithSelector(InvalidDeploySuiteKey.selector, 0, "a,b")); separated.externalSuiteByName("b"); } + /// A table rather than a declaration contract apiece: a fixture is sixty + /// lines to say one string, and the fixtures are what pin that the rule + /// runs on every reader. What is left to pin is WHICH keys it decides which + /// way, and the accepting half is what keeps the rule from being narrowed + /// into refusing the release keys `LibRainDeploySnapshot` generates. function testSuiteKeyAlphabetAccepts() external view { string[7] memory accepted = ["address-registry", "a", "a-b-c", "address-registry@0_1_10", "tofu-token-decimals@0_1_0", "a@z", "a@-_9"]; @@ -312,6 +350,10 @@ contract RainDeploySuitesBaseTest is Test { } } + /// The index is asserted with the key on every entry. It is the only handle + /// on which suite is at fault when the key itself is empty, and a refusal + /// reporting a fixed position would still pass a test that only asked + /// whether it reverted. function testSuiteKeyAlphabetRefuses() external { string[14] memory refused = [ "", diff --git a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol index 0acb497..bf1f8ef 100644 --- a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol @@ -595,6 +595,9 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri sMismatch.externalCheckCandidatesAnchoredToSource(); } + /// Two suites that record the SAME creation code MUST both derive, which + /// is the ordinary state of a repo between a release and the next source + /// change. function testSuitesSharingCreationCodeAllDerive() external { DeploySuite[] memory suites = allSuites(); assertEq(suites.length, 4);