Skip to content

airframe BigIntCodec/BigIntegerCodec silently truncates large negative values during pack #4181

Description

@haskiindahouse

What

Project

airframe (wvlet/airframe)

Category

codec, serialization

Severity

wrong-code

Versions

All versions of airframe-codec (confirmed on latest main)

File

airframe-codec/src/main/scala/wvlet/airframe/codec/PrimitiveCodec.scala

Description

Both BigIntCodec.pack and BigIntegerCodec.pack have a boundary check that only tests the upper bound (<= Long.MaxValue) but does not test the lower bound (>= Long.MinValue). This means any negative BigInt value -- no matter how large in magnitude -- passes the check and gets silently truncated via .longValue.

Reproducer

import wvlet.airframe.codec.PrimitiveCodec.BigIntCodec
import wvlet.airframe.msgpack.spi.MessagePack

// A BigInt far below Long.MinValue
val hugeNegative = BigInt("-123456789012345678901234567890")

// Pack it
val packer = MessagePack.newBufferPacker
BigIntCodec.pack(packer, hugeNegative)
val bytes = packer.toByteArray

// Unpack it
val unpacker = MessagePack.newUnpacker(bytes)
val result = BigInt(unpacker.unpackLong)

println(s"Original: $hugeNegative")
println(s"Roundtripped: $result")
// Original:     -123456789012345678901234567890
// Roundtripped:  6101065590887403150  (or similar wrong value)

Buggy code (lines 309-314)

override def pack(p: Packer, v: BigInt): Unit = {
  if (v.compareTo(BigInt(Long.MaxValue)) <= 0) {  // BUG: true for ALL negative values
    p.packLong(v.longValue)                         // silently truncates
  } else {
    p.packString(v.toString(10))
  }
}

The same pattern exists in BigIntegerCodec.pack (lines 355-360):

override def pack(p: Packer, v: java.math.BigInteger): Unit = {
  if (v.compareTo(java.math.BigInteger.valueOf(Long.MaxValue)) <= 0) {  // BUG
    p.packLong(v.longValue())
  } else {
    p.packString(v.toString(10))
  }
}

Expected behavior

Values outside the range [Long.MinValue, Long.MaxValue] should be packed as strings to preserve precision.

Actual behavior

All negative values (including those below Long.MinValue) are packed as Long, silently losing data.

Fix

Add lower bound check:

if (v >= BigInt(Long.MinValue) && v <= BigInt(Long.MaxValue)) {
  p.packLong(v.longValue)
} else {
  p.packString(v.toString(10))
}

Duplicate check

  • Searched: "BigIntCodec overflow", "BigInt pack truncate", "BigInteger negative"
  • No existing issues found

Minimal runnable reproducer

Save as repro.scala and run scala-cli run --server=false repro.scala:

//> using scala 3.6.4
//> using dep org.wvlet.airframe::airframe-codec:24.10.0

// Bug 242: airframe BigIntCodec silently truncates large negative BigInt values during pack.
// Root cause: PrimitiveCodec.BigIntCodec.pack only checks upper bound (v <= Long.MaxValue),
// not the lower bound, so any negative value -- no matter how big -- flows into packLong
// via .longValue, which truncates by taking the low 64 bits.

import wvlet.airframe.codec.MessageCodec
import wvlet.airframe.codec.PrimitiveCodec.BigIntCodec
import wvlet.airframe.msgpack.spi.MessagePack

@main def repro(): Unit =
  val hugeNegative = BigInt("-99999999999999999999999")

  // Pack via BigIntCodec
  val packer = MessagePack.newBufferPacker
  BigIntCodec.pack(packer, hugeNegative)
  val bytes = packer.toByteArray

  // Unpack as a Long (this is what the buggy path does)
  val unpacker = MessagePack.newUnpacker(bytes)
  val roundtrippedLong = unpacker.unpackLong
  val roundtripped = BigInt(roundtrippedLong)

  println(s"Expected (original): $hugeNegative")
  println(s"Actual (roundtrip):  $roundtripped")
  println(s"Equal?               ${hugeNegative == roundtripped}")
  println(s"Truncation occurred: ${hugeNegative != roundtripped}")

  // Also exercise the full MessageCodec[BigInt] roundtrip API
  val codec = MessageCodec.of[BigInt]
  val packed = codec.toMsgPack(hugeNegative)
  val unpacked = codec.unpack(packed)
  println(s"MessageCodec[BigInt] roundtrip: $unpacked")
  println(s"MessageCodec equal?             ${unpacked == hugeNegative}")

  if hugeNegative != roundtripped then
    println("BUG REPRODUCED: BigIntCodec.pack silently truncated a large negative BigInt.")
    sys.exit(1)
  else
    println("No truncation observed.")

Verified locally:

  1. cross-vendor adversarial gate (codex prover + claude skeptic + 2 independent judges, distinct judge_id by vendor) returned accepted
  2. scala-cli run --server=false on the repro above reproduces the symptom (exit code / output mismatch)

AI-assisted report. If I missed context or it's intended behavior, sorry — happy to close. I batch-audited my older filings yesterday and self-closed 8 false positives, so I'm trying to be conservative now.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions