diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index c8c684f..f43d0be 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -87,8 +87,8 @@ library LibRainDeploySnapshot { return tagForVersion(vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version")); } - /// Whether `subject` is three non-empty runs of digits joined by exactly - /// two `separator`s. + /// Whether `subject` is three numbers joined by exactly two `separator`s, + /// each written without a leading zero. /// /// The ONE definition of the release-version shape. It is asked with `.` /// for a version out of `foundry.toml` and with `_` for the directory that @@ -115,6 +115,16 @@ library LibRainDeploySnapshot { separators++; digitsInComponent = 0; } else if (char >= "0" && char <= "9") { + // A component is one number, so it has one spelling. `01` and + // `1` are the same release and would freeze to two directories, + // neither of which `SnapshotAlreadyFrozen` sees as the other, + // and which `recordPrecedes` cannot order because they compare + // equal as versions. `i` is at least 1 wherever + // `digitsInComponent == 1`, because that digit was read at an + // earlier index. + if (digitsInComponent == 1 && subjectBytes[i - 1] == "0") { + return false; + } digitsInComponent++; } else { return false; diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index b917ea4..130d560 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -99,6 +99,25 @@ contract LibRainDeploySnapshotTest is Test { assertFalse(LibRainDeploySnapshot.isTag("collision-guard")); } + /// A component has ONE spelling. A padded component is the same release as + /// its unpadded form: it freezes to a second directory the immutability + /// check does not recognise as the first, and the two compare equal as + /// versions so nothing can order them either. + function testIsStrictTripleRefusesLeadingZeros() external pure { + assertFalse(LibRainDeploySnapshot.isStrictTriple("0.01.5", ".")); + assertFalse(LibRainDeploySnapshot.isStrictTriple("01.1.5", ".")); + assertFalse(LibRainDeploySnapshot.isStrictTriple("0.1.05", ".")); + assertFalse(LibRainDeploySnapshot.isStrictTriple("00.0.0", ".")); + assertFalse(LibRainDeploySnapshot.isTag("0_01_5")); + + // The boundary: a single zero IS the number zero, and `0.0.0` is a real + // version. + assertTrue(LibRainDeploySnapshot.isStrictTriple("0.0.0", ".")); + assertTrue(LibRainDeploySnapshot.isStrictTriple("0.10.0", ".")); + assertTrue(LibRainDeploySnapshot.isStrictTriple("10.0.100", ".")); + assertTrue(LibRainDeploySnapshot.isTag("0_0_0")); + } + /// EVERY version a freeze accepts MUST produce a directory the record /// recognises as a release. A version that could be frozen to a directory /// the record then ignores is exactly the orphan snapshot @@ -185,6 +204,16 @@ contract LibRainDeploySnapshotTest is Test { } } + /// The refusal MUST be reachable through the release path, naming the + /// version, rather than only through the predicate. + function testTagForVersionRefusesLeadingZeros() external { + string[3] memory bad = ["0.01.5", "01.1.5", "0.1.05"]; + for (uint256 i = 0; i < bad.length; i++) { + vm.expectRevert(abi.encodeWithSelector(UnreleasableVersion.selector, bad[i])); + this.externalTagForVersion(bad[i]); + } + } + /// The tag read from `foundry.toml` MUST go through the same guard, so a /// repo cannot reach a release path with a version the guard would refuse. function testDeployTagUsesTheGuardedConversion() external view {