@@ -232,7 +232,7 @@ fn apply_keystream(key: &TaskCursorKey, iv: &[u8; TAG_LEN], buf: &mut [u8]) {
232232/// The serialized payload is padded with trailing whitespace to a fixed
233233/// bucket before the SIV tag is computed: the keystream preserves plaintext
234234/// length, so an unpadded payload would let the token's length leak the
235- /// named row's field widths (a 32 -char `to_rfc3339()` rendering mints a
235+ /// named row's field widths (a 25 -char `to_rfc3339()` rendering mints a
236236/// shorter token than a 35-char one). `decode` tolerates the padding because
237237/// `serde_json` ignores trailing whitespace, and the tag covers the padded
238238/// bytes, so a token minted before padding landed still verifies.
@@ -369,7 +369,7 @@ mod tests {
369369 let k = key ( ) ;
370370 // Ids are server-minted fixed-length UUIDs; the timestamp is the
371371 // width that varies in production (`to_rfc3339()` renders 35 chars
372- // with nanoseconds, 32 without fractional seconds).
372+ // with nanoseconds, 25 without fractional seconds).
373373 let id = "b3f7a1c2-4d5e-4f6a-8b9c-0d1e2f3a4b5c" ;
374374 let narrow = encode (
375375 & k,
@@ -395,6 +395,86 @@ mod tests {
395395 ) ;
396396 }
397397
398+ /// A token minted before the fixed-width padding landed (unpadded payload,
399+ /// tag over the raw bytes) must still verify: `decode` MACs whatever
400+ /// plaintext the keystream recovers and `serde_json` tolerates the
401+ /// missing trailing whitespace. This pins the back-compat claim in the
402+ /// `encode` docstring using the pre-change recipe directly.
403+ #[ test]
404+ fn unpadded_legacy_token_still_verifies ( ) {
405+ let k = key ( ) ;
406+ let filter = unfiltered ( ) ;
407+ let position = TaskPosition :: new ( "2026-01-03T00:00:00+00:00" , "legacy-task" ) ;
408+ // Pre-padding recipe: serialize, tag, and encrypt with no padding.
409+ let mut payload = serde_json:: to_vec ( & CursorPayload {
410+ t : & position. created_at ,
411+ i : & position. id ,
412+ e : chrono:: Utc :: now ( ) . timestamp ( ) + CURSOR_TTL_SECS ,
413+ } )
414+ . expect ( "cursor payload is plain strings and an integer" ) ;
415+ assert ! (
416+ payload. len( ) < PAYLOAD_BUCKET ,
417+ "fixture must stay under one bucket so the legacy shape is unpadded"
418+ ) ;
419+ let iv = siv ( & k, filter, None , & payload) ;
420+ apply_keystream ( & k, & iv, & mut payload) ;
421+ let token = format ! (
422+ "{CURSOR_PREFIX}.{}.{}" ,
423+ URL_SAFE_NO_PAD . encode( iv) ,
424+ URL_SAFE_NO_PAD . encode( & payload)
425+ ) ;
426+ assert_eq ! (
427+ decode( & k, filter, None , & token) . unwrap( ) ,
428+ position,
429+ "pre-padding token must verify under the current decode"
430+ ) ;
431+ }
432+
433+ /// The `PAYLOAD_BUCKET` docstring promises an oversized payload rounds up
434+ /// to the next multiple instead of failing. Mint a position whose id
435+ /// pushes the serialized payload past one bucket and require the token
436+ /// to round-trip (at two buckets).
437+ #[ test]
438+ fn oversized_payload_rounds_up_to_next_bucket ( ) {
439+ let k = key ( ) ;
440+ // ~140-char id: payload crosses the 128-byte bucket.
441+ let long_id = "a" . repeat ( 140 ) ;
442+ let position = TaskPosition :: new ( "2026-01-03T00:00:00+00:00" , long_id. clone ( ) ) ;
443+ let probe = serde_json:: to_vec ( & CursorPayload {
444+ t : & position. created_at ,
445+ i : & position. id ,
446+ e : 1_700_000_000 ,
447+ } )
448+ . expect ( "cursor payload is plain strings and an integer" ) ;
449+ assert ! (
450+ probe. len( ) > PAYLOAD_BUCKET ,
451+ "fixture payload must exceed one bucket: {} bytes" ,
452+ probe. len( )
453+ ) ;
454+ assert ! (
455+ probe. len( ) <= 2 * PAYLOAD_BUCKET ,
456+ "fixture payload must fit in two buckets: {} bytes" ,
457+ probe. len( )
458+ ) ;
459+ let token = encode ( & k, unfiltered ( ) , None , & position) ;
460+ assert_eq ! (
461+ decode( & k, unfiltered( ) , None , & token) . unwrap( ) ,
462+ position,
463+ "oversized payload must round-trip at two buckets"
464+ ) ;
465+ // Two buckets of ciphertext must be longer than one bucket's worth.
466+ let single = encode (
467+ & k,
468+ unfiltered ( ) ,
469+ None ,
470+ & TaskPosition :: new ( "2026-01-03T00:00:00+00:00" , "task-a" ) ,
471+ ) ;
472+ assert ! (
473+ token. len( ) > single. len( ) ,
474+ "two-bucket token must be longer than a one-bucket token"
475+ ) ;
476+ }
477+
398478 /// Callers paste the token straight into a query string, so it must carry
399479 /// no character that needs percent-encoding.
400480 #[ test]
0 commit comments