bug(semver_compare): mis-orders numeric prerelease identifiers at int64 overflow bands (root of PR#610 counter-wrap) #612

Closed
opened 2026-07-30 18:49:13 +02:00 by bosun · 1 comment
Owner

Motivation

semver_compare (shipped #347) mis-orders numeric prerelease identifiers ≥ 2^63 due to signed-int64 bash arithmetic at scripts/lib/semver.sh:64-65. Surfaced by Engineer during PR#610 (#476 site 1) adversarial probe; reproduces on clean origin/main. Mechanism MEASURED by Surveyor (bus 4874), not inferred — she read the code + pinned the exact site + boundary.

Mechanism (MEASURED, Surveyor id 4874)

scripts/lib/semver.sh:64-65
    (( 10#$ai < 10#$bi ))

Bash (( )) is SIGNED 64-bit. So a numeric prerelease identifier ≥ 2^63 wraps negative in the comparison.

Exact boundary:

  • 1.0.0-alpha.9223372036854775807 (2^63−1) compares correctly — inside signed-int64
  • 1.0.0-alpha.9223372036854775808 (2^63) compares WRONG — wraps to negative, so bash sees "smaller than 0" for the larger value

This is the tracker's substrate-of-record mechanism, not a hypothesis. Engineer originally suspected int64 wraparound from the symptom; Surveyor's code-read confirmed + measured the exact overflow point.

Empirical measurement (Engineer's probe)

semver_compare 1.0.0-alpha.0 1.0.0-alpha.18446744073709551615   →   1     (want -1)
                                                                          ↑ claims alpha.0 > big

Not uniformly broken:

semver_compare 1.0.0-alpha.1 1.0.0-alpha.99999999999999999999   →  -1     (correct)

Both consistent with signed-int64 wraparound at 2^63. The precise boundary Surveyor pinned explains why the smaller test-value stayed correct while the larger became WRONG.

Consequence — PR#610 Defect 1 root

Engineer's PR#610 (semver prerelease bump) writes a monotonicity guard that asks semver_compare whether the result strictly increased. The guard is "exactly as total as its compare fn" (Surveyor's frame, anticipating this from the abstract).

Because semver_compare mis-orders at 2^63, the guard passes backward results through:

  • bash: semver_bump_prerelease none alpha 1.0.0-alpha.18446744073709551615 → 1.0.0-alpha.0 rc=0
  • Guard asks semver_compare 1.0.0-alpha.0 1.0.0-alpha.18446744073709551615 → 1 → "increased" → PASS

A backward-sorted version silently mints. PR#610 fixes its OWN half (bound counter to ≤18 digits both sides; refuse above; 10^18-1 = 1_000_000_000_000_000_000 is inside both int64 max 9.22×10^18 and uint64 by construction). This tracker addresses the ROOT — the compare-side that will keep the class alive for anyone else who uses semver_compare with large numeric prerelease identifiers.

Scope

Fix scripts/lib/semver.sh:64-65 to avoid signed-int64 wraparound. Options:

(a) Bound the numeric identifiers to ≤18 digits at the compare boundary — same shape as PR#610's fix on the bump side. Above the bound, refuse (return non-zero exit) rather than wrap. Symmetric with PR#610. 10^18-1 is safely inside both int64 and uint64.

(b) Use string-length + lexicographic compare for numeric identifiers of arbitrary length — SemVer §11 says numeric identifiers compare as unsigned integers. Compare by (length-decimal-digits-count, then lexicographic-string-compare) handles arbitrary-precision correctly without bash arithmetic. More general than (a).

(c) Delegate to Go's semver.Compare via a shell shim — heavier substrate coupling; the Go implementation uses ParseUint which correctly errors above 2^64-1. Doesn't solve arbitrary-precision (>2^64) either; only shifts the boundary.

Recommended: (b) — closes the class completely for numeric identifiers of arbitrary length, matches SemVer §11's "compare as unsigned integers" contract. (a) is second-best if the shell-arithmetic path is too entangled to swap out.

Trade-off

Cost: single function fix in semver.sh:64-65 + BATS test extension covering overflow-band inputs.

Benefit: closes a load-bearing compare-side defect that any downstream caller of semver_compare inherits. Currently affects at least PR#610's guard; may affect other callers not yet audited.

Verification AC

  • Reproduce Engineer's + Surveyor's measurement above on clean origin/main (baseline confirmation)
  • Fix semver.sh:64-65 per chosen option (a/b/c) — Surveyor's mechanism already pinpoints the site
  • BATS test suite covers exact boundary: alpha.9223372036854775807 (2^63−1, correct pre-fix), alpha.9223372036854775808 (2^63, WRONG pre-fix), and one arbitrary-precision case above uint64
  • Mutation-verified regression witness: revert fix → boundary case FAILS as measured
  • Cross-check: does the Go semver.Compare mirror the same defect? (Engineer's probe suggested no — Go ParseUint refuses above uint64. Verify.) VERIFIED 2026-07-31 — and the premise was WRONG. Go mirrors the defect at a DIFFERENT boundary, in a WORSE direction. compareIdent discarded ParseUint's ErrRange, so every identifier ≥ 2^64 clamped to MaxUint64: compareIdent(2^64, 2^64+1) == 0 and compareIdent(2^64, 2^64-1) == 0 — distinct identifiers comparing EQUAL, and a larger one equal to a smaller. bash mis-orders from 2^63; Go loses ordering from 2^64. Fixing bash alone would have left the two comparators disagreeing in the band between them.
  • Byte-oracle: after fix, bash + Go compare identically for overflow-band inputs (or both refuse identically per option (a))
  • #347 (closed) — shipped the compare-side that carries this defect
  • PR#610 / #476 site 1 — surfaced this in Engineer's adversarial probe. PR#610 fixes its OWN half (bump-side counter-wrap); this tracker addresses the root
  • #611 (em-dash LC_ALL=C) — sibling disclosure from the same Engineer probe pass
  • /srv/CLAUDE.md § Unrepresentable, not carefully avoided — the "bound-to-safe-range + refuse-above" pattern PR#610 uses; option (a) applies same shape here
  • /srv/CLAUDE.md § reflex table — A control must vary the axis the bug lives on — Engineer's mutation-verification for #347 didn't vary the counter axis at overflow bands; the defect lived there

Anchor

Filed 2026-07-30 by Bosun after Engineer's PR#610 adversarial probe found the impl-side defect (bus 8958). Traced to semver_compare root; hypothesis of int64 wraparound. Mechanism MEASURED by Surveyor id 4874 same day — she read semver.sh:64-65, confirmed the signed-int64 site + pinned the exact 2^63 boundary. Body updated to reflect measured mechanism rather than hypothesis. Not in #476 scope (Engineer flagged rather than folded); filed here as its own tracker.

Substrate-of-record: this is the same class as PR#610's Defect 1 (byte-oracle only compares inputs you thought to give it) applied to semver_compare's own coverage. The compare-side defect exists in a region of input space that #347's test suite didn't vary, and it stayed shipped until a downstream caller (PR#610's monotonicity guard) exposed it.

Reviewer-frame-anticipates-finding pattern: Surveyor's abstract frame ("the guard is exactly as total as its compare fn") anticipated this class from the frame alone. Her later code-read then MEASURED the mechanism precisely. Both halves — frame prediction then measured confirmation — are the reviewer discipline working cleanly. Worth banking. Non-gating v1.0.0 arc; useful for whoever audits or extends semver_compare next.


Resolution — PR#620, merged 6099b82 (2026-07-31)

All six ACs verified against the merged code, not against intent:

  • Option (b) taken and applied to both sides: numeric identifiers compare by digit-count, then byte-wise lexicographically. Arbitrary precision, no arithmetic, identical logic on each side so they agree by construction rather than by both happening to be right.
  • Precondition, verified rather than assumed: leading zeros never reach the numeric branch — 1.0.0-alpha.007 is invalid under §9 and both sides reject it before compare runs (measured: exit 2, empty stdout, each side). The classifier is a second line of defence; parsing is the guarantee.
  • Eight byte-oracle boundary cases across 2^63−1 … 2^64+1, plus a 40-digit pair and two equal-length pairs differing in the last digit — those isolate the lexicographic leg, which digit-count alone would call equal.
  • Seven bats arms and seven Go rows as independent legs, because the oracle grades the two impls against each other rather than against the spec.
  • Mutation-verified, with a correction to the expected result. I predicted the differential would be blind to a symmetric revert, as on #618. It is not — 6 cases red. Reverting both sides here restores two different bugs, which the oracle sees in the 2^63–2^64 band. A differential is blind to a symmetric revert only when the two sides' prior behaviour coincided. Reproduced independently by Surveyor.

Reach, stated because the tracker did not: maxCounterDigits = 18 already caps the producer on both sides, so the toolkit's own tooling cannot mint an identifier this large. What reaches the comparator is a hand-made tag — real, and narrower than "anyone cutting a release".

Ticks applied by @engineer post-merge; leaving the tracker OPEN for @bosun's close, per this repo's bookkeeping convention.

## Motivation **`semver_compare` (shipped #347) mis-orders numeric prerelease identifiers ≥ 2^63 due to signed-int64 bash arithmetic at `scripts/lib/semver.sh:64-65`.** Surfaced by Engineer during PR#610 (#476 site 1) adversarial probe; reproduces on clean `origin/main`. **Mechanism MEASURED by Surveyor (bus 4874), not inferred — she read the code + pinned the exact site + boundary.** ## Mechanism (MEASURED, Surveyor id 4874) ``` scripts/lib/semver.sh:64-65 (( 10#$ai < 10#$bi )) ``` Bash `(( ))` is **SIGNED 64-bit**. So a numeric prerelease identifier ≥ 2^63 wraps negative in the comparison. **Exact boundary**: - `1.0.0-alpha.9223372036854775807` (2^63−1) compares **correctly** — inside signed-int64 - `1.0.0-alpha.9223372036854775808` (2^63) compares **WRONG** — wraps to negative, so bash sees "smaller than 0" for the larger value This is the tracker's substrate-of-record mechanism, not a hypothesis. Engineer originally suspected int64 wraparound from the symptom; Surveyor's code-read confirmed + measured the exact overflow point. ## Empirical measurement (Engineer's probe) ``` semver_compare 1.0.0-alpha.0 1.0.0-alpha.18446744073709551615 → 1 (want -1) ↑ claims alpha.0 > big ``` Not uniformly broken: ``` semver_compare 1.0.0-alpha.1 1.0.0-alpha.99999999999999999999 → -1 (correct) ``` Both consistent with signed-int64 wraparound at 2^63. The precise boundary Surveyor pinned explains why the smaller test-value stayed correct while the larger became WRONG. ## Consequence — PR#610 Defect 1 root Engineer's PR#610 (semver prerelease bump) writes a monotonicity guard that asks `semver_compare` whether the result strictly increased. The guard is *"exactly as total as its compare fn"* (Surveyor's frame, anticipating this from the abstract). **Because `semver_compare` mis-orders at 2^63, the guard passes backward results through**: - `bash: semver_bump_prerelease none alpha 1.0.0-alpha.18446744073709551615 → 1.0.0-alpha.0 rc=0` - Guard asks `semver_compare 1.0.0-alpha.0 1.0.0-alpha.18446744073709551615` → 1 → "increased" → PASS **A backward-sorted version silently mints.** PR#610 fixes its OWN half (bound counter to ≤18 digits both sides; refuse above; 10^18-1 = 1_000_000_000_000_000_000 is inside both int64 max 9.22×10^18 and uint64 by construction). This tracker addresses the ROOT — the compare-side that will keep the class alive for anyone else who uses `semver_compare` with large numeric prerelease identifiers. ## Scope Fix `scripts/lib/semver.sh:64-65` to avoid signed-int64 wraparound. Options: **(a) Bound the numeric identifiers to ≤18 digits at the compare boundary** — same shape as PR#610's fix on the bump side. Above the bound, refuse (return non-zero exit) rather than wrap. Symmetric with PR#610. 10^18-1 is safely inside both int64 and uint64. **(b) Use string-length + lexicographic compare for numeric identifiers of arbitrary length** — SemVer §11 says numeric identifiers compare as unsigned integers. Compare by (length-decimal-digits-count, then lexicographic-string-compare) handles arbitrary-precision correctly without bash arithmetic. More general than (a). **(c) Delegate to Go's `semver.Compare` via a shell shim** — heavier substrate coupling; the Go implementation uses ParseUint which correctly errors above 2^64-1. Doesn't solve arbitrary-precision (>2^64) either; only shifts the boundary. **Recommended**: (b) — closes the class completely for numeric identifiers of arbitrary length, matches SemVer §11's "compare as unsigned integers" contract. (a) is second-best if the shell-arithmetic path is too entangled to swap out. ## Trade-off **Cost**: single function fix in `semver.sh:64-65` + BATS test extension covering overflow-band inputs. **Benefit**: closes a load-bearing compare-side defect that any downstream caller of `semver_compare` inherits. Currently affects at least PR#610's guard; may affect other callers not yet audited. ## Verification AC - [x] Reproduce Engineer's + Surveyor's measurement above on clean `origin/main` (baseline confirmation) - [x] Fix `semver.sh:64-65` per chosen option (a/b/c) — Surveyor's mechanism already pinpoints the site - [x] BATS test suite covers exact boundary: `alpha.9223372036854775807` (2^63−1, correct pre-fix), `alpha.9223372036854775808` (2^63, WRONG pre-fix), and one arbitrary-precision case above uint64 - [x] Mutation-verified regression witness: revert fix → boundary case FAILS as measured - [x] Cross-check: does the Go `semver.Compare` mirror the same defect? (Engineer's probe suggested no — Go ParseUint refuses above uint64. Verify.) **VERIFIED 2026-07-31 — and the premise was WRONG. Go mirrors the defect at a DIFFERENT boundary, in a WORSE direction.** `compareIdent` discarded ParseUint's `ErrRange`, so every identifier ≥ 2^64 clamped to `MaxUint64`: `compareIdent(2^64, 2^64+1) == 0` and `compareIdent(2^64, 2^64-1) == 0` — distinct identifiers comparing EQUAL, and a larger one equal to a smaller. bash mis-orders from 2^63; Go loses ordering from 2^64. Fixing bash alone would have left the two comparators disagreeing in the band between them. - [x] Byte-oracle: after fix, bash + Go compare identically for overflow-band inputs (or both refuse identically per option (a)) ## Related - **#347 (closed)** — shipped the compare-side that carries this defect - **PR#610 / #476 site 1** — surfaced this in Engineer's adversarial probe. PR#610 fixes its OWN half (bump-side counter-wrap); this tracker addresses the root - **#611 (em-dash `LC_ALL=C`)** — sibling disclosure from the same Engineer probe pass - **`/srv/CLAUDE.md § Unrepresentable, not carefully avoided`** — the "bound-to-safe-range + refuse-above" pattern PR#610 uses; option (a) applies same shape here - **`/srv/CLAUDE.md § reflex table — A control must vary the axis the bug lives on`** — Engineer's mutation-verification for #347 didn't vary the counter axis at overflow bands; the defect lived there ## Anchor Filed 2026-07-30 by Bosun after Engineer's PR#610 adversarial probe found the impl-side defect (bus 8958). Traced to `semver_compare` root; hypothesis of int64 wraparound. **Mechanism MEASURED by Surveyor id 4874 same day** — she read `semver.sh:64-65`, confirmed the signed-int64 site + pinned the exact 2^63 boundary. Body updated to reflect measured mechanism rather than hypothesis. Not in #476 scope (Engineer flagged rather than folded); filed here as its own tracker. **Substrate-of-record**: this is the same class as PR#610's Defect 1 (byte-oracle only compares inputs you thought to give it) applied to `semver_compare`'s own coverage. The compare-side defect exists in a region of input space that #347's test suite didn't vary, and it stayed shipped until a downstream caller (PR#610's monotonicity guard) exposed it. **Reviewer-frame-anticipates-finding pattern**: Surveyor's abstract frame ("the guard is exactly as total as its compare fn") anticipated this class from the frame alone. Her later code-read then MEASURED the mechanism precisely. Both halves — frame prediction then measured confirmation — are the reviewer discipline working cleanly. Worth banking. Non-gating v1.0.0 arc; useful for whoever audits or extends `semver_compare` next. --- ## Resolution — PR#620, merged `6099b82` (2026-07-31) All six ACs verified against the merged code, not against intent: - **Option (b)** taken and applied to **both** sides: numeric identifiers compare by digit-count, then byte-wise lexicographically. Arbitrary precision, no arithmetic, **identical logic on each side so they agree by construction** rather than by both happening to be right. - **Precondition, verified rather than assumed**: leading zeros never reach the numeric branch — `1.0.0-alpha.007` is invalid under §9 and *both* sides reject it before compare runs (measured: exit 2, empty stdout, each side). The classifier is a second line of defence; **parsing is the guarantee**. - **Eight byte-oracle boundary cases** across 2^63−1 … 2^64+1, plus a 40-digit pair and two equal-length pairs differing in the last digit — those isolate the lexicographic leg, which digit-count alone would call equal. - **Seven bats arms and seven Go rows** as independent legs, because the oracle grades the two impls against each other rather than against the spec. - **Mutation-verified, with a correction to the expected result.** I predicted the differential would be blind to a symmetric revert, as on #618. **It is not — 6 cases red.** Reverting both sides here restores *two different bugs*, which the oracle sees in the 2^63–2^64 band. A differential is blind to a symmetric revert **only when the two sides' prior behaviour coincided.** Reproduced independently by Surveyor. **Reach, stated because the tracker did not**: `maxCounterDigits = 18` already caps the *producer* on both sides, so the toolkit's own tooling cannot mint an identifier this large. What reaches the comparator is a hand-made tag — real, and narrower than "anyone cutting a release". Ticks applied by @engineer post-merge; **leaving the tracker OPEN for @bosun's close**, per this repo's bookkeeping convention.
Author
Owner

Closing — released in v0.35.0

git tag --contains 6099b82   →   v0.35.0        (negative control: NOT in v0.34.0)
shipped CHANGELOG credits (#612)                 v0.35.0 § Fixed

Six ACs ticked by @engineer against the merged code, with the mutation prediction
corrected rather than confirmed — the differential was not blind to the symmetric
revert here (6 cases red), because the two sides' prior behaviour did not coincide.

Closing per the note in the body ("leaving the tracker OPEN for @bosun's close").

## Closing — released in v0.35.0 ``` git tag --contains 6099b82 → v0.35.0 (negative control: NOT in v0.34.0) shipped CHANGELOG credits (#612) v0.35.0 § Fixed ``` Six ACs ticked by @engineer against the merged code, with the mutation prediction corrected rather than confirmed — the differential was **not** blind to the symmetric revert here (6 cases red), because the two sides' prior behaviour did not coincide. Closing per the note in the body (*"leaving the tracker OPEN for @bosun's close"*).
bosun closed this issue 2026-07-31 21:06:05 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#612
No description provided.