diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParser.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParser.java index 617b8d707ce..cbebc626e16 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParser.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParser.java @@ -1,6 +1,7 @@ package datadog.trace.bootstrap.instrumentation.jdbc; import static datadog.trace.bootstrap.instrumentation.jdbc.DBInfo.DEFAULT; +import static datadog.trace.util.IntStringUtils.parseNonNegativeInt; import static java.lang.Math.max; import datadog.trace.api.Pair; @@ -140,7 +141,7 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { final int portLoc = serverName.indexOf(':'); if (portLoc > 1) { - port = Integer.parseInt(serverName.substring(portLoc + 1)); + port = parsePort(serverName, portLoc + 1, serverName.length()); serverName = serverName.substring(0, portLoc); } @@ -221,10 +222,7 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { if (portLoc > 0) { hostEndLoc = portLoc; - try { - builder.port(Integer.parseInt(jdbcUrl.substring(portLoc + 1, dbLoc))); - } catch (final NumberFormatException ignored) { - } + setPort(builder, parsePort(jdbcUrl, portLoc + 1, dbLoc)); } else { hostEndLoc = dbLoc; } @@ -268,10 +266,7 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { if (portLoc > 0) { hostEndLoc = portLoc; final int portEndLoc = clusterSepLoc > 0 ? clusterSepLoc : dbLoc; - try { - builder.port(Integer.parseInt(jdbcUrl.substring(portLoc + 1, portEndLoc))); - } catch (final NumberFormatException ignored) { - } + setPort(builder, parsePort(jdbcUrl, portLoc + 1, portEndLoc)); } else { hostEndLoc = clusterSepLoc > 0 ? clusterSepLoc : dbLoc; } @@ -303,7 +298,7 @@ DBInfo.Builder doParse(String jdbcUrl, final DBInfo.Builder builder) { final Matcher portMatcher = PORT_REGEX.matcher(jdbcUrl); if (portMatcher.find()) { - builder.port(Integer.parseInt(portMatcher.group(1))); + setPort(builder, parsePort(portMatcher.group(1))); } final Matcher userMatcher = USER_REGEX.matcher(jdbcUrl); @@ -390,31 +385,37 @@ DBInfo.Builder doParse(String jdbcUrl, final DBInfo.Builder builder) { ORACLE_CONNECT_INFO() { @Override - DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { + DBInfo.Builder doParse(final String connectInfo, final DBInfo.Builder builder) { final String host; final Integer port; final String instance; - final int hostEnd = jdbcUrl.indexOf(':'); - final int instanceLoc = jdbcUrl.indexOf('/'); + // EZConnect Plus parameters (?key=value) are not part of the address + final int queryLoc = connectInfo.indexOf('?'); + final String jdbcUrl = queryLoc >= 0 ? connectInfo.substring(0, queryLoc) : connectInfo; + + // skip a bracketed IPv6 literal so its colons are not taken as the port separator + final int hostSearchStart = jdbcUrl.startsWith("[") ? Math.max(jdbcUrl.indexOf(']'), 0) : 0; + final int instanceLoc = jdbcUrl.indexOf('/', hostSearchStart); + final int colonLoc = jdbcUrl.indexOf(':', hostSearchStart); + // a ':' after the service name separates the server type, not the port + final int hostEnd = instanceLoc >= 0 && colonLoc > instanceLoc ? -1 : colonLoc; if (hostEnd > 0) { host = jdbcUrl.substring(0, hostEnd); final int afterHostEnd = jdbcUrl.indexOf(':', hostEnd + 1); - if (afterHostEnd > 0) { - port = Integer.parseInt(jdbcUrl.substring(hostEnd + 1, afterHostEnd)); + if (afterHostEnd > 0 && (instanceLoc < 0 || afterHostEnd < instanceLoc)) { + // host:port:sid + port = parsePort(jdbcUrl, hostEnd + 1, afterHostEnd); instance = jdbcUrl.substring(afterHostEnd + 1); } else { if (instanceLoc > 0) { - instance = jdbcUrl.substring(instanceLoc + 1); - port = Integer.parseInt(jdbcUrl.substring(hostEnd + 1, instanceLoc)); + // host:port/service[:server_type][/instance_name] + instance = serviceName(jdbcUrl.substring(instanceLoc + 1)); + port = parsePort(jdbcUrl, hostEnd + 1, instanceLoc); } else { final String portOrInstance = jdbcUrl.substring(hostEnd + 1); - Integer parsedPort = null; - try { - parsedPort = Integer.parseInt(portOrInstance); - } catch (final NumberFormatException ignored) { - } + final Integer parsedPort = parsePort(portOrInstance); if (parsedPort == null) { port = null; instance = portOrInstance; @@ -428,7 +429,7 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { if (instanceLoc > 0) { host = jdbcUrl.substring(0, instanceLoc); port = null; - instance = jdbcUrl.substring(instanceLoc + 1); + instance = serviceName(jdbcUrl.substring(instanceLoc + 1)); } else { if (jdbcUrl.isEmpty()) { return builder; @@ -447,6 +448,20 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { } return builder.instance(instance); } + + /** Drops the optional {@code :server_type} and {@code /instance_name} EZConnect suffixes. */ + private String serviceName(final String service) { + int end = service.length(); + final int serverTypeLoc = service.indexOf(':'); + if (serverTypeLoc >= 0) { + end = serverTypeLoc; + } + final int instanceNameLoc = service.indexOf('/'); + if (instanceNameLoc >= 0 && instanceNameLoc < end) { + end = instanceNameLoc; + } + return service.substring(0, end); + } }, ORACLE_AT() { @@ -469,10 +484,12 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { } final int hostStart; + final int protocolEnd = connectInfo.indexOf("://"); if (connectInfo.startsWith("//")) { hostStart = "//".length(); - } else if (connectInfo.startsWith("ldap://")) { - hostStart = "ldap://".length(); + } else if (protocolEnd > 0 && protocolEnd == connectInfo.indexOf(':')) { + // protocol prefix, e.g. ldap://, tcp://, tcps:// + hostStart = protocolEnd + "://".length(); } else { hostStart = 0; } @@ -511,7 +528,7 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { final Matcher portMatcher = PORT_REGEX.matcher(urlPart2); if (portMatcher.find()) { - builder.port(Integer.parseInt(portMatcher.group(1))); + setPort(builder, parsePort(portMatcher.group(1))); } final Matcher instanceMatcher = INSTANCE_REGEX.matcher(urlPart2); @@ -697,7 +714,7 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { final int portLoc = url.indexOf(':'); if (portLoc > 0) { host = url.substring(0, portLoc); - builder.port(Integer.parseInt(url.substring(portLoc + 1))); + setPort(builder, parsePort(url, portLoc + 1, url.length())); } else { host = url; } @@ -726,6 +743,10 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { final int protoLoc = jdbcUrl.indexOf("://"); final int typeEndLoc = dbInfo.getType().length(); + if (protoLoc <= typeEndLoc) { + // no "jtds:://" prefix to parse + return builder; + } final String subtype = jdbcUrl.substring(typeEndLoc + 1, protoLoc); builder.subtype(subtype); @@ -740,40 +761,34 @@ DBInfo.Builder doParse(final String jdbcUrl, final DBInfo.Builder builder) { } } + // [:][/][;=[;...]] final String details = jdbcUrl.substring(protoLoc + "://".length()); - final int hostEndLoc; - final int portLoc = details.indexOf(':', typeEndLoc + 1); - final int dbLoc = details.indexOf('/', typeEndLoc); - final int paramLoc = details.indexOf(';', dbLoc); - - if (paramLoc > 0) { + final int paramLoc = details.indexOf(';'); + final String address; + if (paramLoc >= 0) { populateStandardProperties(builder, splitQuery(details.substring(paramLoc + 1), ';')); - if (dbLoc > 0) { - builder.db(details.substring(dbLoc + 1, paramLoc)); - } + address = details.substring(0, paramLoc); } else { - if (dbLoc > 0) { - builder.db(details.substring(dbLoc + 1)); - } + address = details; } - if (portLoc > 0) { - hostEndLoc = portLoc; - final int portEndLoc = dbLoc > 0 ? dbLoc : (paramLoc > 0 ? paramLoc : details.length()); - try { - builder.port(Integer.parseInt(details.substring(portLoc + 1, portEndLoc))); - } catch (final NumberFormatException ignored) { - } - } else if (dbLoc > 0) { - hostEndLoc = dbLoc; - } else if (paramLoc > 0) { - hostEndLoc = paramLoc; + final int dbLoc = address.indexOf('/'); + final String hostAndPort; + if (dbLoc >= 0) { + builder.db(address.substring(dbLoc + 1)); + hostAndPort = address.substring(0, dbLoc); } else { - hostEndLoc = details.length(); + hostAndPort = address; } - builder.host(details.substring(0, hostEndLoc)); + final int portLoc = hostAndPort.indexOf(':'); + if (portLoc >= 0) { + setPort(builder, parsePort(hostAndPort, portLoc + 1, hostAndPort.length())); + builder.host(hostAndPort.substring(0, portLoc)); + } else { + builder.host(hostAndPort); + } return builder; } @@ -900,6 +915,29 @@ public static DBInfo parse(String connectionUrl, final Properties props) { // Source: https://stackoverflow.com/a/13592567 @SuppressForbidden + /** + * @return the port, or {@code null} if {@code s[start, end)} is not a valid port number + */ + private static Integer parsePort(final CharSequence s, final int start, final int end) { + final int port = parseNonNegativeInt(s, start, end - start); + return port >= 0 ? port : null; + } + + /** + * @return the port, or {@code null} if {@code s} is null or not a valid port number + */ + private static Integer parsePort(final String s) { + final int port = parseNonNegativeInt(s); + return port >= 0 ? port : null; + } + + /** Sets the port only when one was parsed, keeping any default already on the builder. */ + private static void setPort(final DBInfo.Builder builder, final Integer port) { + if (port != null) { + builder.port(port); + } + } + private static Map splitQuery(final String query, final char separator) { if (query == null || query.isEmpty()) { return Collections.emptyMap(); @@ -956,12 +994,7 @@ private static void populateStandardProperties( } if (props.containsKey("portnumber")) { - final String portNumber = (String) props.get("portnumber"); - try { - builder.port(Integer.parseInt(portNumber)); - } catch (final NumberFormatException e) { - ExceptionLogger.LOGGER.debug("Error parsing portnumber property: {}", portNumber, e); - } + setPort(builder, parsePort((String) props.get("portnumber"))); } if (props.containsKey("servicename")) { // this property is used to specify the db to use for Sybase connection strings @@ -969,12 +1002,7 @@ private static void populateStandardProperties( } if (props.containsKey("portNumber")) { - final String portNumber = (String) props.get("portNumber"); - try { - builder.port(Integer.parseInt(portNumber)); - } catch (final NumberFormatException e) { - ExceptionLogger.LOGGER.debug("Error parsing portNumber property: {}", portNumber, e); - } + setPort(builder, parsePort((String) props.get("portNumber"))); } } } diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParserJtdsTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParserJtdsTest.java new file mode 100644 index 00000000000..2c652abf324 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParserJtdsTest.java @@ -0,0 +1,36 @@ +package datadog.trace.bootstrap.instrumentation.jdbc; + +import static datadog.trace.bootstrap.instrumentation.jdbc.JDBCConnectionUrlParser.extractDBInfo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.tabletest.junit.TableTest; + +/** + * Tests for jTDS URLs ({@code jdbc:jtds:://[:][/][;]}). + * + *

The port and database separators used to be searched for across the whole remainder of the + * URL, starting at an offset unrelated to it, so a ':' or '/' in a property value or a short host + * name produced a {@code StringIndexOutOfBoundsException} or a wrong host. + */ +class JDBCConnectionUrlParserJtdsTest { + + @TableTest({ + "scenario | url | subtype | host | port | db ", + "host port and db | jdbc:jtds:sqlserver://dbhost:1500/mydb | sqlserver | dbhost | 1500 | mydb", + "colon in property after db | jdbc:jtds:sqlserver://dbhost:1500/mydb;appname=a:b | sqlserver | dbhost | 1500 | mydb", + "colon in property, no port | jdbc:jtds:sqlserver://dbhost/mydb;password=p:w | sqlserver | dbhost | 1433 | mydb", + "colon in property, no db | jdbc:jtds:sqlserver://dbhost;password=p:w | sqlserver | dbhost | 1433 | ", + "slash in property, no db | jdbc:jtds:sqlserver://dbhost;password=p/w | sqlserver | dbhost | 1433 | ", + "short host with port | jdbc:jtds:sqlserver://db:1500/mydb | sqlserver | db | 1500 | mydb", + "sybase with port | jdbc:jtds:sybase://dbhost:7200/mydb | sybase | dbhost | 7200 | mydb", + "missing protocol separator | jdbc:jtds:sqlserver:dbhost | | | | " + }) + void parsesJtdsUrls(String url, String subtype, String host, Integer port, String db) { + DBInfo info = extractDBInfo(url, null); + assertEquals("jtds", info.getType()); + assertEquals(subtype, info.getSubtype()); + assertEquals(host, info.getHost()); + assertEquals(port, info.getPort()); + assertEquals(db, info.getDb()); + } +} diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParserOracleTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParserOracleTest.java new file mode 100644 index 00000000000..2f55579d3f6 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/jdbc/JDBCConnectionUrlParserOracleTest.java @@ -0,0 +1,39 @@ +package datadog.trace.bootstrap.instrumentation.jdbc; + +import static datadog.trace.bootstrap.instrumentation.jdbc.JDBCConnectionUrlParser.extractDBInfo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.tabletest.junit.TableTest; + +/** + * Tests for Oracle {@code @} connect strings that previously threw {@code NumberFormatException}. + * + *

A second ':' after the port (EZConnect {@code :server_type}, IPv6 literals, {@code ?params}) + * or a protocol prefix other than {@code ldap://} was taken as the {@code host:port:sid} form, so a + * non-numeric segment was parsed as the port and the whole URL fell back to defaults. + */ +class JDBCConnectionUrlParserOracleTest { + + @TableTest({ + "scenario | url | host | port | instance", + "SID form | jdbc:oracle:thin:@orcl.host:55:orclsn | orcl.host | 55 | orclsn ", + "service form | jdbc:oracle:thin:@//orcl.host:55/orclsn | orcl.host | 55 | orclsn ", + "service with server type | jdbc:oracle:thin:@//orcl.host:55/orclsn:dedicated | orcl.host | 55 | orclsn ", + "service with type and instance | jdbc:oracle:thin:@//orcl.host:55/orclsn:pooled/inst1 | orcl.host | 55 | orclsn ", + "service with instance name | jdbc:oracle:thin:@//orcl.host:55/orclsn/inst1 | orcl.host | 55 | orclsn ", + "service without port | jdbc:oracle:thin:@//orcl.host/orclsn:dedicated | orcl.host | 1521 | orclsn ", + "tcps protocol | jdbc:oracle:thin:@tcps://orcl.host:2484/orclsn | orcl.host | 2484 | orclsn ", + "tcp protocol | jdbc:oracle:thin:@tcp://orcl.host:55/orclsn | orcl.host | 55 | orclsn ", + "EZConnect Plus params | jdbc:oracle:thin:@tcps://orcl.host:2484/orclsn?wallet_location=c:/w | orcl.host | 2484 | orclsn ", + "IPv6 literal | jdbc:oracle:thin:@//[::1]:55/orclsn | '[::1]' | 55 | orclsn ", + "non-numeric port | jdbc:oracle:thin:@orcl.host:abc:orclsn | orcl.host | 1521 | orclsn " + }) + void parsesOracleConnectStrings(String url, String host, Integer port, String instance) { + DBInfo info = extractDBInfo(url, null); + assertEquals("oracle", info.getType()); + assertEquals("thin", info.getSubtype()); + assertEquals(host, info.getHost()); + assertEquals(port, info.getPort()); + assertEquals(instance, info.getInstance()); + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java index f3fe6982d17..f74275e4c26 100644 --- a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java +++ b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java @@ -1,6 +1,7 @@ package datadog.trace.lambda; import static datadog.trace.lambda.LambdaEventParser.findHeader; +import static datadog.trace.util.IntStringUtils.parseNonNegativeInt; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapterBase; import java.util.Locale; @@ -48,14 +49,9 @@ class LambdaURIDataAdapter extends URIDataAdapterBase { String proto = forwardedProto == null ? null : forwardedProto.toLowerCase(Locale.ROOT); this.scheme = "http".equals(proto) || "https".equals(proto) ? proto : "https"; + // X-Forwarded-Port is client-influenceable, so parse it without throwing String forwardedPort = findHeader(headers, "x-forwarded-port"); - int parsedPort = -1; - if (forwardedPort != null && !forwardedPort.isEmpty()) { - try { - parsedPort = Integer.parseInt(forwardedPort.trim()); - } catch (NumberFormatException ignored) { - } - } + int parsedPort = forwardedPort == null ? -1 : parseNonNegativeInt(forwardedPort.trim()); // URIUtils.buildURL only suppresses the port for 80 on http and 443 on https, so the default // has to follow the scheme or an http URL would leak ":443". this.port = parsedPort > 0 ? parsedPort : ("http".equals(this.scheme) ? 80 : 443); diff --git a/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java b/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java index f51ec9e4e78..5d17e0dfedc 100644 --- a/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java @@ -77,6 +77,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; +import org.tabletest.junit.TableTest; class LambdaAppSecHandlerTest extends DDCoreJavaSpecification { @@ -2520,6 +2521,29 @@ void ignoresEmptyForwardedPort() { assertEquals("http://lb.example.com/alb", tagsOf(context).get(Tags.HTTP_URL)); } + @TableTest({ + "scenario | forwardedPort | url ", + "padded port | ' 8080 ' | http://lb.example.com:8080/alb", + "comma-joined values | '8080, 9090' | http://lb.example.com/alb ", + "plus sign | +8080 | http://lb.example.com/alb ", + "negative | -8080 | http://lb.example.com/alb ", + "overflow | 99999999999 | http://lb.example.com/alb ", + "not a number | http | http://lb.example.com/alb " + }) + void fallsBackToTheSchemeDefaultForAnUnusableForwardedPort(String forwardedPort, String url) { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com\", \"x-forwarded-proto\": \"http\"," + + " \"x-forwarded-port\": \"" + + forwardedPort + + "\"}, \"requestContext\": {\"elb\": {\"targetGroupArn\": \"arn\"}}}")); + + assertEquals(url, tagsOf(context).get(Tags.HTTP_URL)); + } + @Test void normalisesForwardedSchemeCasing() { setupMockCallbacks(new Callbacks()); diff --git a/internal-api/src/main/java/datadog/trace/util/IntStringUtils.java b/internal-api/src/main/java/datadog/trace/util/IntStringUtils.java new file mode 100644 index 00000000000..9d18f830d20 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/IntStringUtils.java @@ -0,0 +1,41 @@ +package datadog.trace.util; + +import javax.annotation.Nullable; + +/** Exception-free {@code int} parsing, the {@code int} counterpart of {@code LongStringUtils}. */ +public final class IntStringUtils { + + private IntStringUtils() {} + + /** + * Parses a non-negative decimal {@code int} from the whole sequence without throwing. + * + * @return the parsed value, or {@code -1} if {@code s} is null or empty, contains anything other + * than ASCII digits (including a sign), or overflows {@code int} + */ + public static int parseNonNegativeInt(@Nullable CharSequence s) { + return s == null ? -1 : parseNonNegativeInt(s, 0, s.length()); + } + + /** + * Parses a non-negative decimal {@code int} from the {@code len} characters of {@code s} starting + * at {@code start}, without throwing or allocating. + * + * @return the parsed value, or {@code -1} if the range is empty or out of bounds, contains + * anything other than ASCII digits (including a sign), or overflows {@code int} + */ + public static int parseNonNegativeInt(@Nullable CharSequence s, int start, int len) { + if (s == null || start < 0 || len <= 0 || start > s.length() - len) { + return -1; + } + int result = 0; + for (int i = start, end = start + len; i < end; i++) { + int digit = s.charAt(i) - '0'; + if (digit < 0 || digit > 9 || result > (Integer.MAX_VALUE - digit) / 10) { + return -1; + } + result = result * 10 + digit; + } + return result; + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/IntStringUtilsTest.java b/internal-api/src/test/java/datadog/trace/util/IntStringUtilsTest.java new file mode 100644 index 00000000000..50e12c5e0b2 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/IntStringUtilsTest.java @@ -0,0 +1,51 @@ +package datadog.trace.util; + +import static datadog.trace.util.IntStringUtils.parseNonNegativeInt; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class IntStringUtilsTest { + + @TableTest({ + "scenario | input | expected ", + "zero | 0 | 0 ", + "port | 1521 | 1521 ", + "leading zeros | 007 | 7 ", + "int max | 2147483647 | 2147483647", + "int max plus one | 2147483648 | -1 ", + "far overflow | 99999999999 | -1 ", + "empty | '' | -1 ", + "minus sign | -1 | -1 ", + "plus sign | +1 | -1 ", + "letters | abc | -1 ", + "trailing garbage | 12a | -1 ", + "whitespace | ' 1' | -1 ", + "non-ASCII digit | '\u0661' | -1 " + }) + void parseNonNegativeIntWholeSequence(String input, int expected) { + assertEquals(expected, parseNonNegativeInt(input)); + } + + @TableTest({ + "scenario | input | start | len | expected", + "middle of string | host:1521/db | 5 | 4 | 1521 ", + "to end | host:1521 | 5 | 4 | 1521 ", + "empty range | host:/db | 5 | 0 | -1 ", + "negative length | host:1521 | 6 | -1 | -1 ", + "negative start | 1521 | -1 | 4 | -1 ", + "past end | 1521 | 1 | 4 | -1 ", + "start past length | 1521 | 5 | 1 | -1 ", + "range spans garbage | host:1521/db | 4 | 5 | -1 " + }) + void parseNonNegativeIntRange(String input, int start, int len, int expected) { + assertEquals(expected, parseNonNegativeInt(input, start, len)); + } + + @Test + void parseNonNegativeIntNull() { + assertEquals(-1, parseNonNegativeInt(null)); + assertEquals(-1, parseNonNegativeInt(null, 0, 0)); + } +}