Skip to content

Commit 89e93bc

Browse files
committed
Support running an SQL file on init (1.3)
This is a backport of the PR #252 to `v1.3-ossivalis` stable branch. 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 2cbc516 commit 89e93bc

File tree

6 files changed

+376
-13
lines changed

6 files changed

+376
-13
lines changed

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,29 @@ public final class DuckDBConnection implements java.sql.Connection {
4343
volatile boolean transactionRunning;
4444
final String url;
4545
private final boolean readOnly;
46+
private final String sessionInitSQL;
4647

47-
public static DuckDBConnection newConnection(String url, boolean readOnly, Properties properties)
48-
throws SQLException {
48+
public static DuckDBConnection newConnection(String url, boolean readOnly, Properties properties) throws Exception {
49+
return newConnection(url, readOnly, null, properties);
50+
}
51+
52+
public static DuckDBConnection newConnection(String url, boolean readOnly, String sessionInitSQL,
53+
Properties properties) throws SQLException {
4954
if (null == properties) {
5055
properties = new Properties();
5156
}
5257
String dbName = dbNameFromUrl(url);
5358
ByteBuffer nativeReference = DuckDBNative.duckdb_jdbc_startup(dbName.getBytes(UTF_8), readOnly, properties);
54-
return new DuckDBConnection(nativeReference, url, readOnly);
59+
return new DuckDBConnection(nativeReference, url, readOnly, sessionInitSQL);
5560
}
5661

57-
private DuckDBConnection(ByteBuffer connectionReference, String url, boolean readOnly) throws SQLException {
62+
private DuckDBConnection(ByteBuffer connectionReference, String url, boolean readOnly, String sessionInitSQL)
63+
throws SQLException {
5864
this.connRef = connectionReference;
5965
this.url = url;
6066
this.readOnly = readOnly;
67+
this.sessionInitSQL = sessionInitSQL;
68+
// Hardcoded 'true' here is intentional, autocommit is handled in stmt#execute()
6169
DuckDBNative.duckdb_jdbc_set_auto_commit(connectionReference, true);
6270
}
6371

@@ -88,7 +96,7 @@ public Connection duplicate() throws SQLException {
8896
connRefLock.lock();
8997
try {
9098
checkOpen();
91-
return new DuckDBConnection(DuckDBNative.duckdb_jdbc_connect(connRef), url, readOnly);
99+
return new DuckDBConnection(DuckDBNative.duckdb_jdbc_connect(connRef), url, readOnly, null);
92100
} finally {
93101
connRefLock.unlock();
94102
}
@@ -471,6 +479,10 @@ public DuckDBHugeInt createHugeInt(long lower, long upper) throws SQLException {
471479
return new DuckDBHugeInt(lower, upper);
472480
}
473481

482+
public String getSessionInitSQL() throws SQLException {
483+
return sessionInitSQL;
484+
}
485+
474486
void checkOpen() throws SQLException {
475487
if (isClosed()) {
476488
throw new SQLException("Connection was closed");

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

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

3+
import static java.nio.charset.StandardCharsets.UTF_8;
4+
import static java.nio.file.StandardOpenOption.READ;
5+
import static org.duckdb.JdbcUtils.*;
6+
import static org.duckdb.io.IOUtils.readToString;
7+
8+
import java.io.InputStream;
9+
import java.io.InputStreamReader;
10+
import java.io.Reader;
311
import java.nio.ByteBuffer;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.nio.file.Paths;
15+
import java.security.DigestInputStream;
16+
import java.security.MessageDigest;
417
import java.sql.*;
518
import java.util.*;
619
import java.util.concurrent.ScheduledThreadPoolExecutor;
720
import java.util.concurrent.ThreadFactory;
8-
import static org.duckdb.JdbcUtils.*;
9-
1021
import java.util.concurrent.locks.ReentrantLock;
1122
import java.util.logging.Logger;
23+
import org.duckdb.io.LimitedInputStream;
1224

1325
public class DuckDBDriver implements java.sql.Driver {
1426

@@ -31,6 +43,16 @@ public class DuckDBDriver implements java.sql.Driver {
3143
private static final Set<String> supportedOptions = new LinkedHashSet<>();
3244
private static final ReentrantLock supportedOptionsLock = new ReentrantLock();
3345

46+
private static final String SESSION_INIT_SQL_FILE_OPTION = "session_init_sql_file";
47+
private static final String SESSION_INIT_SQL_FILE_SHA256_OPTION = "session_init_sql_file_sha256";
48+
private static final long SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES = 1 << 20; // 1MB
49+
private static final String SESSION_INIT_SQL_FILE_URL_EXAMPLE =
50+
"jdbc:duckdb:/path/to/db1.db;session_init_sql_file=/path/to/init.sql;session_init_sql_file_sha256=...";
51+
private static final String SESSION_INIT_SQL_CONN_INIT_MARKER =
52+
"/\\*\\s*DUCKDB_CONNECTION_INIT_BELOW_MARKER\\s*\\*/";
53+
private static final LinkedHashSet<String> sessionInitSQLFileDbNames = new LinkedHashSet<>();
54+
private static final ReentrantLock sessionInitSQLFileLock = new ReentrantLock();
55+
3456
static {
3557
try {
3658
DriverManager.registerDriver(new DuckDBDriver());
@@ -56,6 +78,9 @@ public Connection connect(String url, Properties info) throws SQLException {
5678
// URL options
5779
ParsedProps pp = parsePropsFromUrl(url);
5880

81+
// Read session init file
82+
SessionInitSQLFile sf = readSessionInitSQLFile(pp);
83+
5984
// Options in URL take preference
6085
for (Map.Entry<String, String> en : pp.props.entrySet()) {
6186
props.put(en.getKey(), en.getValue());
@@ -82,11 +107,12 @@ public Connection connect(String url, Properties info) throws SQLException {
82107
boolean pinDBOpt = isStringTruish(pinDbOptStr, false);
83108

84109
// Create connection
85-
DuckDBConnection conn = DuckDBConnection.newConnection(pp.shortUrl, readOnly, props);
110+
DuckDBConnection conn = DuckDBConnection.newConnection(pp.shortUrl, readOnly, sf.origFileText, props);
86111

87112
// Run post-init
88113
try {
89114
pinDB(pinDBOpt, pp.shortUrl, conn);
115+
runSessionInitSQLFile(conn, pp.shortUrl, sf);
90116
} catch (SQLException e) {
91117
closeQuietly(conn);
92118
throw e;
@@ -143,6 +169,7 @@ private static ParsedProps parsePropsFromUrl(String url) throws SQLException {
143169
}
144170
String[] parts = url.split(";");
145171
LinkedHashMap<String, String> props = new LinkedHashMap<>();
172+
List<String> origPropNames = new ArrayList<>();
146173
for (int i = 1; i < parts.length; i++) {
147174
String entry = parts[i].trim();
148175
if (entry.isEmpty()) {
@@ -154,10 +181,11 @@ private static ParsedProps parsePropsFromUrl(String url) throws SQLException {
154181
}
155182
String key = kv[0].trim();
156183
String value = kv[1].trim();
184+
origPropNames.add(key);
157185
props.put(key, value);
158186
}
159187
String shortUrl = parts[0].trim();
160-
return new ParsedProps(shortUrl, props);
188+
return new ParsedProps(shortUrl, props, origPropNames);
161189
}
162190

163191
private static void pinDB(boolean pinnedDbOpt, String url, DuckDBConnection conn) throws SQLException {
@@ -247,17 +275,124 @@ private static void removeUnsupportedOptions(Properties props) throws SQLExcepti
247275
}
248276
}
249277

278+
private static SessionInitSQLFile readSessionInitSQLFile(ParsedProps pp) throws SQLException {
279+
if (!pp.props.containsKey(SESSION_INIT_SQL_FILE_OPTION)) {
280+
return new SessionInitSQLFile();
281+
}
282+
283+
List<String> urlOptsList = new ArrayList<>(pp.props.keySet());
284+
285+
if (!SESSION_INIT_SQL_FILE_OPTION.equals(urlOptsList.get(0))) {
286+
throw new SQLException(
287+
"'session_init_sql_file' can only be specified as the first parameter in connection string,"
288+
+ " example: '" + SESSION_INIT_SQL_FILE_URL_EXAMPLE + "'");
289+
}
290+
for (int i = 1; i < pp.origPropNames.size(); i++) {
291+
if (SESSION_INIT_SQL_FILE_OPTION.equalsIgnoreCase(pp.origPropNames.get(i))) {
292+
throw new SQLException("'session_init_sql_file' option cannot be specified more than once");
293+
}
294+
}
295+
String filePathStr = pp.props.remove(SESSION_INIT_SQL_FILE_OPTION);
296+
297+
final String expectedSha256;
298+
if (pp.props.containsKey(SESSION_INIT_SQL_FILE_SHA256_OPTION)) {
299+
if (!SESSION_INIT_SQL_FILE_SHA256_OPTION.equals(urlOptsList.get(1))) {
300+
throw new SQLException(
301+
"'session_init_sql_file_sha256' can only be specified as the second parameter in connection string,"
302+
+ " example: '" + SESSION_INIT_SQL_FILE_URL_EXAMPLE + "'");
303+
}
304+
for (int i = 2; i < pp.origPropNames.size(); i++) {
305+
if (SESSION_INIT_SQL_FILE_SHA256_OPTION.equalsIgnoreCase(pp.origPropNames.get(i))) {
306+
throw new SQLException("'session_init_sql_file_sha256' option cannot be specified more than once");
307+
}
308+
}
309+
expectedSha256 = pp.props.remove(SESSION_INIT_SQL_FILE_SHA256_OPTION);
310+
} else {
311+
expectedSha256 = "";
312+
}
313+
314+
Path filePath = Paths.get(filePathStr);
315+
if (!Files.exists(filePath)) {
316+
throw new SQLException("Specified session init SQL file not found, path: " + filePath);
317+
}
318+
319+
final String origFileText;
320+
final String actualSha256;
321+
try {
322+
long fileSize = Files.size(filePath);
323+
if (fileSize > SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES) {
324+
throw new SQLException("Specified session init SQL file size: " + fileSize +
325+
" exceeds max allowed size: " + SESSION_INIT_SQL_FILE_MAX_SIZE_BYTES);
326+
}
327+
MessageDigest md = MessageDigest.getInstance("SHA-256");
328+
try (InputStream is = new DigestInputStream(
329+
new LimitedInputStream(Files.newInputStream(filePath, READ), fileSize), md)) {
330+
Reader reader = new InputStreamReader(is, UTF_8);
331+
origFileText = readToString(reader);
332+
actualSha256 = bytesToHex(md.digest());
333+
}
334+
} catch (Exception e) {
335+
throw new SQLException(e);
336+
}
337+
338+
if (!expectedSha256.isEmpty() && !expectedSha256.toLowerCase().equals(actualSha256)) {
339+
throw new SQLException("Session init SQL file SHA-256 mismatch, expected: " + expectedSha256 +
340+
", actual: " + actualSha256);
341+
}
342+
343+
String[] parts = origFileText.split(SESSION_INIT_SQL_CONN_INIT_MARKER);
344+
if (parts.length > 2) {
345+
throw new SQLException("Connection init marker: '" + SESSION_INIT_SQL_CONN_INIT_MARKER +
346+
"' can only be specified once");
347+
}
348+
if (1 == parts.length) {
349+
return new SessionInitSQLFile(origFileText, parts[0].trim());
350+
} else {
351+
return new SessionInitSQLFile(origFileText, parts[0].trim(), parts[1].trim());
352+
}
353+
}
354+
355+
private static void runSessionInitSQLFile(Connection conn, String url, SessionInitSQLFile sf) throws SQLException {
356+
if (sf.isEmpty()) {
357+
return;
358+
}
359+
sessionInitSQLFileLock.lock();
360+
try {
361+
362+
if (!sf.dbInitSQL.isEmpty()) {
363+
String dbName = dbNameFromUrl(url);
364+
if (MEMORY_DB.equals(dbName) || !sessionInitSQLFileDbNames.contains(dbName)) {
365+
try (Statement stmt = conn.createStatement()) {
366+
stmt.execute(sf.dbInitSQL);
367+
}
368+
}
369+
sessionInitSQLFileDbNames.add(dbName);
370+
}
371+
372+
if (!sf.connInitSQL.isEmpty()) {
373+
try (Statement stmt = conn.createStatement()) {
374+
stmt.execute(sf.connInitSQL);
375+
}
376+
}
377+
378+
} finally {
379+
sessionInitSQLFileLock.unlock();
380+
}
381+
}
382+
250383
private static class ParsedProps {
251384
final String shortUrl;
252385
final LinkedHashMap<String, String> props;
386+
final List<String> origPropNames;
253387

254388
private ParsedProps(String url) {
255-
this(url, new LinkedHashMap<>());
389+
this(url, new LinkedHashMap<>(), new ArrayList<>());
256390
}
257391

258-
private ParsedProps(String shortUrl, LinkedHashMap<String, String> props) {
392+
private ParsedProps(String shortUrl, LinkedHashMap<String, String> props, List<String> origPropNames) {
259393
this.shortUrl = shortUrl;
260394
this.props = props;
395+
this.origPropNames = origPropNames;
261396
}
262397
}
263398

@@ -279,4 +414,28 @@ public void run() {
279414
}
280415
}
281416
}
417+
418+
private static class SessionInitSQLFile {
419+
final String dbInitSQL;
420+
final String connInitSQL;
421+
final String origFileText;
422+
423+
private SessionInitSQLFile() {
424+
this(null, null, null);
425+
}
426+
427+
private SessionInitSQLFile(String origFileText, String dbInitSQL) {
428+
this(origFileText, dbInitSQL, "");
429+
}
430+
431+
private SessionInitSQLFile(String origFileText, String dbInitSQL, String connInitSQL) {
432+
this.origFileText = origFileText;
433+
this.dbInitSQL = dbInitSQL;
434+
this.connInitSQL = connInitSQL;
435+
}
436+
437+
boolean isEmpty() {
438+
return null == dbInitSQL && null == connInitSQL && null == origFileText;
439+
}
440+
}
282441
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ static <T> T unwrap(Object obj, Class<T> iface) throws SQLException {
1919
return (T) obj;
2020
}
2121

22-
2322
static String removeOption(Properties props, String opt) {
2423
return removeOption(props, opt, null);
2524
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3638,7 +3638,7 @@ public static void main(String[] args) throws Exception {
36383638
} else {
36393639
statusCode = runTests(args, TestDuckDBJDBC.class, TestBatch.class, TestClosure.class,
36403640
TestExtensionTypes.class, TestSpatial.class, TestParameterMetadata.class,
3641-
TestPrepare.class, TestResults.class, TestTimestamp.class);
3641+
TestPrepare.class, TestResults.class, TestSessionInit.class, TestTimestamp.class);
36423642
}
36433643
System.exit(statusCode);
36443644
}

0 commit comments

Comments
 (0)