Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions dd-trace-api/src/main/java/datadog/trace/api/DD64bTraceId.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ public static DD64bTraceId from(String s) throws NumberFormatException {
return DD64bTraceId.create(LongStringUtils.parseUnsignedLong(s), s);
}

/**
* Like {@link #from(String)}, but returns {@code null} instead of throwing when the given {@code
* String} is not a plain decimal unsigned 64 bit id. Stricter than {@link #from(String)}: a
* leading {@code +} and non-ASCII digits are rejected.
*
* <p>Internal API for parsing untrusted input, such as propagation headers, on the request path.
* Not intended for use outside the tracer.
*
* @param s String of unsigned 64 bit id
* @return DDTraceId, or {@code null} if the {@code String} is not a valid id
*/
public static DD64bTraceId fromOrNull(String s) {
int len = s == null ? 0 : s.length();
long id = LongStringUtils.parseUnsignedLongOrSentinel(s, 0, len, 0L);
if (id == 0L && !LongStringUtils.isUnsignedLongZero(s, 0, len)) {
return null;
}
return DD64bTraceId.create(id, s);
}

/**
* Create a new {@code DDTraceId} from the given {@code String} hex representation of the unsigned
* 64 bit id.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,93 @@ private static int firstNonZeroCharacter(CharSequence s, int start) {
return firstNonZero;
}

/**
* Parse the decimal representation of the unsigned 64 bit long from the {@code String}.
*
* @param s String in decimal of unsigned 64-bits long.
* @return long
* @throws NumberFormatException
*/
public static long parseUnsignedLong(String s) throws NumberFormatException {
long result = parseUnsignedLongOrSentinel(s, 0, s == null ? 0 : s.length(), 0L);
if (result != 0L) {
return result;
}
// Zero is ambiguous with the invalid sentinel. Let the strict parser decide: it accepts input
// the non-throwing parser rejects (a leading '+', non-ASCII digits) and builds the exception.
return parseUnsignedLongStrict(s);
}

/**
* Parse the decimal representation of the unsigned 64 bit long from the {@code CharSequence}
* without throwing, for untrusted input such as propagation headers.
*
* <p>Accepts 1 to 20 ASCII digits only: no sign, no whitespace, no non-ASCII digits. An unsigned
* 64 bit value uses every bit, so no return value can mean "invalid" on its own. Callers pick an
* {@code ifInvalid} that is impossible or harmless for their use, e.g. {@code 0} for trace and
* span ids. With {@code 0} as {@code ifInvalid}, {@link #isUnsignedLongZero} tells a real zero
* apart from invalid input.
*
* @param s CharSequence containing the decimal digits
* @param start the start index of the decimal value
* @param len the length of the decimal value
* @param ifInvalid the value returned for null, empty, malformed or out of range input
* @return the parsed long, or {@code ifInvalid}
*/
public static long parseUnsignedLongOrSentinel(
CharSequence s, int start, int len, long ifInvalid) {
if (s == null || len <= 0 || len > 20 || start < 0 || start + len > s.length()) {
Comment thread
dougqh marked this conversation as resolved.
return ifInvalid;
}
int end = start + len;
// Signed 64 bits holds any 18 digit number, so the first 18 digits cannot overflow
int fastEnd = Math.min(end, start + 18);
long result = 0;
int i = start;
for (; i < fastEnd; i++) {
int d = s.charAt(i) - '0';
if (d < 0 || d > 9) {
return ifInvalid;
}
result = result * 10 + d;
}
for (; i < end; i++) {
int d = s.charAt(i) - '0';
if (d < 0 || d > 9 || Long.compareUnsigned(result, MAX_FIRST_PART) > 0) {
return ifInvalid;
}
long scaled = result * 10;
result = scaled + d;
if (Long.compareUnsigned(result, scaled) < 0) {
return ifInvalid;
}
}
return result;
}

/**
* Whether {@code s[start, start + len)} is a zero that {@link #parseUnsignedLongOrSentinel}
* accepts: 1 to 20 {@code '0'} characters. Callers that use {@code 0} as the invalid sentinel
* check this only when the parse returned {@code 0}.
*
* @param s CharSequence containing the decimal digits
* @param start the start index of the decimal value
* @param len the length of the decimal value
* @return true if the range parses to zero, false if it is zero-valued only as the sentinel
*/
public static boolean isUnsignedLongZero(CharSequence s, int start, int len) {
if (s == null || len <= 0 || len > 20 || start < 0 || start + len > s.length()) {
return false;
}
for (int i = start, end = start + len; i < end; i++) {
if (s.charAt(i) != '0') {
return false;
}
}
return true;
}

private static long parseUnsignedLongStrict(String s) throws NumberFormatException {
if (s == null) {
throw new NumberFormatException("s can't be null");
}
Expand Down
41 changes: 41 additions & 0 deletions dd-trace-api/src/test/java/datadog/trace/api/DDTraceIdTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

Expand Down Expand Up @@ -70,6 +71,46 @@ void failParsingIllegal64BitIdStringRepresentation(String stringId) {
assertThrows(NumberFormatException.class, () -> DD64bTraceId.from(stringId));
}

@TableTest({
"scenario | stringId | expectedId ",
"zero | '0' | DD64bTraceId.ZERO ",
"one | '1' | DD64bTraceId.ONE ",
"max | '18446744073709551615' | DD64bTraceId.MAX ",
"long max | '9223372036854775807' | DD64bTraceId.LONG_MAX",
"long max plus one | '9223372036854775808' | DD64bTraceId.LONG_MIN"
})
@ParameterizedTest(name = "fromOrNull parses valid 64-bit id String representation [{index}]")
void fromOrNullParsesValid64BitIdStringRepresentation(String stringId, DD64bTraceId expectedId) {
DD64bTraceId ddid = DD64bTraceId.fromOrNull(stringId);

assertEquals(expectedId, ddid);
assertEquals(stringId, ddid.toString());
}

@Test
void fromOrNullKeepsTheParsedStringForToString() {
String stringId = "1234567890";

assertSame(stringId, DD64bTraceId.fromOrNull(stringId).toString());
}

@ParameterizedTest(name = "fromOrNull returns null for illegal 64-bit id String [{index}]")
@NullSource
@ValueSource(
strings = {
"",
"-1",
"+1",
"18446744073709551616",
"18446744073709551625",
"184467440737095516150",
"18446744073709551a1",
"184467440737095511a"
})
void fromOrNullReturnsNullForIllegal64BitIdString(String stringId) {
assertNull(DD64bTraceId.fromOrNull(stringId));
}

@TableTest({
"scenario | hexId | expectedId ",
"zero | '0' | DD64bTraceId.ZERO ",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package datadog.trace.api.internal.util;

import static datadog.trace.api.internal.util.LongStringUtils.parseUnsignedLong;
import static datadog.trace.api.internal.util.LongStringUtils.parseUnsignedLongOrSentinel;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;

import datadog.trace.api.DDTraceApiTableTestConverters;
import java.util.concurrent.ThreadLocalRandom;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.tabletest.junit.TableTest;
import org.tabletest.junit.TypeConverterSources;

@TypeConverterSources(DDTraceApiTableTestConverters.class)
class LongStringUtilsTest {
private static final long SENTINEL = 42L;

@TableTest({
"scenario | input | expected ",
"zero | '0' | 0 ",
"one | '1' | 1 ",
"leading zeros | '00001' | 1 ",
"18 digits | '999999999999999999' | 999999999999999999 ",
"19 digits | '1000000000000000000' | 1000000000000000000",
"long max | '9223372036854775807' | Long.MAX_VALUE ",
"long max plus one | '9223372036854775808' | Long.MIN_VALUE ",
"unsigned max | '18446744073709551615' | -1 ",
"20 digits with leading zero | '00000000000000000001' | 1 "
})
@ParameterizedTest(name = "parse valid decimal [{index}]")
void parseValidDecimal(String input, long expected) {
assertEquals(expected, parseUnsignedLongOrSentinel(input, 0, input.length(), SENTINEL));
assertEquals(expected, parseUnsignedLong(input));
}

@ParameterizedTest(name = "non-throwing parse returns sentinel for invalid input [{index}]")
@ValueSource(
strings = {
"",
"-1",
"+1",
" 1",
"1 ",
"12a",
"0x1a",
"١", // ARABIC-INDIC DIGIT ONE
"18446744073709551616", // unsigned max + 1
"18446744073709551620", // first 19 digits already too large
"99999999999999999999",
"184467440737095516150", // 21 digits
"000000000000000000001" // 21 digits, even with leading zeros
})
void nonThrowingParseReturnsSentinelForInvalidInput(String input) {
assertEquals(SENTINEL, parseUnsignedLongOrSentinel(input, 0, input.length(), SENTINEL));
}

@Test
void nonThrowingParseReturnsSentinelForNull() {
assertEquals(SENTINEL, parseUnsignedLongOrSentinel(null, 0, 0, SENTINEL));
}

@TableTest({
"scenario | input | start | len | expected",
"middle | 'x123y' | 1 | 3 | 123 ",
"prefix | '123y' | 0 | 3 | 123 ",
"negative start | '123' | -1 | 2 | 42 ",
"past the end | '123' | 1 | 3 | 42 ",
"zero length | '123' | 0 | 0 | 42 ",
"includes bad char | 'x123y' | 1 | 4 | 42 "
})
@ParameterizedTest(name = "non-throwing parse of a range [{index}]")
void nonThrowingParseOfARange(String input, int start, int len, long expected) {
assertEquals(expected, parseUnsignedLongOrSentinel(input, start, len, SENTINEL));
}

@TableTest({
"scenario | input | start | len | expected",
"zero | '0' | 0 | 1 | true ",
"20 zeros | '00000000000000000000' | 0 | 20 | true ",
"zeros in a range | 'x00y' | 1 | 2 | true ",
"21 zeros | '000000000000000000000' | 0 | 21 | false ",
"non-zero digit | '01' | 0 | 2 | false ",
"not a digit | 'a' | 0 | 1 | false ",
"empty | '' | 0 | 0 | false ",
"past the end | '0' | 0 | 2 | false "
})
@ParameterizedTest(name = "isUnsignedLongZero [{index}]")
void isUnsignedLongZero(String input, int start, int len, boolean expected) {
assertEquals(expected, LongStringUtils.isUnsignedLongZero(input, start, len));
}

@Test
void isUnsignedLongZeroOfNull() {
assertFalse(LongStringUtils.isUnsignedLongZero(null, 0, 0));
}

@TableTest({
"scenario | input | expected",
"leading plus | '+1' | 1 ",
"non-ASCII digit | '١' | 1 "
})
@ParameterizedTest(name = "throwing parse keeps accepting what Long.parseLong accepts [{index}]")
void throwingParseKeepsAcceptingWhatLongParseLongAccepts(String input, long expected) {
assertEquals(expected, parseUnsignedLong(input));
}

@ParameterizedTest(name = "throwing parse rejects invalid input [{index}]")
@NullSource
@ValueSource(strings = {"", "-1", "12a", "18446744073709551616", "184467440737095516150"})
void throwingParseRejectsInvalidInput(String input) {
assertThrows(NumberFormatException.class, () -> parseUnsignedLong(input));
}

@Test
void matchesJdkForRandomUnsignedLongs() {
ThreadLocalRandom random = ThreadLocalRandom.current();
for (int i = 0; i < 10_000; i++) {
long value = random.nextLong();
String s = Long.toUnsignedString(value);
assertEquals(value, parseUnsignedLongOrSentinel(s, 0, s.length(), SENTINEL), s);
}
}

@Test
void matchesJdkForRandomDigitStrings() {
// Lengths around the 18 / 19 / 20 digit boundaries, where overflow handling changes. Longer
// input is always rejected, even with leading zeros, matching parseUnsignedLong(String)
ThreadLocalRandom random = ThreadLocalRandom.current();
char[] digits = new char[20];
for (int i = 0; i < 10_000; i++) {
int len = 17 + random.nextInt(4);
for (int j = 0; j < len; j++) {
digits[j] = (char) ('0' + random.nextInt(10));
}
String s = new String(digits, 0, len);
boolean valid;
long expected = 0;
try {
expected = Long.parseUnsignedLong(s);
valid = true;
} catch (NumberFormatException e) {
valid = false;
}
if (valid) {
assertEquals(expected, parseUnsignedLongOrSentinel(s, 0, len, SENTINEL), s);
} else {
assertEquals(SENTINEL, parseUnsignedLongOrSentinel(s, 0, len, SENTINEL), s);
}
}
}
}
Loading
Loading