commit c4c352c2133350d86f3ebe32be8e97da5cff5480
parent cd74fa6d5a6849eed5f1949eadc9e791678349a2
Author: Marc Stibane <marc@taler.net>
Date: Thu, 13 Aug 2026 16:41:40 +0200
AI: guard timestamp overflow
Diffstat:
2 files changed, 53 insertions(+), 6 deletions(-)
diff --git a/taler-swift/Sources/taler-swift/Time.swift b/taler-swift/Sources/taler-swift/Time.swift
@@ -18,6 +18,9 @@ enum TimestampError: Error {
/// The value `never` cannot be returned as UInt64.
case invalidUInt64Value
+
+ /// The number of seconds is outside the range the protocol allows.
+ case invalidSecondsValue
}
// MARK: -
/// A point in time, represented by milliseconds from January 1, 1970..
@@ -30,14 +33,20 @@ public enum Timestamp: Codable, Hashable, Sendable {
case t_ms = "t_ms"
}
+ /// The protocol declares every timestamp as an integer capped at 2^53-1.
+ static let maxSeconds: UInt64 = (1 << 53) - 1
+
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
+ if let seconds: UInt64 = try? container.decode(UInt64.self, forKey: .t_s) {
+ // Reject out of range instead of letting `seconds * 1000` overflow, which
+ // would trap - a crash no do/catch around the decode could survive.
+ guard seconds <= Self.maxSeconds else { throw TimestampError.invalidSecondsValue }
+ self = Timestamp.milliseconds(seconds * 1000)
+ return
+ }
do {
- if let seconds: UInt64 = try? container.decode(UInt64.self, forKey: .t_s) {
- self = Timestamp.milliseconds(seconds * 1000)
- } else {
- self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_ms))
- }
+ self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_ms))
} catch { // rethrows or never
let stringValue = try container.decode(String.self, forKey: .t_s)
if stringValue == "never" {
@@ -294,7 +303,14 @@ public func -(lhs: Timestamp, rhs: Timestamp) throws -> Duration {
if lhs_ms < rhs_ms {
return Duration.microseconds(0)
} else {
- return Duration.microseconds((lhs_ms - rhs_ms) * 1000)
+ // A difference of more than ~585000 years cannot be expressed in
+ // microseconds. Saturate like + and - with a Duration do, don't trap.
+ let result = (lhs_ms - rhs_ms).multipliedReportingOverflow(by: 1000)
+ if result.overflow {
+ return Duration.forever
+ } else {
+ return Duration.microseconds(result.partialValue)
+ }
}
case .never:
throw TimestampError.invalidArithmeticArguments
diff --git a/taler-swift/Tests/taler-swiftTests/TimeTests.swift b/taler-swift/Tests/taler-swiftTests/TimeTests.swift
@@ -34,6 +34,21 @@ class TimeTests: XCTestCase {
XCTAssertThrowsError(try JSONDecoder().decode(Timestamp.self, from: json2))
}
+ func testParsingOutOfRangeSeconds() {
+ // The protocol caps a timestamp at 2^53-1 seconds, which must still decode...
+ let maxSeconds: UInt64 = (1 << 53) - 1
+ let json1 = "{ \"t_s\" : \(maxSeconds) }".data(using: .utf8)!
+ let t: Timestamp = try! JSONDecoder().decode(Timestamp.self, from: json1)
+ XCTAssertEqual(t, Timestamp.milliseconds(maxSeconds * 1000))
+
+ // ...while anything above it must be rejected. Multiplying it by 1000 unchecked
+ // overflows UInt64 and traps, which no do/catch around the decode can survive.
+ for tooBig in [maxSeconds + 1, 20_000_000_000_000_000, UInt64.max] {
+ let json = "{ \"t_s\" : \(tooBig) }".data(using: .utf8)!
+ XCTAssertThrowsError(try JSONDecoder().decode(Timestamp.self, from: json))
+ }
+ }
+
func testSerialize() {
// Encoding is lossy: milliseconds are truncated to whole seconds (see bugs.txt),
// so a round trip only holds for whole-second timestamps.
@@ -98,4 +113,20 @@ class TimeTests: XCTestCase {
XCTAssert(try! Timestamp.milliseconds(12309487) - Timestamp.milliseconds(12309488) == Duration.microseconds((0) * 1000))
XCTAssertThrowsError(try Timestamp.milliseconds(12309487) - Timestamp.never)
}
+
+ func testTimestampDifferenceOverflow() {
+ // t_s = 2^53-1 is a timestamp the protocol allows, so it can arrive from any
+ // exchange or merchant. Its distance from now doesn't fit into microseconds:
+ // the difference must saturate to forever, not trap.
+ let maxSeconds: UInt64 = (1 << 53) - 1
+ let farFuture = Timestamp.milliseconds(maxSeconds * 1000)
+ XCTAssert(try! farFuture - Timestamp.milliseconds(0) == Duration.forever)
+ XCTAssert(try! farFuture.duration() == Duration.forever)
+ XCTAssert(try! Timestamp.milliseconds(UInt64.max) - Timestamp.milliseconds(0) == Duration.forever)
+
+ // right below the boundary the exact difference is still returned
+ let maxDiffMs = UInt64.max / 1000
+ XCTAssert(try! Timestamp.milliseconds(maxDiffMs) - Timestamp.milliseconds(0) == Duration.microseconds(maxDiffMs * 1000))
+ XCTAssert(try! Timestamp.milliseconds(maxDiffMs + 1) - Timestamp.milliseconds(0) == Duration.forever)
+ }
}