Uuid::new_v7 can panic or wrap on large Timestamp values because secs * 1000 is not saturating #868

Closed
opened 2026-03-15 10:34:21 -05:00 by meng-xu-cs · 1 comment
meng-xu-cs commented 2026-03-15 10:34:21 -05:00 (Migrated from github.com)

Summary

Uuid::new_v7 currently converts a Timestamp from (seconds, subsec_nanos) to Unix milliseconds like this:

let (secs, nanos) = ts.to_unix();
let millis = (secs * 1000).saturating_add(nanos as u64 / 1_000_000);

The saturating_add happens after a plain u64 multiplication. For sufficiently large public Timestamp inputs, secs * 1000 overflows first.

Because Timestamp::from_unix_time is a public safe API and accepts arbitrary u64 seconds, this is reachable without unsafe.

Reproduction

Using uuid = { version = "1.22.0", features = ["v7"] }:

use uuid::{Timestamp, Uuid};

fn main() {
    let ts = Timestamp::from_unix_time(u64::MAX, 0, 0, 0);
    let uuid = Uuid::new_v7(ts);
    println!("{uuid}");
}

Debug build

cargo run

This panics with an overflow in Uuid::new_v7.

Release build

cargo run --release

This does not panic, but the multiplication wraps before encoding, so the UUID gets a wrapped/truncated millisecond timestamp that is unrelated to the input timestamp.

Impact

This is a correctness bug in a public safe API:

  • callers can trigger a panic in debug builds using Timestamp::from_unix_time,
  • release builds produce UUIDs whose v7 timestamp field does not correspond to the supplied timestamp,
  • behavior differs by build mode for the same input.

Even though these are extreme timestamp values, the API currently accepts them and the implementation already suggests it intends to avoid overflow via saturating_add.

Suggested fix

A minimal fix is to make the multiplication saturating too:

let millis = secs
    .saturating_mul(1000)
    .saturating_add((nanos / 1_000_000) as u64);

A more complete fix would also clamp millis to the maximum value representable by UUIDv7's 48-bit millisecond timestamp field before passing it to Builder::from_unix_timestamp_millis, e.g.:

const MAX_V7_MILLIS: u64 = (1u64 << 48) - 1;

let millis = secs
    .saturating_mul(1000)
    .saturating_add((nanos / 1_000_000) as u64)
    .min(MAX_V7_MILLIS);

That would avoid both the immediate overflow and a later mismatch between an oversized u64 millisecond value and the 48-bit v7 timestamp encoding.

## Summary `Uuid::new_v7` currently converts a `Timestamp` from `(seconds, subsec_nanos)` to Unix milliseconds like this: ```rust let (secs, nanos) = ts.to_unix(); let millis = (secs * 1000).saturating_add(nanos as u64 / 1_000_000); ``` The `saturating_add` happens **after** a plain `u64` multiplication. For sufficiently large public `Timestamp` inputs, `secs * 1000` overflows first. Because `Timestamp::from_unix_time` is a public safe API and accepts arbitrary `u64` seconds, this is reachable without `unsafe`. ## Reproduction Using `uuid = { version = "1.22.0", features = ["v7"] }`: ```rust use uuid::{Timestamp, Uuid}; fn main() { let ts = Timestamp::from_unix_time(u64::MAX, 0, 0, 0); let uuid = Uuid::new_v7(ts); println!("{uuid}"); } ``` ### Debug build ```bash cargo run ``` This panics with an overflow in `Uuid::new_v7`. ### Release build ```bash cargo run --release ``` This does not panic, but the multiplication wraps before encoding, so the UUID gets a wrapped/truncated millisecond timestamp that is unrelated to the input timestamp. ## Impact This is a correctness bug in a public safe API: - callers can trigger a panic in debug builds using `Timestamp::from_unix_time`, - release builds produce UUIDs whose v7 timestamp field does not correspond to the supplied timestamp, - behavior differs by build mode for the same input. Even though these are extreme timestamp values, the API currently accepts them and the implementation already suggests it intends to avoid overflow via `saturating_add`. ## Suggested fix A minimal fix is to make the multiplication saturating too: ```rust let millis = secs .saturating_mul(1000) .saturating_add((nanos / 1_000_000) as u64); ``` A more complete fix would also clamp `millis` to the maximum value representable by UUIDv7's 48-bit millisecond timestamp field before passing it to `Builder::from_unix_timestamp_millis`, e.g.: ```rust const MAX_V7_MILLIS: u64 = (1u64 << 48) - 1; let millis = secs .saturating_mul(1000) .saturating_add((nanos / 1_000_000) as u64) .min(MAX_V7_MILLIS); ``` That would avoid both the immediate overflow and a later mismatch between an oversized `u64` millisecond value and the 48-bit v7 timestamp encoding.
KodrAus commented 2026-03-16 22:31:18 -05:00 (Migrated from github.com)

Thanks @meng-xu-cs, we should be using a saturating multiplication there. I’m not sure if we need to further clamp the value, since we shift it into position in the resulting UUID, so if there’s work we can safely avoid doing then I think we should.

Thanks @meng-xu-cs, we should be using a saturating multiplication there. I’m not sure if we need to further clamp the value, since we shift it into position in the resulting UUID, so if there’s work we can safely avoid doing then I think we should.
Sign in to join this conversation.
No milestone
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
gh-uuid-rs/uuid#868
No description provided.