Skip to content

Commit acfa6e6

Browse files
glasstigerclaude
andcommitted
Serialize token-store long fields as digits
FileTokenStore.putLongMember wrote a long field via sink.put(long), which routes through Numbers.append(..., checkNaN=true) and renders Long.MIN_VALUE as the literal JSON null. The two long fields (expires_at_millis, token_ttl_millis) are present, non-nullable integers, so a bare null there breaks the frozen cross-language format (serialize() omits absent fields rather than writing null, so a null is indistinguishable from absent) and round-trips back to 0 on read via parseLongOrZero -- a silent corruption a Python peer would also diverge on. putLongMember now calls Numbers.append(sink, value, false), which emits the full number, so every long value round-trips verbatim. The path is reachable only through the public FileTokenStore.save(...) SPI; OidcDeviceAuth clamps both fields to a non-negative range before persisting, so the OIDC flow never hits it. Add testLongFieldsSerializeAsDigitsNotBareNull: it saves Long.MIN_VALUE in both long fields, asserts the on-disk JSON carries the digits rather than a bare null, and that load() round-trips them instead of collapsing to 0. The test fails on the pre-fix code and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f3e62ed commit acfa6e6

2 files changed

Lines changed: 32 additions & 1 deletion

File tree

core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,12 @@ private static void putBooleanMember(StringSink sink, String name, boolean value
402402
private static void putLongMember(StringSink sink, String name, long value) {
403403
sink.put(',');
404404
putName(sink, name);
405-
sink.put(value);
405+
// write the digits unconditionally. sink.put(long) routes through Numbers.append(..., checkNaN=true),
406+
// which renders Long.MIN_VALUE as the literal JSON null - a bare null for a present, non-nullable
407+
// integer field would break the frozen cross-language contract (serialize() OMITS absent fields rather
408+
// than writing null, so a null here is indistinguishable from absent) and round-trips back to 0 via
409+
// parseLongOrZero. checkNaN=false emits the full number, so every long value round-trips verbatim.
410+
Numbers.append(sink, value, false);
406411
}
407412

408413
private static void putName(StringSink sink, String name) {

core/src/test/java/io/questdb/client/test/cutlass/auth/FileTokenStoreTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,32 @@ public void testLockFilePermissionsOwnerOnly() throws Exception {
682682
});
683683
}
684684

685+
@Test
686+
public void testLongFieldsSerializeAsDigitsNotBareNull() throws Exception {
687+
assertMemoryLeak(() -> {
688+
Path dir = storeDir();
689+
FileTokenStore store = new FileTokenStore(dir);
690+
TokenStoreKey key = sampleKey();
691+
// the two long fields are present, non-nullable integers, so Long.MIN_VALUE must serialize as its
692+
// digits. serialize() reserves an omitted member (not a bare null) for an absent value, so a null
693+
// here would be indistinguishable from absent and breaks the frozen cross-language contract; the
694+
// reader would also round-trip that null back to 0 (parseLongOrZero), a silent corruption.
695+
store.save(key, new PersistedToken("ACCESS-1", null, "REFRESH-1", Long.MIN_VALUE, Long.MIN_VALUE));
696+
697+
String json = new String(Files.readAllBytes(tokenFile(dir, key)), StandardCharsets.UTF_8);
698+
Assert.assertTrue("expires_at_millis must be written as digits, not a bare null [json=" + json + ']',
699+
json.contains("\"expires_at_millis\":-9223372036854775808"));
700+
Assert.assertTrue("token_ttl_millis must be written as digits, not a bare null [json=" + json + ']',
701+
json.contains("\"token_ttl_millis\":-9223372036854775808"));
702+
703+
// and the extreme value round-trips verbatim rather than collapsing to 0 on read
704+
PersistedToken loaded = store.load(key);
705+
Assert.assertNotNull(loaded);
706+
Assert.assertEquals(Long.MIN_VALUE, loaded.getExpiresAtMillis());
707+
Assert.assertEquals(Long.MIN_VALUE, loaded.getTokenTtlMillis());
708+
});
709+
}
710+
685711
@Test
686712
public void testNoLeftoverTempFileAfterSave() throws Exception {
687713
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)