commit d1a531a3ef65fc16f27ebed8106c5a5399562c6d
parent 2989a2317965a1c6da04c96d2dbbc4bdd2852e7a
Author: Florian Dold <dold@taler.net>
Date: Tue, 1 Sep 2026 21:35:36 +0200
qtart: expose API version and SQLite open options
Diffstat:
2 files changed, 147 insertions(+), 5 deletions(-)
diff --git a/tart_module.c b/tart_module.c
@@ -43,6 +43,9 @@ static JSValue make_js_ta_copy(JSContext *ctx, uint8_t *data, size_t size);
#define TART_MAX_RSA_MODULUS_BYTES 1024U
#define TART_MAX_RSA_EXPONENT_BYTES 8U
+/* Increment when the JavaScript-facing native API gains new semantics. */
+#define TART_API_VERSION 1
+
static JSValue js_encode_utf8(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv)
{
@@ -1843,21 +1846,109 @@ static JSValue throw_sqlite3_error(JSContext *ctx, sqlite3 *db)
#define MAX_SAFE_INTEGER (((int64_t)1 << 53) - 1)
#define MIN_SAFE_INTEGER (-(((int64_t)1 << 53) - 1))
-// (path: string, options?: { readonly?: boolean = false }) => Sqlite3Database
+static int sqlite3_open_boolean_option(JSContext *ctx,
+ JSValueConst options,
+ const char *name,
+ int *result)
+{
+ JSValue value;
+
+ value = JS_GetPropertyStr(ctx, options, name);
+ if (JS_IsException(value)) {
+ return -1;
+ }
+ if (JS_IsUndefined(value)) {
+ *result = 0;
+ } else if (!JS_IsBool(value)) {
+ JS_FreeValue(ctx, value);
+ JS_ThrowTypeError(ctx, "sqlite3Open option '%s' must be a boolean", name);
+ return -1;
+ } else {
+ *result = JS_ToBool(ctx, value);
+ }
+ JS_FreeValue(ctx, value);
+ return 0;
+}
+
+static int sqlite_uri_path_char(unsigned char c)
+{
+ return (c >= 'a' && c <= 'z') ||
+ (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') ||
+ c == '/' || c == '-' || c == '.' || c == '_' || c == '~';
+}
+
+static char *sqlite3_immutable_uri(const char *filename)
+{
+ static const char hex[] = "0123456789ABCDEF";
+ static const char prefix[] = "file:";
+ static const char suffix[] = "?immutable=1";
+ size_t filename_len;
+ size_t allocation_size;
+ char *uri;
+ char *p;
+
+ filename_len = strlen(filename);
+ if (filename_len > (SIZE_MAX - sizeof(prefix) - sizeof(suffix)) / 3) {
+ return NULL;
+ }
+ allocation_size = sizeof(prefix) - 1 + filename_len * 3 + sizeof(suffix);
+ uri = sqlite3_malloc64(allocation_size);
+ if (!uri) {
+ return NULL;
+ }
+ p = uri;
+ memcpy(p, prefix, sizeof(prefix) - 1);
+ p += sizeof(prefix) - 1;
+ for (size_t i = 0; i < filename_len; i++) {
+ unsigned char c = (unsigned char)filename[i];
+ if (sqlite_uri_path_char(c)) {
+ *p++ = (char)c;
+ } else {
+ *p++ = '%';
+ *p++ = hex[c >> 4];
+ *p++ = hex[c & 0x0f];
+ }
+ }
+ memcpy(p, suffix, sizeof(suffix));
+ return uri;
+}
+
+// (path: string, options?: { readonly?: boolean, immutable?: boolean }) => Sqlite3Database
static JSValue js_sqlite3_open(JSContext *ctx, JSValue this_val,
int argc, JSValueConst *argv)
{
JSValue ret_val = JS_UNINITIALIZED;
JSValue db_obj = JS_UNINITIALIZED;
const char *filename = NULL;
+ const char *open_filename;
+ char *immutable_uri = NULL;
sqlite3 *sqlite3_db = NULL;
+ int readonly = 0;
+ int immutable = 0;
+ int open_flags;
int ret;
- if (!JS_IsString(argv[0])) {
+ if (argc < 1 || !JS_IsString(argv[0])) {
ret_val = JS_ThrowTypeError(ctx, "filename argument required");
goto done;
}
+ if (argc > 1 && !JS_IsUndefined(argv[1])) {
+ if (!JS_IsObject(argv[1])) {
+ ret_val = JS_ThrowTypeError(ctx, "sqlite3Open options must be an object");
+ goto done;
+ }
+ if (sqlite3_open_boolean_option(ctx, argv[1], "readonly", &readonly) < 0 ||
+ sqlite3_open_boolean_option(ctx, argv[1], "immutable", &immutable) < 0) {
+ ret_val = JS_EXCEPTION;
+ goto done;
+ }
+ }
+ if (immutable) {
+ readonly = 1;
+ }
+
filename = JS_ToCString(ctx, argv[0]);
if (!filename) {
@@ -1865,8 +1956,22 @@ static JSValue js_sqlite3_open(JSContext *ctx, JSValue this_val,
goto done;
}
+ open_filename = filename;
+ open_flags = readonly
+ ? SQLITE_OPEN_READONLY
+ : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
+ if (immutable) {
+ immutable_uri = sqlite3_immutable_uri(filename);
+ if (!immutable_uri) {
+ ret_val = JS_ThrowOutOfMemory(ctx);
+ goto done;
+ }
+ open_filename = immutable_uri;
+ open_flags |= SQLITE_OPEN_URI;
+ }
+
// fprintf(stderr, "opening sqlite3 db at %s\n", filename),
- ret = sqlite3_open_v2(filename, &sqlite3_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);
+ ret = sqlite3_open_v2(open_filename, &sqlite3_db, open_flags, NULL);
if (SQLITE_OK != ret) {
if (NULL == sqlite3_db) {
// sqlite3 was unable to even allocate memory
@@ -1891,6 +1996,7 @@ static JSValue js_sqlite3_open(JSContext *ctx, JSValue this_val,
JS_SetOpaque(db_obj, sqlite3_db);
ret_val = db_obj;
done:
+ sqlite3_free(immutable_uri);
JS_FreeCString(ctx, filename);
return ret_val;
}
@@ -2467,6 +2573,7 @@ fail:
static const JSCFunctionListEntry tart_talercrypto_funcs[] = {
+ JS_PROP_INT32_DEF("apiVersion", TART_API_VERSION, 0),
JS_CFUNC_DEF("structuredClone", 1, js_structured_clone),
JS_CFUNC_DEF("encodeUtf8", 1, js_encode_utf8),
JS_CFUNC_DEF("crashForDebug", 0, js_crash_for_debug),
@@ -2489,7 +2596,7 @@ static const JSCFunctionListEntry tart_talercrypto_funcs[] = {
JS_CFUNC_DEF("rsaBlind", 3, js_talercrypto_rsa_blind),
JS_CFUNC_DEF("rsaUnblind", 3, js_talercrypto_rsa_unblind),
JS_CFUNC_DEF("rsaVerify", 3, js_talercrypto_rsa_verify),
- JS_CFUNC_DEF("sqlite3Open", 1, js_sqlite3_open),
+ JS_CFUNC_DEF("sqlite3Open", 2, js_sqlite3_open),
JS_CFUNC_DEF("sqlite3Close", 1, js_sqlite3_close),
JS_CFUNC_DEF("sqlite3Prepare", 2, js_sqlite3_prepare),
JS_CFUNC_DEF("sqlite3Finalize", 1, js_sqlite3_finalize),
diff --git a/tests/test_sqlite3_error.js b/tests/test_sqlite3_error.js
@@ -1,7 +1,7 @@
import * as os from "os";
import * as tart from "tart";
-const dbPath = `/tmp/qtart-sqlite3-error-${Date.now()}.sqlite3`;
+const dbPath = `/tmp/qtart-sqlite3-error-${Date.now()}?.sqlite3`;
let db;
let stmt;
@@ -45,6 +45,8 @@ function expectTypeError(fn, messagePart) {
}
try {
+ assert(tart.apiVersion === 1,
+ `expected Qtart API version 1, got ${tart.apiVersion}`);
db = tart.sqlite3Open(dbPath);
tart.sqlite3Exec(db, "CREATE TABLE entries (value TEXT UNIQUE)");
tart.sqlite3Exec(db, "INSERT INTO entries VALUES ('one')");
@@ -89,6 +91,39 @@ try {
stmt = undefined;
tart.sqlite3Close(db);
db = undefined;
+
+ expectTypeError(
+ () => tart.sqlite3Open(dbPath, { readonly: "yes" }),
+ "option 'readonly' must be a boolean",
+ );
+
+ db = tart.sqlite3Open(dbPath, { readonly: true });
+ stmt = tart.sqlite3Prepare(db, "SELECT value FROM entries");
+ assert(tart.sqlite3StmtGetFirst(stmt)?.value === "one",
+ "read-only connection must read existing records");
+ tart.sqlite3Finalize(stmt);
+ stmt = undefined;
+ expectSqlite3Error(
+ () => tart.sqlite3Exec(db, "INSERT INTO entries VALUES ('two')"),
+ "SQLITE_READONLY",
+ "readonly",
+ );
+ tart.sqlite3Close(db);
+ db = undefined;
+
+ db = tart.sqlite3Open(dbPath, { immutable: true });
+ stmt = tart.sqlite3Prepare(db, "SELECT value FROM entries");
+ assert(tart.sqlite3StmtGetFirst(stmt)?.value === "one",
+ "immutable connection must preserve URI-special path characters");
+ tart.sqlite3Finalize(stmt);
+ stmt = undefined;
+ expectSqlite3Error(
+ () => tart.sqlite3Exec(db, "INSERT INTO entries VALUES ('two')"),
+ "SQLITE_READONLY",
+ "readonly",
+ );
+ tart.sqlite3Close(db);
+ db = undefined;
} finally {
if (stmt !== undefined) {
tart.sqlite3Finalize(stmt);