Skip to content

Commit b809a75

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 ae6639c commit b809a75

File tree

6 files changed

+402
-14
lines changed

6 files changed

+402
-14
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 & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
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.ScheduledThreadPoolExecutor;
916
import java.util.concurrent.ThreadFactory;
1017
import java.util.concurrent.locks.ReentrantLock;
1118
import java.util.logging.Logger;
1219
import java.util.regex.Pattern;
20+
import org.duckdb.io.LimitedInputStream;
1321

1422
public class DuckDBDriver implements java.sql.Driver {
1523

@@ -41,6 +49,16 @@ public class DuckDBDriver implements java.sql.Driver {
4149
private static final Set<String> supportedOptions = new LinkedHashSet<>();
4250
private static final ReentrantLock supportedOptionsLock = new ReentrantLock();
4351

52+
private static final String SESSION_INIT_SQL_FILE_OPTION = "session_init_sql_file";
53+
private static final String SESSION_INIT_SQL_FILE_SHA256_OPTION = "session_init_sql_file_sha256";
54+
private static final long SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES = 1 << 20; // 1MB
55+
private static final String SESSION_INIT_SQL_FILE_URL_EXAMPLE =
56+
"jdbc:duckdb:/path/to/db1.db;session_init_sql_file=/path/to/init.sql;session_init_sql_file_sha256=...";
57+
private static final String SESSION_INIT_SQL_CONN_INIT_MARKER =
58+
"/\\*\\s*DUCKDB_CONNECTION_INIT_BELOW_MARKER\\s*\\*/";
59+
private static final LinkedHashSet<String> sessionInitSQLFileDbNames = new LinkedHashSet<>();
60+
private static final ReentrantLock sessionInitSQLFileLock = new ReentrantLock();
61+
4462
static {
4563
try {
4664
DriverManager.registerDriver(new DuckDBDriver());
@@ -65,6 +83,11 @@ public Connection connect(String url, Properties info) throws SQLException {
6583

6684
// URL options
6785
ParsedProps pp = parsePropsFromUrl(url);
86+
87+
// Read session init file
88+
SessionInitSQLFile sf = readSessionInitSQLFile(pp);
89+
90+
// Options in URL take preference
6891
for (Map.Entry<String, String> en : pp.props.entrySet()) {
6992
props.put(en.getKey(), en.getValue());
7093
}
@@ -107,11 +130,17 @@ public Connection connect(String url, Properties info) throws SQLException {
107130
boolean pinDBOpt = isStringTruish(pinDbOptStr, false);
108131

109132
// Create connection
110-
DuckDBConnection conn = DuckDBConnection.newConnection(shortUrl, readOnly, props);
111-
112-
pinDB(pinDBOpt, shortUrl, conn);
133+
DuckDBConnection conn = DuckDBConnection.newConnection(shortUrl, readOnly, sf.origFileText, props);
113134

114-
initDucklake(conn, shortUrl, ducklake, ducklakeAlias);
135+
// Run post-init
136+
try {
137+
pinDB(pinDBOpt, shortUrl, conn);
138+
runSessionInitSQLFile(conn, url, sf);
139+
initDucklake(conn, shortUrl, ducklake, ducklakeAlias);
140+
} catch (SQLException e) {
141+
closeQuietly(conn);
142+
throw e;
143+
}
115144

116145
return conn;
117146
}
@@ -202,6 +231,7 @@ private static ParsedProps parsePropsFromUrl(String url) throws SQLException {
202231
}
203232
String[] parts = url.split(";");
204233
LinkedHashMap<String, String> props = new LinkedHashMap<>();
234+
List<String> origPropNames = new ArrayList<>();
205235
for (int i = 1; i < parts.length; i++) {
206236
String entry = parts[i].trim();
207237
if (entry.isEmpty()) {
@@ -213,10 +243,11 @@ private static ParsedProps parsePropsFromUrl(String url) throws SQLException {
213243
}
214244
String key = kv[0].trim();
215245
String value = kv[1].trim();
246+
origPropNames.add(key);
216247
props.put(key, value);
217248
}
218249
String shortUrl = parts[0].trim();
219-
return new ParsedProps(shortUrl, props);
250+
return new ParsedProps(shortUrl, props, origPropNames);
220251
}
221252

222253
private static void pinDB(boolean pinnedDbOpt, String url, DuckDBConnection conn) throws SQLException {
@@ -306,17 +337,124 @@ private static void removeUnsupportedOptions(Properties props) throws SQLExcepti
306337
}
307338
}
308339

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

313450
private ParsedProps(String url) {
314-
this(url, new LinkedHashMap<>());
451+
this(url, new LinkedHashMap<>(), new ArrayList<>());
315452
}
316453

317-
private ParsedProps(String shortUrl, LinkedHashMap<String, String> props) {
454+
private ParsedProps(String shortUrl, LinkedHashMap<String, String> props, List<String> origPropNames) {
318455
this.shortUrl = shortUrl;
319456
this.props = props;
457+
this.origPropNames = origPropNames;
320458
}
321459
}
322460

@@ -338,4 +476,28 @@ public void run() {
338476
}
339477
}
340478
}
479+
480+
private static class SessionInitSQLFile {
481+
final String dbInitSQL;
482+
final String connInitSQL;
483+
final String origFileText;
484+
485+
private SessionInitSQLFile() {
486+
this(null, null, null);
487+
}
488+
489+
private SessionInitSQLFile(String origFileText, String dbInitSQL) {
490+
this(origFileText, dbInitSQL, "");
491+
}
492+
493+
private SessionInitSQLFile(String origFileText, String dbInitSQL, String connInitSQL) {
494+
this.origFileText = origFileText;
495+
this.dbInitSQL = dbInitSQL;
496+
this.connInitSQL = connInitSQL;
497+
}
498+
499+
boolean isEmpty() {
500+
return null == dbInitSQL && null == connInitSQL && null == origFileText;
501+
}
502+
}
341503
}

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)