topdown: fix format_int precision loss for integers larger than 64 bits (#8857)

## Description

`format_int(x, base)` corrupts integers that need more than 64 bits of
precision, in every base. It routes the value through
`builtins.NumberToFloat` (a `big.Float` with a 64-bit mantissa) then
`f.Int()`, so any integer above ~2^64 is rounded before formatting:

```rego
format_int(18446744073709551617, 16)   # "10000000000000000"    (should be "10000000000000001")
format_int(18446744073709551617, 10)   # "18446744073709551616" (should be "...617")
```

`sprintf("%x", [18446744073709551617])` returns the correct
`10000000000000001`, so two builtins disagree on the same exact-integer
value.

## Fix

Format integer inputs through an exact `big.Int` (mirroring
`builtinSprintf`). Fractional/exponent inputs still fall through to the
existing float-truncation path, so `format_int(15.9, 16) == "f"` and
`format_int(-15.9, 16) == "-f"` are unchanged.

## Test

Added a golden case covering a >2^64 integer in bases 2/8/10/16,
negatives, and the fractional-truncation cases. Full `go test
./v1/topdown/` passes (900+ existing string golden cases, no
regressions).

---------

Signed-off-by: Synvoya <16019863+Synvoya@users.noreply.github.com>
Co-authored-by: Synvoya <16019863+Synvoya@users.noreply.github.com>
This commit is contained in:
Synvoya
2026-07-09 03:13:17 +10:00
committed by GitHub
parent 39fcb030e5
commit 65c39790fc
3 changed files with 46 additions and 0 deletions
@@ -1,3 +1,4 @@
# Exception Format is <test name>: <reason>
"data/toplevel integer": "https://github.com/open-policy-agent/opa/issues/3711"
"data/nested integer": "https://github.com/open-policy-agent/opa/issues/3711"
"strings/format_int: bignum exact (>64-bit integer, all bases + negative)": "WASM cannot represent integers larger than 64 bits (see https://github.com/open-policy-agent/opa/issues/3711); this change fixes the Go topdown builtin."
@@ -0,0 +1,31 @@
---
cases:
- note: "strings/format_int: bignum exact (>64-bit integer, all bases + negative)"
query: data.generated.p = x
modules:
- |
package generated
p := result if {
result := {
"b16": format_int(18446744073709551617, 16),
"b10": format_int(18446744073709551617, 10),
"b8": format_int(18446744073709551617, 8),
"b2": format_int(18446744073709551617, 2),
"neg16": format_int(-18446744073709551617, 16),
"neg10": format_int(-18446744073709551617, 10),
"frac16": format_int(15.9, 16),
"fracneg16": format_int(-15.9, 16),
}
}
data: {}
want_result:
- x:
b16: "10000000000000001"
b10: "18446744073709551617"
b8: "2000000000000000000001"
b2: "10000000000000000000000000000000000000000000000000000000000000001"
neg16: "-10000000000000001"
neg10: "-18446744073709551617"
frac16: "f"
fracneg16: "-f"
+14
View File
@@ -129,11 +129,14 @@ func builtinFormatInt(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Ter
}
var format string
var radix int
switch base {
case ast.Number("2"):
format = "%b"
radix = 2
case ast.Number("8"):
format = "%o"
radix = 8
case ast.Number("10"):
// Fast path: for numbers whose decimal string is already interned (e.g.
// "0""100"), we can skip strconv.ParseInt entirely.
@@ -144,12 +147,23 @@ func builtinFormatInt(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Ter
return iter(ast.InternedIntegerString(i))
}
format = "%d"
radix = 10
case ast.Number("16"):
format = "%x"
radix = 16
default:
return builtins.NewOperandEnumErr(2, "2", "8", "10", "16")
}
// For integer inputs, format the exact big.Int. Routing integers through a
// float (as the fractional path below does) loses precision for values that
// need more than a float64's 53-bit mantissa, e.g. 18446744073709551617.
if i, ok := new(big.Int).SetString(string(input), 10); ok {
return iter(ast.InternedTerm(i.Text(radix)))
}
// Fractional inputs (e.g. 15.9) are truncated toward zero, matching the
// historical behaviour: format_int(15.9, 16) == "f", format_int(-15.9, 16) == "-f".
f := builtins.NumberToFloat(input)
i, _ := f.Int(nil)