From 4e318a870f160a86e1354be3e09374dc4a0d6cba Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 13:00:18 +0000 Subject: [PATCH] Refuse a leading zero on a release-version component `isStrictTriple` accepted any non-empty run of digits per component, so `0.01.5` and `0.1.5` were two spellings of one release. `freeze` gates a re-cut on the directory existing, so the second spelling freezes the same release again under a name `SnapshotAlreadyFrozen` never sees as the first; `recordPrecedes` reads both through `vm.parseUint` and so compares them EQUAL as versions, ordering them by the accident of a zero character. A component is one number, so it gets one spelling. A single `0` stays legal: it IS the number zero, and `0.0.0` is a real version. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/LibRainDeploySnapshot.sol | 14 ++++++++++-- test/src/lib/LibRainDeploySnapshot.t.sol | 29 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) 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 {