Skip to content

Commit a870cdd

Browse files
committed
Support running an SQL file on init
This change adds support for `session_init_sql_file` connection option, that allows to speficy the path to an SQL file in local file system, that will be read by the driver and executed in a newly created connection before passing it to user. By default the file is initalized only once per database, on the first connection established to this DB. For `:memory:` connection-private DBs it effectively executed once per connection. In addition to the DB init, it supports executing a part of the SQL file for every connection. It looks for the specific marker: ``` /* DUCKDB_CONNECTION_INIT_BELOW_MARKER */ ``` in the SQL file. If this marker is present - everything before the marker is executed on DB init, and everything after this marker - on connection init. DB init is not re-run when the DB is closed and re-opened after the last connection to it was closed and then new one created. If such re-init is necessary - `jdbc_pin_db` option is supposed to be used instead. It is understood, that this feature can be security sensitive (it effectively implements an RCE entry) in contexts, where other applications/processes/users can control the appending to user-specified connection string or re-writing the specified file in local file system. The following security measures are taken to mitigate that: - `session_init_sql_file` option can only be specified in the connection string itself, it is not accepted as part of connection `Properties` - `session_init_sql_file` option must be specified as the first option in the connection string, for example: 'jdbc:duckdb:;session_init_sql_file=/path/to/init.sql' - `session_init_sql_file_sha256=<sha56sum_of_sql_file>` option can be specified, the file contents SHA-256 sum is checked againts this value - `session_init_sql_file_sha256` option can only be specified in the connection string itself - `session_init_sql_file` and `session_init_sql_file_sha256` options cannot be specified multiple times - content of the SQL file are available to the running code using `DuckDBConnection#getSessionInitSQL()` method Testing: new tests added in a separate file.
1 parent 5243e14 commit a870cdd

File tree

6 files changed

+402
-13
lines changed

6 files changed

+402
-13
lines changed

src/main/java/org/duckdb/DuckDBConnection.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,31 @@ public final class DuckDBConnection implements java.sql.Connection {
4545
volatile boolean transactionRunning;
4646
final String url;
4747
private final boolean readOnly;
48+
private final String sessionInitSQL;
4849

49-
public static DuckDBConnection newConnection(String url, boolean readOnly, Properties properties)
50-
throws SQLException {
50+
public static DuckDBConnection newConnection(String url, boolean readOnly, Properties properties) throws Exception {
51+
return newConnection(url, readOnly, null, properties);
52+
}
53+
54+
public static DuckDBConnection newConnection(String url, boolean readOnly, String sessionInitSQL,
55+
Properties properties) throws SQLException {
5156
if (null == properties) {
5257
properties = new Properties();
5358
}
5459
String dbName = dbNameFromUrl(url);
5560
String autoCommitStr = removeOption(properties, JDBC_AUTO_COMMIT);
5661
boolean autoCommit = isStringTruish(autoCommitStr, true);
5762
ByteBuffer nativeReference = DuckDBNative.duckdb_jdbc_startup(dbName.getBytes(UTF_8), readOnly, properties);
58-
return new DuckDBConnection(nativeReference, url, readOnly, autoCommit);
63+
return new DuckDBConnection(nativeReference, url, readOnly, sessionInitSQL, autoCommit);
5964
}
6065

61-
private DuckDBConnection(ByteBuffer connectionReference, String url, boolean readOnly, boolean autoCommit)
62-
throws SQLException {
66+
private DuckDBConnection(ByteBuffer connectionReference, String url, boolean readOnly, String sessionInitSQL,
67+
boolean autoCommit) throws SQLException {
6368
this.connRef = connectionReference;
6469
this.url = url;
6570
this.readOnly = readOnly;
6671
this.autoCommit = autoCommit;
72+
this.sessionInitSQL = sessionInitSQL;
6773
// Hardcoded 'true' here is intentional, autocommit is handled in stmt#execute()
6874
DuckDBNative.duckdb_jdbc_set_auto_commit(connectionReference, true);
6975
}
@@ -95,7 +101,8 @@ public Connection duplicate() throws SQLException {
95101
connRefLock.lock();
96102
try {
97103
checkOpen();
98-
return new DuckDBConnection(DuckDBNative.duckdb_jdbc_connect(connRef), url, readOnly, autoCommit);
104+
return new DuckDBConnection(DuckDBNative.duckdb_jdbc_connect(connRef), url, readOnly, sessionInitSQL,
105+
autoCommit);
99106
} finally {
100107
connRefLock.unlock();
101108
}
@@ -478,6 +485,10 @@ public DuckDBHugeInt createHugeInt(long lower, long upper) throws SQLException {
478485
return new DuckDBHugeInt(lower, upper);
479486
}
480487

488+
public String getSessionInitSQL() throws SQLException {
489+
return sessionInitSQL;
490+
}
491+
481492
void checkOpen() throws SQLException {
482493
if (isClosed()) {
483494
throw new SQLException("Connection was closed");

src/main/java/org/duckdb/DuckDBDriver.java

Lines changed: 169 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
package org.duckdb;
22

3+
import static java.nio.charset.StandardCharsets.UTF_8;
4+
import static java.nio.file.StandardOpenOption.READ;
35
import static org.duckdb.JdbcUtils.*;
6+
import static org.duckdb.io.IOUtils.readToString;
47

8+
import java.io.*;
59
import java.nio.ByteBuffer;
10+
import java.nio.file.*;
11+
import java.security.DigestInputStream;
12+
import java.security.MessageDigest;
613
import java.sql.*;
714
import java.util.*;
815
import java.util.concurrent.locks.ReentrantLock;
916
import java.util.logging.Logger;
1017
import java.util.regex.Pattern;
18+
import org.duckdb.io.LimitedInputStream;
1119

1220
public class DuckDBDriver implements java.sql.Driver {
1321

@@ -37,6 +45,16 @@ public class DuckDBDriver implements java.sql.Driver {
3745
private static final Set<String> supportedOptions = new LinkedHashSet<>();
3846
private static final ReentrantLock supportedOptionsLock = new ReentrantLock();
3947

48+
private static final String SESSION_INIT_SQL_FILE_OPTION = "session_init_sql_file";
49+
private static final String SESSION_INIT_SQL_FILE_SHA256_OPTION = "session_init_sql_file_sha256";
50+
private static final long SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES = 1 << 20; // 1MB
51+
private static final String SESSION_INIT_SQL_FILE_URL_EXAMPLE =
52+
"jdbc:duckdb:/path/to/db1.db;session_init_sql_file=/path/to/init.sql;session_init_sql_file_sha256=...";
53+
private static final String SESSION_INIT_SQL_CONN_INIT_MARKER =
54+
"/\\*\\s*DUCKDB_CONNECTION_INIT_BELOW_MARKER\\s*\\*/";
55+
private static final LinkedHashSet<String> sessionInitSQLFileDbNames = new LinkedHashSet<>();
56+
private static final ReentrantLock sessionInitSQLFileLock = new ReentrantLock();
57+
4058
static {
4159
try {
4260
DriverManager.registerDriver(new DuckDBDriver());
@@ -58,6 +76,11 @@ public Connection connect(String url, Properties info) throws SQLException {
5876

5977
// URL options
6078
ParsedProps pp = parsePropsFromUrl(url);
79+
80+
// Read session init file
81+
SessionInitSQLFile sf = readSessionInitSQLFile(pp);
82+
83+
// Options in URL take preference
6184
for (Map.Entry<String, String> en : pp.props.entrySet()) {
6285
props.put(en.getKey(), en.getValue());
6386
}
@@ -100,9 +123,16 @@ public Connection connect(String url, Properties info) throws SQLException {
100123
boolean pinDBOpt = isStringTruish(pinDbOptStr, false);
101124

102125
// Create connection
103-
DuckDBConnection conn = DuckDBConnection.newConnection(shortUrl, readOnly, props);
104-
pinDB(pinDBOpt, shortUrl, conn);
105-
initDucklake(conn, shortUrl, ducklake, ducklakeAlias);
126+
DuckDBConnection conn = DuckDBConnection.newConnection(shortUrl, readOnly, sf.origFileText, props);
127+
128+
try {
129+
pinDB(pinDBOpt, shortUrl, conn);
130+
runSessionInitSQLFile(conn, url, sf);
131+
initDucklake(conn, shortUrl, ducklake, ducklakeAlias);
132+
} catch (SQLException e) {
133+
closeQuietly(conn);
134+
throw e;
135+
}
106136

107137
return conn;
108138
}
@@ -193,6 +223,7 @@ private static ParsedProps parsePropsFromUrl(String url) throws SQLException {
193223
}
194224
String[] parts = url.split(";");
195225
LinkedHashMap<String, String> props = new LinkedHashMap<>();
226+
List<String> origPropNames = new ArrayList<>();
196227
for (int i = 1; i < parts.length; i++) {
197228
String entry = parts[i].trim();
198229
if (entry.isEmpty()) {
@@ -204,10 +235,11 @@ private static ParsedProps parsePropsFromUrl(String url) throws SQLException {
204235
}
205236
String key = kv[0].trim();
206237
String value = kv[1].trim();
238+
origPropNames.add(key);
207239
props.put(key, value);
208240
}
209241
String shortUrl = parts[0].trim();
210-
return new ParsedProps(shortUrl, props);
242+
return new ParsedProps(shortUrl, props, origPropNames);
211243
}
212244

213245
private static void pinDB(boolean pinnedDbOpt, String url, DuckDBConnection conn) throws SQLException {
@@ -297,17 +329,124 @@ private static void removeUnsupportedOptions(Properties props) throws SQLExcepti
297329
}
298330
}
299331

332+
private static SessionInitSQLFile readSessionInitSQLFile(ParsedProps pp) throws SQLException {
333+
if (!pp.props.containsKey(SESSION_INIT_SQL_FILE_OPTION)) {
334+
return new SessionInitSQLFile();
335+
}
336+
337+
List<String> urlOptsList = new ArrayList<>(pp.props.keySet());
338+
339+
if (!SESSION_INIT_SQL_FILE_OPTION.equals(urlOptsList.get(0))) {
340+
throw new SQLException(
341+
"'session_init_sql_file' can only be specified as the first parameter in connection string,"
342+
+ " example: '" + SESSION_INIT_SQL_FILE_URL_EXAMPLE + "'");
343+
}
344+
for (int i = 1; i < pp.origPropNames.size(); i++) {
345+
if (SESSION_INIT_SQL_FILE_OPTION.equalsIgnoreCase(pp.origPropNames.get(i))) {
346+
throw new SQLException("'session_init_sql_file' option cannot be specified more than once");
347+
}
348+
}
349+
String filePathStr = pp.props.remove(SESSION_INIT_SQL_FILE_OPTION);
350+
351+
final String expectedSha256;
352+
if (pp.props.containsKey(SESSION_INIT_SQL_FILE_SHA256_OPTION)) {
353+
if (!SESSION_INIT_SQL_FILE_SHA256_OPTION.equals(urlOptsList.get(1))) {
354+
throw new SQLException(
355+
"'session_init_sql_file_sha256' can only be specified as the second parameter in connection string,"
356+
+ " example: '" + SESSION_INIT_SQL_FILE_URL_EXAMPLE + "'");
357+
}
358+
for (int i = 2; i < pp.origPropNames.size(); i++) {
359+
if (SESSION_INIT_SQL_FILE_SHA256_OPTION.equalsIgnoreCase(pp.origPropNames.get(i))) {
360+
throw new SQLException("'session_init_sql_file_sha256' option cannot be specified more than once");
361+
}
362+
}
363+
expectedSha256 = pp.props.remove(SESSION_INIT_SQL_FILE_SHA256_OPTION);
364+
} else {
365+
expectedSha256 = "";
366+
}
367+
368+
Path filePath = Paths.get(filePathStr);
369+
if (!Files.exists(filePath)) {
370+
throw new SQLException("Specified session init SQL file not found, path: " + filePath);
371+
}
372+
373+
final String origFileText;
374+
final String actualSha256;
375+
try {
376+
long fileSize = Files.size(filePath);
377+
if (fileSize > SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES) {
378+
throw new SQLException("Specified session init SQL file size: " + fileSize +
379+
" exceeds max allowed size: " + SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES);
380+
}
381+
MessageDigest md = MessageDigest.getInstance("SHA-256");
382+
try (InputStream is = new DigestInputStream(
383+
new LimitedInputStream(Files.newInputStream(filePath, READ), fileSize), md)) {
384+
Reader reader = new InputStreamReader(is, UTF_8);
385+
origFileText = readToString(reader);
386+
actualSha256 = bytesToHex(md.digest());
387+
}
388+
} catch (Exception e) {
389+
throw new SQLException(e);
390+
}
391+
392+
if (!expectedSha256.isEmpty() && !expectedSha256.toLowerCase().equals(actualSha256)) {
393+
throw new SQLException("Session init SQL file SHA-256 mismatch, expected: " + expectedSha256 +
394+
", actual: " + actualSha256);
395+
}
396+
397+
String[] parts = origFileText.split(SESSION_INIT_SQL_CONN_INIT_MARKER);
398+
if (parts.length > 2) {
399+
throw new SQLException("Connection init marker: '" + SESSION_INIT_SQL_CONN_INIT_MARKER +
400+
"' can only be specified once");
401+
}
402+
if (1 == parts.length) {
403+
return new SessionInitSQLFile(origFileText, parts[0].trim());
404+
} else {
405+
return new SessionInitSQLFile(origFileText, parts[0].trim(), parts[1].trim());
406+
}
407+
}
408+
409+
private static void runSessionInitSQLFile(Connection conn, String url, SessionInitSQLFile sf) throws SQLException {
410+
if (sf.isEmpty()) {
411+
return;
412+
}
413+
sessionInitSQLFileLock.lock();
414+
try {
415+
416+
if (!sf.dbInitSQL.isEmpty()) {
417+
String dbName = dbNameFromUrl(url);
418+
if (MEMORY_DB.equals(dbName) || !sessionInitSQLFileDbNames.contains(dbName)) {
419+
try (Statement stmt = conn.createStatement()) {
420+
stmt.execute(sf.dbInitSQL);
421+
}
422+
}
423+
sessionInitSQLFileDbNames.add(dbName);
424+
}
425+
426+
if (!sf.connInitSQL.isEmpty()) {
427+
try (Statement stmt = conn.createStatement()) {
428+
stmt.execute(sf.connInitSQL);
429+
}
430+
}
431+
432+
} finally {
433+
sessionInitSQLFileLock.unlock();
434+
}
435+
}
436+
300437
private static class ParsedProps {
301438
final String shortUrl;
302439
final LinkedHashMap<String, String> props;
440+
final List<String> origPropNames;
303441

304442
private ParsedProps(String url) {
305-
this(url, new LinkedHashMap<>());
443+
this(url, new LinkedHashMap<>(), new ArrayList<>());
306444
}
307445

308-
private ParsedProps(String shortUrl, LinkedHashMap<String, String> props) {
446+
private ParsedProps(String shortUrl, LinkedHashMap<String, String> props, List<String> origPropNames) {
309447
this.shortUrl = shortUrl;
310448
this.props = props;
449+
this.origPropNames = origPropNames;
311450
}
312451
}
313452

@@ -329,4 +468,28 @@ public void run() {
329468
}
330469
}
331470
}
471+
472+
private static class SessionInitSQLFile {
473+
final String dbInitSQL;
474+
final String connInitSQL;
475+
final String origFileText;
476+
477+
private SessionInitSQLFile() {
478+
this(null, null, null);
479+
}
480+
481+
private SessionInitSQLFile(String origFileText, String dbInitSQL) {
482+
this(origFileText, dbInitSQL, "");
483+
}
484+
485+
private SessionInitSQLFile(String origFileText, String dbInitSQL, String connInitSQL) {
486+
this.origFileText = origFileText;
487+
this.dbInitSQL = dbInitSQL;
488+
this.connInitSQL = connInitSQL;
489+
}
490+
491+
boolean isEmpty() {
492+
return null == dbInitSQL && null == connInitSQL && null == origFileText;
493+
}
494+
}
332495
}

src/main/java/org/duckdb/JdbcUtils.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,26 @@ static String dbNameFromUrl(String url) throws SQLException {
7575
}
7676
return dbName;
7777
}
78+
79+
static String bytesToHex(byte[] bytes) {
80+
if (null == bytes) {
81+
return "";
82+
}
83+
StringBuilder sb = new StringBuilder(bytes.length * 2);
84+
for (byte b : bytes) {
85+
sb.append(String.format("%02x", b));
86+
}
87+
return sb.toString();
88+
}
89+
90+
static void closeQuietly(AutoCloseable closeable) {
91+
if (null == closeable) {
92+
return;
93+
}
94+
try {
95+
closeable.close();
96+
} catch (Exception e) {
97+
// suppress
98+
}
99+
}
78100
}

src/test/java/org/duckdb/TestDuckDBJDBC.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3674,7 +3674,7 @@ public static void main(String[] args) throws Exception {
36743674
} else {
36753675
statusCode = runTests(args, TestDuckDBJDBC.class, TestBatch.class, TestClosure.class,
36763676
TestExtensionTypes.class, TestSpatial.class, TestParameterMetadata.class,
3677-
TestPrepare.class, TestResults.class, TestTimestamp.class);
3677+
TestPrepare.class, TestResults.class, TestSessionInit.class, TestTimestamp.class);
36783678
}
36793679
System.exit(statusCode);
36803680
}

0 commit comments

Comments
 (0)