From 3ac1d64ab47ee0bedafaf8072b9dc04bce378e87 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 15:46:24 +0200 Subject: [PATCH] fix(12-05): cast floats to strings with PHP's 14-digit precision in request validation PHP 8's (string) cast of a float formats with the precision ini (14 significant digits, %.14G), not the shortest round-trip form: 1/3 is 0.33333333333333, 1e14 is 1.0E+14 and 5e-324 is 4.9406564584125E-324. phpFloatString, used for the string form of JSON floats in size, in, regex and integer checks, printed up to 17 digits and switched to the exponent form only from 1e15. TestPHPFloatStringMatchesPHPCast pins 27 values to php -r output. --- modules/lagoon/validate_rules.go | 44 +++++++++++++++++++-------- modules/lagoon/validate_rules_test.go | 43 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/modules/lagoon/validate_rules.go b/modules/lagoon/validate_rules.go index 63e02ec..79531ec 100644 --- a/modules/lagoon/validate_rules.go +++ b/modules/lagoon/validate_rules.go @@ -618,8 +618,11 @@ func phpScalarString(value any) (string, bool) { return "", false } -// phpFloatString formats a float as PHP 8 casts it to string: the shortest -// round-trip digits, in exponent form (1.0E+15) from 1e15 up and below 1e-4. +// phpFloatString formats a float as PHP 8 casts it to string: %.14G with +// the default precision ini of 14 (zend_gcvt). The value is correctly +// rounded to 14 significant digits and trailing zeros are dropped; it is +// written in exponent form (1.0E+15, 1.5E-5) when the decimal point would +// sit more than 14 digits right or more than 3 zeros left of the digits. func phpFloatString(f float64) string { switch { case math.IsNaN(f): @@ -634,21 +637,38 @@ func phpFloatString(f float64) string { } return "0" } - e := strconv.FormatFloat(f, 'e', -1, 64) + const precision = 14 + e := strconv.FormatFloat(math.Abs(f), 'e', precision-1, 64) mant, expStr, _ := strings.Cut(e, "e") exp, _ := strconv.Atoi(expStr) - if exp >= -4 && exp < 15 { - return strconv.FormatFloat(f, 'f', -1, 64) + digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0") + if digits == "" { + digits = "0" } - if !strings.Contains(mant, ".") { - mant += ".0" + decpt := exp + 1 + var out string + switch { + case decpt < -3 || decpt > precision: + m := digits[:1] + ".0" + if len(digits) > 1 { + m = digits[:1] + "." + digits[1:] + } + sign := "+" + if exp < 0 { + sign, exp = "-", -exp + } + out = m + "E" + sign + strconv.Itoa(exp) + case decpt <= 0: + out = "0." + strings.Repeat("0", -decpt) + digits + case decpt >= len(digits): + out = digits + strings.Repeat("0", decpt-len(digits)) + default: + out = digits[:decpt] + "." + digits[decpt:] } - sign := "+" - if exp < 0 { - sign = "-" - exp = -exp + if f < 0 { + out = "-" + out } - return mant + "E" + sign + strconv.Itoa(exp) + return out } // phpTrim trims the characters PHP's trim() does. diff --git a/modules/lagoon/validate_rules_test.go b/modules/lagoon/validate_rules_test.go index 6af7ac1..f5b6e0f 100644 --- a/modules/lagoon/validate_rules_test.go +++ b/modules/lagoon/validate_rules_test.go @@ -2,6 +2,7 @@ package lagoon import ( "encoding/json" + "math" "reflect" "strings" "testing" @@ -209,3 +210,45 @@ func TestValidateRulesMatchLaravel(t *testing.T) { }) } } + +// TestPHPFloatStringMatchesPHPCast pins phpFloatString to PHP 8's (string) +// cast of a float, which formats with the precision ini (14 significant +// digits, %.14G): the wants are php -r '(string)$x' output. +func TestPHPFloatStringMatchesPHPCast(t *testing.T) { + for _, tc := range []struct { + in float64 + want string + }{ + {0.1 + 0.2, "0.3"}, + {1e14, "1.0E+14"}, + {1e15, "1.0E+15"}, + {1e20, "1.0E+20"}, + {123456789012345.0, "1.2345678901234E+14"}, + {12345678901234.0, "12345678901234"}, + {1.5e-5, "1.5E-5"}, + {0.0001, "0.0001"}, + {0.00012345678901234567, "0.00012345678901235"}, + {-2.5, "-2.5"}, + {3.0, "3"}, + {1.0 / 3, "0.33333333333333"}, + {2.0 / 3, "0.66666666666667"}, + {-1e-10, "-1.0E-10"}, + {99999999999999.9, "1.0E+14"}, + {0.1, "0.1"}, + {999999999999999.0, "1.0E+15"}, + {5e-324, "4.9406564584125E-324"}, + {1.7976931348623157e308, "1.7976931348623E+308"}, + {100.0, "100"}, + {1234.5678, "1234.5678"}, + {-0.000099999, "-9.9999E-5"}, + {0, "0"}, + {math.Copysign(0, -1), "-0"}, + {math.Inf(1), "INF"}, + {math.Inf(-1), "-INF"}, + {math.NaN(), "NAN"}, + } { + if got := phpFloatString(tc.in); got != tc.want { + t.Errorf("phpFloatString(%v) = %q, want %q", tc.in, got, tc.want) + } + } +}