Repository: denoland/deno
Pull request: fix(ext/node): support read-only database in `node:sqlite`

Implements the `readOnly` option for `DatabaseSync`.

Permissions:
`--allow-read=test.db --allow-write=test.db` => all works
`--allow-read=test.db` => only `readOnly` dbs work, cannot create new db


Changed files:
filename: ext/node/ops/sqlite/database.rs
patch: @@ -20,6 +20,7 @@ struct DatabaseSyncOptions {
   open: bool,
   #[serde(default = "true_fn")]
   enable_foreign_key_constraints: bool,
+  read_only: bool,
 }
 
 fn true_fn() -> bool {
@@ -31,6 +32,7 @@ impl Default for DatabaseSyncOptions {
     DatabaseSyncOptions {
       open: true,
       enable_foreign_key_constraints: true,
+      read_only: false,
     }
   }
 }
@@ -43,17 +45,31 @@ pub struct DatabaseSync {
 
 impl GarbageCollected for DatabaseSync {}
 
-fn check_perms(state: &mut OpState, location: &str) -> Result<(), SqliteError> {
-  if location != ":memory:" {
-    state
-      .borrow::<PermissionsContainer>()
-      .check_read_with_api_name(location, Some("node:sqlite"))?;
-    state
-      .borrow::<PermissionsContainer>()
-      .check_write_with_api_name(location, Some("node:sqlite"))?;
+fn open_db(
+  state: &mut OpState,
+  readonly: bool,
+  location: &str,
+) -> Result<rusqlite::Connection, SqliteError> {
+  if location == ":memory:" {
+    return Ok(rusqlite::Connection::open_in_memory()?);
   }
 
-  Ok(())
+  state
+    .borrow::<PermissionsContainer>()
+    .check_read_with_api_name(location, Some("node:sqlite"))?;
+
+  if readonly {
+    return Ok(rusqlite::Connection::open_with_flags(
+      location,
+      rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
+    )?);
+  }
+
+  state
+    .borrow::<PermissionsContainer>()
+    .check_write_with_api_name(location, Some("node:sqlite"))?;
+
+  Ok(rusqlite::Connection::open(location)?)
 }
 
 // Represents a single connection to a SQLite database.
@@ -75,9 +91,8 @@ impl DatabaseSync {
     let options = options.unwrap_or_default();
 
     let db = if options.open {
-      check_perms(state, &location)?;
+      let db = open_db(state, options.read_only, &location)?;
 
-      let db = rusqlite::Connection::open(&location)?;
       if options.enable_foreign_key_constraints {
         db.execute("PRAGMA foreign_keys = ON", [])?;
       }
@@ -104,8 +119,7 @@ impl DatabaseSync {
       return Err(SqliteError::AlreadyOpen);
     }
 
-    check_perms(state, &self.location)?;
-    let db = rusqlite::Connection::open(&self.location)?;
+    let db = open_db(state, self.options.read_only, &self.location)?;
     if self.options.enable_foreign_key_constraints {
       db.execute("PRAGMA foreign_keys = ON", [])?;
     }

filename: tests/unit_node/sqlite_test.ts
patch: @@ -2,6 +2,8 @@
 import { DatabaseSync } from "node:sqlite";
 import { assert, assertEquals, assertThrows } from "@std/assert";
 
+const tempDir = Deno.makeTempDirSync();
+
 Deno.test("[node/sqlite] in-memory databases", () => {
   const db1 = new DatabaseSync(":memory:");
   const db2 = new DatabaseSync(":memory:");
@@ -40,7 +42,6 @@ Deno.test(
     name: "[node/sqlite] PRAGMAs are supported",
   },
   () => {
-    const tempDir = Deno.makeTempDirSync();
     const db = new DatabaseSync(`${tempDir}/test.db`);
 
     assertEquals(db.prepare("PRAGMA journal_mode = WAL").get(), {
@@ -99,5 +100,31 @@ Deno.test({
     assertThrows(() => {
       new DatabaseSync("test.db");
     }, Deno.errors.NotCapable);
+    assertThrows(() => {
+      new DatabaseSync("test.db", { readOnly: true });
+    }, Deno.errors.NotCapable);
+  },
+});
+
+Deno.test({
+  name: "[node/sqlite] readOnly database",
+  permissions: { read: true, write: true },
+  fn() {
+    {
+      const db = new DatabaseSync(`${tempDir}/test3.db`);
+      db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY)");
+      db.close();
+    }
+    {
+      const db = new DatabaseSync(`${tempDir}/test3.db`, { readOnly: true });
+      assertThrows(
+        () => {
+          db.exec("CREATE TABLE test(key INTEGER PRIMARY KEY)");
+        },
+        Error,
+        "attempt to write a readonly database",
+      );
+      db.close();
+    }
   },
 });