diff --git a/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuard.java b/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuard.java index 7628b79..788a62e 100644 --- a/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuard.java +++ b/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuard.java @@ -10,7 +10,7 @@ final class LibsqlConformanceGuard { static final String EXPECTED_DRIVER_NAME = "libSQL JDBC Driver"; - static final String EXPECTED_DATABASE_PRODUCT_NAME = "SQLite (libSQL)"; + static final String EXPECTED_DATABASE_PRODUCT_NAME = "SQLite"; static final String LIBSQL_SOURCE_ID_SUFFIX = "alt1"; private static final String PROBE_URL = "jdbc:sqlite::memory:"; diff --git a/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuardTest.java b/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuardTest.java index bac55e3..b298f27 100644 --- a/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuardTest.java +++ b/libsql-conformance/src/test/java/io/github/magnusp/libsql/conformance/LibsqlConformanceGuardTest.java @@ -18,7 +18,7 @@ void acceptsALibsqlConnection() throws Exception { Connection connection = connection( "libSQL JDBC Driver", - "SQLite (libSQL)", + "SQLite", "2024-10-21 16:30:22 03a9703e27c44437c39363d0baf82db4ebc94538a0f284" + "11c85dda156f82alt1"); @@ -43,7 +43,7 @@ void rejectsANonLibsqlServerAndNamesTheResolvedSourceId() { Connection connection = connection( "libSQL JDBC Driver", - "SQLite (libSQL)", + "SQLite", "2024-10-21 16:30:22 03a9703e27c44437c39363d0baf82db4ebc94538a0f284" + "11c85dda156f82"); diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/LibsqlJdbcIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/LibsqlJdbcIT.java index 2c91127..ebf0ad4 100644 --- a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/LibsqlJdbcIT.java +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/LibsqlJdbcIT.java @@ -113,7 +113,7 @@ void testDatabaseMetaData() throws SQLException { } DatabaseMetaData meta = conn.getMetaData(); - assertThat(meta.getDatabaseProductName()).contains("libSQL"); + assertThat(meta.getDatabaseProductName()).isEqualTo("SQLite"); try (ResultSet rs = meta.getTables(null, null, "meta_table", null)) { assertThat(rs.next()).isTrue(); diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/WebSocketTransportIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/WebSocketTransportIT.java index 56340ac..68d260a 100644 --- a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/WebSocketTransportIT.java +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/WebSocketTransportIT.java @@ -107,8 +107,8 @@ void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable(String schem void metadataAndBatchWorkOverWebSocket(String scheme) throws SQLException { try (Connection connection = connect(scheme)) { DatabaseMetaData metaData = connection.getMetaData(); - assertThat(metaData.getDatabaseProductName()).isEqualTo("SQLite (libSQL)"); - assertThat(metaData.getURL()).isEqualTo(url(scheme)); + assertThat(metaData.getDatabaseProductName()).isEqualTo("SQLite"); + assertThat(metaData.getURL()).isEqualTo("jdbc:libsql:" + url(scheme)); try (Statement statement = connection.createStatement()) { statement.execute( diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java index def5980..b7599ec 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java @@ -58,6 +58,9 @@ public class LibsqlConnection implements Connection { private final LibsqlClientConfig config; private final LibsqlClient client; + /** The JDBC URL the caller connected with, kept for {@code DatabaseMetaData.getURL()}. */ + private final String jdbcUrl; + /** Guards every field below and every transport round trip. See the class javadoc. */ private final Object stateLock = new Object(); @@ -102,14 +105,24 @@ public static TransactionMode from(String mode) { // Interactive stream state lives in the client; see LibsqlClient.hasOpenStream(). public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client) { + this(config, client, null); + } + + public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client, String jdbcUrl) { this.config = config; this.client = client; + this.jdbcUrl = jdbcUrl; } public LibsqlClientConfig getConfig() { return config; } + /** The JDBC URL used to open this connection, or the transport endpoint for test doubles. */ + String getJdbcUrl() { + return jdbcUrl != null ? jdbcUrl : (config != null ? config.url() : null); + } + public void checkClosed() throws SQLException { synchronized (stateLock) { if (closed) { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java index 51ebce6..5cf7474 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java @@ -189,7 +189,7 @@ private String resolveDatabaseProductVersion() throws SQLException { public boolean allTablesAreSelectable() throws SQLException { return true; } @Override public String getURL() throws SQLException { - return connection.getConfig() != null ? connection.getConfig().url() : null; + return connection.getJdbcUrl(); } @Override public String getUserName() throws SQLException { return null; } @@ -204,7 +204,7 @@ public String getURL() throws SQLException { @Override public boolean nullsAreSortedAtEnd() throws SQLException { return false; } @Override - public String getDatabaseProductName() throws SQLException { return "SQLite (libSQL)"; } + public String getDatabaseProductName() throws SQLException { return "SQLite"; } @Override public String getDatabaseProductVersion() throws SQLException { return resolveDatabaseProductVersion(); } @Override diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java index d62bd80..4fab91e 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java @@ -45,7 +45,7 @@ public Connection connect(String url, Properties info) throws SQLException { } else { client = new LibsqlHttpClient(config); } - return new LibsqlConnection(config, client); + return new LibsqlConnection(config, client, LibsqlUrlParser.metadataUrl(url)); } private static boolean isWebSocketUrl(String url) { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java index e0ebbbe..d0231e1 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java @@ -5,16 +5,24 @@ import java.net.URI; import java.sql.SQLException; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; +import java.util.Set; +import java.util.regex.Pattern; public class LibsqlUrlParser { public static final String PREFIX = "jdbc:libsql:"; public static final String SQLITE_PREFIX = "jdbc:sqlite:"; + private static final Set CREDENTIAL_QUERY_PARAMS = + Set.of("authToken", "jwt", "password", "token"); + private static final Pattern USER_INFO = Pattern.compile("^(jdbc:[a-zA-Z]+:[a-zA-Z]+://)[^/@]*@"); + public static boolean acceptsUrl(String url) { if (url == null) return false; if (url.startsWith(PREFIX)) return true; @@ -41,6 +49,38 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx buildEndpoint(uri, scheme), authToken, connectTimeout, busyTimeout, encoding); } + /** + * Returns the URL for {@code DatabaseMetaData.getURL()} without credentials. The driver + * accepts an auth token in the query string, so returning the raw URL would leak it to any + * consumer that inspects metadata. + */ + static String metadataUrl(String url) { + if (url == null) { + return null; + } + String redacted = USER_INFO.matcher(url).replaceFirst("$1"); + int queryStart = redacted.indexOf('?'); + if (queryStart < 0) { + return redacted; + } + String query = redacted.substring(queryStart + 1); + String[] pairs = query.split("&"); + List kept = new ArrayList<>(pairs.length); + for (String pair : pairs) { + int equals = pair.indexOf('='); + String key = equals >= 0 ? pair.substring(0, equals) : pair; + if (!CREDENTIAL_QUERY_PARAMS.contains(key)) { + kept.add(pair); + } + } + if (kept.size() == pairs.length) { + return redacted; + } + return kept.isEmpty() + ? redacted.substring(0, queryStart) + : redacted.substring(0, queryStart) + "?" + String.join("&", kept); + } + private static URI normalizeUri(String url) throws SQLException { String rawUriStr = url.startsWith(PREFIX) ? url.substring(PREFIX.length()) diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java index 33a3bef..93f1b90 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java @@ -54,6 +54,17 @@ void getMetaDataIsCachedPerConnection() throws SQLException { assertThat(other.getMetaData()).isNotSameAs(connection.getMetaData()); } + @Test + void metadataReportsTheJdbcUrlUsedToConnect() throws SQLException { + LibsqlConnection connection = new LibsqlConnection( + CONFIG, + new ScriptedClient((request, index) -> successResponse(request)), + "jdbc:sqlite:http://localhost:1"); + + assertThat(connection.getMetaData().getURL()) + .isEqualTo("jdbc:sqlite:http://localhost:1"); + } + @Test void disablingAutoCommitDefersTheBeginUntilTheNextStatement() throws SQLException { ScriptedClient client = new ScriptedClient((request, index) -> successResponse(request)); diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java index da68640..04bd891 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java @@ -134,4 +134,27 @@ void testAcceptsWebSocketUrlForms() { assertThat(LibsqlUrlParser.acceptsUrl("jdbc:sqlite:wss://localhost:8080")).isTrue(); assertThat(LibsqlUrlParser.acceptsUrl("jdbc:other:ws://localhost:8080")).isFalse(); } + + @Test + void testMetadataUrlRedactsAuthenticationQueryParameters() { + assertThat(LibsqlUrlParser.metadataUrl( + "jdbc:libsql:http://localhost:8080/dev/tenant" + + "?authToken=secretToken&connectTimeout=5000")) + .isEqualTo("jdbc:libsql:http://localhost:8080/dev/tenant?connectTimeout=5000"); + assertThat(LibsqlUrlParser.metadataUrl( + "jdbc:libsql:http://localhost:8080?jwt=secretToken&password=secret")) + .isEqualTo("jdbc:libsql:http://localhost:8080"); + } + + @Test + void testMetadataUrlRedactsUserInfo() { + assertThat(LibsqlUrlParser.metadataUrl("jdbc:libsql:http://user:pass@localhost:8080/dev")) + .isEqualTo("jdbc:libsql:http://localhost:8080/dev"); + } + + @Test + void testMetadataUrlLeavesANonCredentialUrlUnchanged() { + String url = "jdbc:sqlite:http://localhost:8080/dev/tenant?connectTimeout=5000"; + assertThat(LibsqlUrlParser.metadataUrl(url)).isEqualTo(url); + } } diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java index 5ed536c..305947a 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java @@ -277,11 +277,19 @@ void autoCommitInsertDoesNotExposeGeneratedIdentity() { } @Test - void jdbcUrlIsDetectedAsDefaultDialect() { + void libsqlUrlIsNotDetectedByJooq() { assertThat(JDBCUtils.dialect(connection)) - .as("DatabaseMetaData reports 'SQLite (libSQL)', which jOOQ does not map to" - + " SQLDialect.SQLITE. Pass SQLDialect.SQLITE explicitly until the" - + " product name is exactly 'SQLite'.") + .as("jOOQ 3.21 derives the dialect from DatabaseMetaData.getURL(), and it" + + " has no jdbc:libsql: prefix. Pass SQLDialect.SQLITE explicitly.") .isEqualTo(SQLDialect.DEFAULT); } + + @Test + void sqliteAliasUrlIsDetectedAsSqliteDialect() throws Exception { + try (Connection alias = DriverManager.getConnection("jdbc:sqlite:" + httpUrl())) { + assertThat(JDBCUtils.dialect(alias)) + .as("getURL() preserves the jdbc:sqlite: prefix, which jOOQ recognizes") + .isEqualTo(SQLDialect.SQLITE); + } + } } diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java index 958dbad..77a28b0 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java @@ -52,11 +52,15 @@ abstract class JooqSqldSupport { .withStartupTimeout(Duration.ofSeconds(60))); static String jdbcUrl() { - return "jdbc:libsql:http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT); + return "jdbc:libsql:" + httpUrl(); + } + + static String httpUrl() { + return "http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT); } static void createSchema() { - String baseUrl = "http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT); + String baseUrl = httpUrl(); String requests = SCHEMA.stream() .map(sql -> "{\"type\":\"execute\",\"stmt\":{\"sql\":\"" + escape(sql) + "\"}}") .collect(Collectors.joining(",", "", ",{\"type\":\"close\"}")); diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java index ea47a85..f50d60b 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java @@ -6,7 +6,6 @@ import liquibase.database.Database; import liquibase.database.DatabaseFactory; import liquibase.database.core.SQLiteDatabase; -import liquibase.database.core.UnsupportedDatabase; import liquibase.database.jvm.JdbcConnection; import liquibase.resource.ClassLoaderResourceAccessor; import org.junit.jupiter.api.Test; @@ -30,12 +29,9 @@ * snapshot paths against a real sqld container. * *

Liquibase selects its SQLite implementation with - * {@code "SQLite".equalsIgnoreCase(getDatabaseProductName())}. This driver - * reports {@code "SQLite (libSQL)"}, so the tests that need Liquibase's SQLite - * SQL build a {@link SQLiteDatabase} directly. The gap is pinned in - * {@link #databaseFactoryFallsBackToUnsupportedDatabase()}; when the product - * name becomes exactly {@code SQLite}, switch {@link #liquibase} back to - * {@link DatabaseFactory#findCorrectDatabaseImplementation} and delete the pin. + * {@code "SQLite".equalsIgnoreCase(getDatabaseProductName())}. The driver + * reports exactly {@code SQLite}, so {@link DatabaseFactory} resolves + * {@link SQLiteDatabase} through its normal implementation selection. */ class LiquibaseIT extends LiquibaseSqldSupport { @@ -148,30 +144,20 @@ void changeLogSyncMarksChangesAsRunWithoutExecutingThem() throws Exception { } /** - * Pins the driver gap that stops Liquibase from selecting its SQLite - * dialect when an application lets Liquibase inspect the connection. - * - *

Liquibase's {@code SQLiteDatabase.isCorrectDatabaseImplementation} + * Liquibase's {@code SQLiteDatabase.isCorrectDatabaseImplementation} * compares the product name with {@code equalsIgnoreCase("SQLite")}. The - * driver reports {@code "SQLite (libSQL)"}, so Liquibase silently falls - * back to {@link UnsupportedDatabase} and emits generic DDL such as - * {@code INT GENERATED BY DEFAULT AS IDENTITY}, which sqld rejects. - * Returning exactly {@code "SQLite"} and keeping the libSQL identity in - * {@code getDriverName()} would close the gap. - * - *

This assertion describes current behavior, not desired behavior. If - * it starts failing because the product name changed, remove this test and - * the explicit {@link SQLiteDatabase} workaround in {@link #liquibase}. + * driver reports exactly that, so the database factory selects the SQLite + * dialect and no longer emits generic DDL that sqld rejects. */ @Test - void databaseFactoryFallsBackToUnsupportedDatabase() throws Exception { + void databaseFactoryResolvesTheSqliteImplementation() throws Exception { try (Connection conn = DriverManager.getConnection(jdbcUrl())) { - assertThat(conn.getMetaData().getDatabaseProductName()).isEqualTo("SQLite (libSQL)"); + assertThat(conn.getMetaData().getDatabaseProductName()).isEqualTo("SQLite"); Database database = DatabaseFactory.getInstance() .findCorrectDatabaseImplementation(new JdbcConnection(conn)); - assertThat(database).isInstanceOf(UnsupportedDatabase.class); + assertThat(database).isInstanceOf(SQLiteDatabase.class); } } @@ -195,8 +181,8 @@ void databaseFactoryCannotResolveALibsqlUrlToADriver() { } private static Liquibase liquibase(Connection conn, String changelog) throws Exception { - Database database = new SQLiteDatabase(); - database.setConnection(new JdbcConnection(conn)); + Database database = DatabaseFactory.getInstance() + .findCorrectDatabaseImplementation(new JdbcConnection(conn)); return new Liquibase(changelog, new ClassLoaderResourceAccessor(), database); }