diff --git a/internal/wasm/sdk/test/e2e/exceptions.yaml b/internal/wasm/sdk/test/e2e/exceptions.yaml index d8eb5d5283..afd9333d64 100644 --- a/internal/wasm/sdk/test/e2e/exceptions.yaml +++ b/internal/wasm/sdk/test/e2e/exceptions.yaml @@ -1,3 +1,4 @@ # Exception Format is : "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." diff --git a/v1/test/cases/testdata/v1/strings/test-strings-format-int-bignum.yaml b/v1/test/cases/testdata/v1/strings/test-strings-format-int-bignum.yaml new file mode 100644 index 0000000000..891de6610c --- /dev/null +++ b/v1/test/cases/testdata/v1/strings/test-strings-format-int-bignum.yaml @@ -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" diff --git a/v1/topdown/strings.go b/v1/topdown/strings.go index 90fa39ca8f..99885622f5 100644 --- a/v1/topdown/strings.go +++ b/v1/topdown/strings.go @@ -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)