1#[cfg(test)]
2use std::cell::Cell;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use rusqlite::{Connection, OpenFlags};
7use sha2::{Digest, Sha256};
8
9use super::pragma::{ConnectionMode, ConnectionPragmas};
10
11#[cfg(test)]
12thread_local! {
13 static CONFIGURED_CONNECTION_OPENS: Cell<usize> = const { Cell::new(0) };
14}
15
16#[cfg(test)]
17pub(crate) fn reset_configured_connection_open_count() {
18 CONFIGURED_CONNECTION_OPENS.with(|count| count.set(0));
19}
20
21#[cfg(test)]
22pub(crate) fn configured_connection_open_count() -> usize {
23 CONFIGURED_CONNECTION_OPENS.with(Cell::get)
24}
25
26#[cfg(test)]
27fn record_configured_connection_open() {
28 CONFIGURED_CONNECTION_OPENS.with(|count| count.set(count.get() + 1));
29}
30
31pub fn deterministic_hash(data: &[u8]) -> u64 {
32 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
33 const FNV_PRIME: u64 = 0x00000100000001B3;
34 let mut hash = FNV_OFFSET;
35 for &byte in data {
36 hash ^= byte as u64;
37 hash = hash.wrapping_mul(FNV_PRIME);
38 }
39 hash
40}
41
42pub fn content_identity_hash(data: &[u8]) -> String {
43 let mut hasher = Sha256::new();
44 hasher.update(data);
45 format!("sha256:content-v1:{:x}", hasher.finalize())
46}
47
48pub fn legacy_content_identity_hash(data: &[u8]) -> String {
49 format!("{:016x}", deterministic_hash(data))
50}
51
52pub fn to_sql_refs(params: &[Box<dyn rusqlite::types::ToSql>]) -> Vec<&dyn rusqlite::types::ToSql> {
53 params.iter().map(|b| b.as_ref()).collect()
54}
55
56pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
57 if s.len() <= max_bytes {
58 return s;
59 }
60 let mut end = max_bytes;
61 while end > 0 && !s.is_char_boundary(end) {
62 end -= 1;
63 }
64 &s[..end]
65}
66
67pub fn canonical_project_path(cwd: &str) -> PathBuf {
68 crate::project_id::canonical_project_path(cwd)
69}
70
71pub fn project_from_cwd(cwd: &str) -> String {
72 crate::project_id::project_from_cwd(cwd)
73}
74
75pub fn absolute_data_dir() -> Result<PathBuf> {
76 let path = super::data_dir::try_data_dir()?;
77 if path.is_absolute() {
78 return Ok(path);
79 }
80 Ok(std::env::current_dir()
81 .context("read current directory for relative REMEM_DATA_DIR")?
82 .join(path))
83}
84
85#[cfg(test)]
86pub fn db_path() -> PathBuf {
87 super::data_dir::data_dir().join("remem.db")
88}
89
90pub fn try_db_path() -> Result<PathBuf> {
91 Ok(super::data_dir::try_data_dir()?.join("remem.db"))
92}
93
94pub fn open_db() -> Result<Connection> {
95 let path = try_db_path()?;
96 let key = super::crypto::require_cipher_key_or_plaintext_override()?;
97 if let Some(parent) = path.parent() {
98 std::fs::create_dir_all(parent)?;
99 #[cfg(unix)]
100 {
101 use std::os::unix::fs::PermissionsExt;
102 let perms = std::fs::Permissions::from_mode(0o700);
103 if let Err(e) = std::fs::set_permissions(parent, perms) {
104 crate::log::warn("db", &format!("cannot set data dir permissions: {}", e));
105 }
106 }
107 }
108
109 let conn = open_configured_connection(&path, key.as_ref())?;
110 crate::retrieval::vector::load_vec_extension(&conn)?;
111 crate::migrate::run_migrations(&conn)?;
112 crate::retrieval::vector::ensure_vec_table(&conn)?;
113 advance_vec_index(&conn);
114 crate::memory::retrieval_enrichment::enforce_binary_policy_floor(&conn)?;
115 Ok(conn)
116}
117
118fn advance_vec_index(conn: &Connection) {
122 if let Err(error) = crate::retrieval::vector::ensure_vec_index(conn) {
123 crate::log::error(
124 "db",
125 &format!("vec index maintenance failed; brute-force vector scan remains: {error:#}"),
126 );
127 }
128}
129
130pub fn open_db_no_migrate() -> Result<Connection> {
131 let path = try_db_path()?;
132 let key = super::crypto::require_cipher_key_or_plaintext_override()?;
133 if !path.exists() {
134 anyhow::bail!("database not found: {}", path.display());
135 }
136
137 let conn = open_configured_existing_read_write_connection(&path, key.as_ref())?;
138 crate::retrieval::vector::load_vec_extension(&conn)?;
139 crate::migrate::ensure_schema_current(&conn)?;
140 advance_vec_index(&conn);
141 crate::memory::retrieval_enrichment::enforce_binary_policy_floor(&conn)?;
142 Ok(conn)
143}
144
145pub fn open_db_for_hook() -> Result<Connection> {
146 let conn = open_db_no_migrate().context(
147 "hook database open requires an existing current schema without drift; run `remem install` outside the hook path",
148 )?;
149 Ok(conn)
150}
151
152pub fn open_db_read_only() -> Result<Connection> {
153 let path = try_db_path()?;
154 let key = super::crypto::require_cipher_key_or_plaintext_override()?;
155 if !path.exists() {
156 anyhow::bail!("database not found: {}", path.display());
157 }
158
159 open_configured_read_only_connection(&path, key.as_ref())
160}
161
162pub fn open_db_read_only_current() -> Result<Connection> {
165 let conn = open_db_read_only()?;
166 crate::migrate::ensure_schema_current(&conn)?;
167 Ok(conn)
168}
169
170pub(crate) fn open_configured_connection(
171 path: &Path,
172 key: Option<&super::crypto::CipherKey>,
173) -> Result<Connection> {
174 let pragmas = ConnectionPragmas::from_env()?;
175 let conn = Connection::open(path)
176 .with_context(|| format!("Failed to open database: {}", path.display()))?;
177 #[cfg(test)]
178 record_configured_connection_open();
179
180 super::crypto::configure_cipher(&conn, key)?;
181
182 pragmas.apply(&conn, ConnectionMode::ReadWrite)?;
183 Ok(conn)
184}
185
186pub(crate) fn open_configured_existing_read_write_connection(
187 path: &Path,
188 key: Option<&super::crypto::CipherKey>,
189) -> Result<Connection> {
190 let pragmas = ConnectionPragmas::from_env()?;
191 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE).with_context(
192 || {
193 format!(
194 "Failed to open existing database read-write: {}",
195 path.display()
196 )
197 },
198 )?;
199 #[cfg(test)]
200 record_configured_connection_open();
201
202 super::crypto::configure_cipher(&conn, key)?;
203
204 pragmas.apply(&conn, ConnectionMode::ReadWrite)?;
205 Ok(conn)
206}
207
208pub(crate) fn open_configured_read_only_connection(
209 path: &Path,
210 key: Option<&super::crypto::CipherKey>,
211) -> Result<Connection> {
212 let pragmas = ConnectionPragmas::from_env()?;
213 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
214 .with_context(|| format!("Failed to open database read-only: {}", path.display()))?;
215 #[cfg(test)]
216 record_configured_connection_open();
217
218 super::crypto::configure_cipher(&conn, key)?;
219 pragmas.apply(&conn, ConnectionMode::ReadOnly)?;
220 Ok(conn)
221}
222
223pub fn detect_git_branch(cwd: &str) -> Option<String> {
224 let output =
225 crate::git_util::git_output_soft(Path::new(cwd), &["rev-parse", "--abbrev-ref", "HEAD"])?;
226 if !output.status.success() {
227 return None;
228 }
229 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
230 if branch.is_empty() || branch == "HEAD" {
231 None
232 } else {
233 Some(branch)
234 }
235}
236
237pub fn detect_git_commit(cwd: &str) -> Option<String> {
238 let output =
239 crate::git_util::git_output_soft(Path::new(cwd), &["rev-parse", "--short", "HEAD"])?;
240 if !output.status.success() {
241 return None;
242 }
243 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
244 if sha.is_empty() {
245 None
246 } else {
247 Some(sha)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use rusqlite::{params, Connection};
254
255 use super::*;
256 use crate::db::crypto::ALLOW_PLAINTEXT_ENV;
257 use crate::db::test_support::ScopedTestDataDir;
258
259 #[test]
260 fn content_identity_hash_is_versioned_sha256() {
261 let hash = content_identity_hash(b"hello world");
262
263 assert_eq!(
264 hash,
265 "sha256:content-v1:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
266 );
267 let legacy_hash = legacy_content_identity_hash(b"hello world");
268 assert_eq!(legacy_hash.len(), 16);
269 assert!(!legacy_hash.starts_with("sha256:"));
270 }
271
272 #[test]
273 fn open_db_for_hook_does_not_create_missing_database() {
274 let test_dir = ScopedTestDataDir::new("hook-open-missing");
275 test_dir.remove_db_files();
276
277 let err = open_db_for_hook().expect_err("missing database should fail");
278
279 let message = err.to_string();
280 assert!(
281 message.contains("hook database open requires"),
282 "unexpected error: {message}"
283 );
284 assert!(
285 !test_dir.path.exists(),
286 "hook open must not create data dir"
287 );
288 assert!(
289 !test_dir.db_path().exists(),
290 "hook open must not create database file"
291 );
292 }
293
294 #[test]
295 fn open_db_for_hook_opens_current_schema_read_write() -> Result<()> {
296 let _test_dir = ScopedTestDataDir::new("hook-open-current-rw");
297 let setup = crate::db::open_db()?;
298 drop(setup);
299
300 let conn = crate::db::open_db_for_hook()?;
301 conn.execute("CREATE TABLE hook_rw_probe(id INTEGER PRIMARY KEY)", [])?;
302 conn.execute("INSERT INTO hook_rw_probe(id) VALUES (1)", [])?;
303 let count: i64 =
304 conn.query_row("SELECT COUNT(*) FROM hook_rw_probe", [], |row| row.get(0))?;
305
306 assert_eq!(count, 1);
307 Ok(())
308 }
309
310 #[test]
311 fn open_db_for_hook_rejects_older_schema_without_migrating() -> Result<()> {
312 let _test_dir = ScopedTestDataDir::new("hook-open-older-schema");
313 let setup = crate::db::open_db()?;
314 let latest = crate::migrate::latest_schema_version();
315 setup.execute(
316 "DELETE FROM _schema_migrations WHERE version = ?1",
317 [latest],
318 )?;
319 drop(setup);
320
321 let err = crate::db::open_db_for_hook()
322 .expect_err("older schema should require foreground migration");
323
324 assert!(
325 err.to_string().contains("hook database open requires"),
326 "unexpected error: {err:#}"
327 );
328 let check = Connection::open(crate::db::db_path())?;
329 let latest_rows: i64 = check.query_row(
330 "SELECT COUNT(*) FROM _schema_migrations WHERE version = ?1",
331 [latest],
332 |row| row.get(0),
333 )?;
334 assert_eq!(latest_rows, 0);
335 Ok(())
336 }
337
338 #[test]
339 fn open_db_for_hook_rejects_schema_drift_without_repairing() -> Result<()> {
340 let test_dir = ScopedTestDataDir::new("hook-open-schema-drift");
341 create_current_schema_missing_migration(&test_dir.db_path(), 22)?;
342
343 let err = crate::db::open_db_for_hook().expect_err("schema drift should fail closed");
344
345 assert!(
346 format!("{err:#}").contains("schema drift requires foreground migration"),
347 "unexpected error: {err:#}"
348 );
349 assert!(
350 format!("{err:#}").contains("schema drift"),
351 "unexpected error: {err:#}"
352 );
353 let check = Connection::open(test_dir.db_path())?;
354 let state_keys_exists: i64 = check.query_row(
355 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'memory_state_keys'",
356 [],
357 |row| row.get(0),
358 )?;
359 assert_eq!(state_keys_exists, 0);
360 Ok(())
361 }
362
363 #[test]
364 fn open_db_no_migrate_rejects_post_v022_schema_drift_without_repairing() -> Result<()> {
365 let test_dir = ScopedTestDataDir::new("no-migrate-post-v022-schema-drift");
366 create_current_schema_missing_migration(&test_dir.db_path(), 45)?;
367
368 let err = crate::db::open_db_no_migrate().expect_err("schema drift should fail closed");
369
370 let message = format!("{err:#}");
371 assert!(
372 message.contains("schema drift requires foreground migration"),
373 "unexpected error: {message}"
374 );
375 assert!(
376 message.contains("v045_memory_usage_columns"),
377 "unexpected error: {message}"
378 );
379 let check = Connection::open(test_dir.db_path())?;
380 let usage_table_exists: i64 = check.query_row(
381 "SELECT COUNT(*) FROM sqlite_master
382 WHERE type = 'table' AND name = 'memory_citation_events'",
383 [],
384 |row| row.get(0),
385 )?;
386 assert_eq!(usage_table_exists, 0);
387 Ok(())
388 }
389
390 #[test]
391 fn open_db_read_only_does_not_create_missing_database() {
392 let test_dir = ScopedTestDataDir::new("readonly-missing");
393 test_dir.remove_db_files();
394
395 let err = open_db_read_only().expect_err("missing database should fail");
396
397 let message = err.to_string();
398 assert!(
399 message.contains("database not found"),
400 "unexpected error: {message}"
401 );
402 assert!(
403 !test_dir.path.exists(),
404 "read-only open must not create data dir"
405 );
406 assert!(
407 !test_dir.db_path().exists(),
408 "read-only open must not create database file"
409 );
410 }
411
412 #[test]
413 fn open_db_read_only_refuses_plaintext_without_explicit_override() -> Result<()> {
414 let test_dir = ScopedTestDataDir::new("readonly-cipher-fail-closed");
415 let conn = crate::db::open_db()?;
416 drop(conn);
417 std::env::remove_var(ALLOW_PLAINTEXT_ENV);
418
419 let err = crate::db::open_db_read_only()
420 .expect_err("read-only open must enforce the plaintext guard");
421
422 let message = err.to_string();
423 assert!(message.contains("SQLCipher key"), "got: {message}");
424 assert!(
425 test_dir.db_path().exists(),
426 "read-only guard must not remove the existing database"
427 );
428 Ok(())
429 }
430
431 #[test]
432 fn open_db_does_not_backfill_missing_vector_embeddings() -> Result<()> {
433 let _test_dir = ScopedTestDataDir::new("open-no-vector-backfill");
434 let conn = crate::db::open_db()?;
435 conn.execute(
436 "INSERT INTO memories
437 (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch, status)
438 VALUES (1, '/repo', 'Vector row', 'Open must not backfill this row.', 'decision', 1, 1, 'active')",
439 [],
440 )?;
441 drop(conn);
442
443 let reopened = crate::db::open_db()?;
444 let count: i64 =
445 reopened.query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
446 row.get(0)
447 })?;
448 assert_eq!(count, 0);
449 assert_eq!(
450 crate::retrieval::vector::backfill_missing_memory_embeddings(&reopened, 10)?,
451 1
452 );
453 Ok(())
454 }
455
456 #[test]
457 fn open_db_read_only_does_not_run_migrations() -> Result<()> {
458 let _test_dir = ScopedTestDataDir::new("readonly-no-migration");
459 let path = crate::db::db_path();
460 std::fs::create_dir_all(crate::db::data_dir())?;
461 let conn = Connection::open(&path)?;
462 conn.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", [])?;
463 drop(conn);
464
465 let readonly = crate::db::open_db_read_only()?;
466 let marker_exists: i64 = readonly.query_row(
467 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'marker'",
468 [],
469 |row| row.get(0),
470 )?;
471 let migrations_exists: i64 = readonly.query_row(
472 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'",
473 [],
474 |row| row.get(0),
475 )?;
476
477 assert_eq!(marker_exists, 1);
478 assert_eq!(migrations_exists, 0);
479 Ok(())
480 }
481
482 #[test]
483 fn open_db_read_only_current_works_while_writer_holds_immediate_lock() -> Result<()> {
484 let _test_dir = ScopedTestDataDir::new("readonly-current-write-lock");
485 let writer = crate::db::open_db()?;
486 writer.execute_batch("BEGIN IMMEDIATE")?;
487
488 let readonly = crate::db::open_db_read_only_current()?;
489 let latest: i64 =
490 readonly.query_row("SELECT MAX(version) FROM _schema_migrations", [], |row| {
491 row.get(0)
492 })?;
493
494 assert_eq!(latest, crate::migrate::latest_schema_version());
495 writer.execute_batch("ROLLBACK")?;
496 Ok(())
497 }
498
499 #[test]
500 fn open_db_read_only_current_rejects_stale_schema_without_writes() -> Result<()> {
501 let _test_dir = ScopedTestDataDir::new("readonly-current-stale");
502 let setup = crate::db::open_db()?;
503 let latest = crate::migrate::latest_schema_version();
504 setup.execute(
505 "DELETE FROM _schema_migrations WHERE version = ?1",
506 [latest],
507 )?;
508 drop(setup);
509
510 let err =
511 crate::db::open_db_read_only_current().expect_err("stale schema must fail closed");
512 assert!(
513 err.to_string().contains("run a foreground remem command"),
514 "unexpected error: {err:#}"
515 );
516
517 let check = Connection::open(crate::db::db_path())?;
518 let rows: i64 = check.query_row(
519 "SELECT COUNT(*) FROM _schema_migrations WHERE version = ?1",
520 [latest],
521 |row| row.get(0),
522 )?;
523 assert_eq!(rows, 0);
524 Ok(())
525 }
526
527 #[test]
528 fn open_db_no_migrate_does_not_create_missing_database() {
529 let test_dir = ScopedTestDataDir::new("no-migrate-missing");
530 test_dir.remove_db_files();
531
532 let err = open_db_no_migrate().expect_err("missing database should fail");
533
534 let message = err.to_string();
535 assert!(
536 message.contains("database not found"),
537 "unexpected error: {message}"
538 );
539 assert!(
540 !test_dir.path.exists(),
541 "no-migrate open must not create data dir"
542 );
543 assert!(
544 !test_dir.db_path().exists(),
545 "no-migrate open must not create database file"
546 );
547 }
548
549 #[test]
550 fn open_db_no_migrate_refuses_plaintext_without_explicit_override() -> Result<()> {
551 let test_dir = ScopedTestDataDir::new("no-migrate-cipher-fail-closed");
552 let conn = crate::db::open_db()?;
553 drop(conn);
554 std::env::remove_var(ALLOW_PLAINTEXT_ENV);
555
556 let err = crate::db::open_db_no_migrate()
557 .expect_err("no-migrate open must enforce the plaintext guard");
558
559 let message = err.to_string();
560 assert!(message.contains("SQLCipher key"), "got: {message}");
561 assert!(
562 test_dir.db_path().exists(),
563 "no-migrate guard must not remove the existing database"
564 );
565 Ok(())
566 }
567
568 #[test]
569 fn open_db_no_migrate_opens_current_schema_read_write() -> Result<()> {
570 let _test_dir = ScopedTestDataDir::new("no-migrate-current-rw");
571 let setup = crate::db::open_db()?;
572 drop(setup);
573
574 let conn = crate::db::open_db_no_migrate()?;
575 conn.execute(
576 "CREATE TABLE no_migrate_rw_probe(id INTEGER PRIMARY KEY)",
577 [],
578 )?;
579 conn.execute("INSERT INTO no_migrate_rw_probe(id) VALUES (1)", [])?;
580 let count: i64 = conn.query_row("SELECT COUNT(*) FROM no_migrate_rw_probe", [], |row| {
581 row.get(0)
582 })?;
583
584 assert_eq!(count, 1);
585 Ok(())
586 }
587
588 #[test]
589 fn open_db_no_migrate_does_not_run_migrations() -> Result<()> {
590 let test_dir = ScopedTestDataDir::new("no-migrate-no-migration");
591 std::fs::create_dir_all(&test_dir.path)?;
592 let setup = Connection::open(test_dir.db_path())?;
593 setup.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", [])?;
594 drop(setup);
595
596 let err = crate::db::open_db_no_migrate().expect_err("stale schema should fail");
597
598 assert!(
599 err.to_string().contains("schema is not initialized"),
600 "unexpected error: {err:#}"
601 );
602 let check = Connection::open(test_dir.db_path())?;
603 let migrations_exists: i64 = check.query_row(
604 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_schema_migrations'",
605 [],
606 |row| row.get(0),
607 )?;
608 assert_eq!(migrations_exists, 0);
609 Ok(())
610 }
611
612 #[test]
613 fn open_db_no_migrate_rejects_older_schema_without_migrating() -> Result<()> {
614 let _test_dir = ScopedTestDataDir::new("no-migrate-older-schema");
615 let setup = crate::db::open_db()?;
616 let latest = crate::migrate::latest_schema_version();
617 setup.execute(
618 "DELETE FROM _schema_migrations WHERE version = ?1",
619 [latest],
620 )?;
621 drop(setup);
622
623 let err = crate::db::open_db_no_migrate()
624 .expect_err("older schema should require foreground migration");
625
626 assert!(
627 err.to_string().contains("requires schema"),
628 "unexpected error: {err:#}"
629 );
630 let check = Connection::open(crate::db::db_path())?;
631 let latest_rows: i64 = check.query_row(
632 "SELECT COUNT(*) FROM _schema_migrations WHERE version = ?1",
633 [latest],
634 |row| row.get(0),
635 )?;
636 assert_eq!(latest_rows, 0);
637 Ok(())
638 }
639
640 #[test]
641 fn open_db_no_migrate_rejects_incomplete_schema_without_migrating() -> Result<()> {
642 let _test_dir = ScopedTestDataDir::new("no-migrate-incomplete-schema");
643 let setup = crate::db::open_db()?;
644 let latest = crate::migrate::latest_schema_version();
645 let missing = crate::migrate::MIGRATIONS
646 .iter()
647 .rev()
648 .find(|migration| migration.version < latest)
649 .expect("test requires at least two migrations")
650 .version;
651 setup.execute(
652 "DELETE FROM _schema_migrations WHERE version = ?1",
653 [missing],
654 )?;
655 drop(setup);
656
657 let err = crate::db::open_db_no_migrate()
658 .expect_err("incomplete schema should require foreground migration");
659
660 assert!(
661 err.to_string().contains("missing migration"),
662 "unexpected error: {err:#}"
663 );
664 let check = Connection::open(crate::db::db_path())?;
665 let (missing_rows, latest_rows): (i64, i64) = check.query_row(
666 "SELECT
667 SUM(CASE WHEN version = ?1 THEN 1 ELSE 0 END),
668 SUM(CASE WHEN version = ?2 THEN 1 ELSE 0 END)
669 FROM _schema_migrations",
670 (missing, latest),
671 |row| Ok((row.get(0)?, row.get(1)?)),
672 )?;
673 assert_eq!(missing_rows, 0);
674 assert_eq!(latest_rows, 1);
675 Ok(())
676 }
677
678 #[test]
679 fn open_db_no_migrate_rejects_newer_schema_without_migrating() -> Result<()> {
680 let _test_dir = ScopedTestDataDir::new("no-migrate-newer-schema");
681 let setup = crate::db::open_db()?;
682 let latest = crate::migrate::latest_schema_version();
683 setup.execute(
684 "INSERT INTO _schema_migrations (version, name, applied_at_epoch)
685 VALUES (?1, 'future-test', 0)",
686 [latest + 1],
687 )?;
688 drop(setup);
689
690 let err = crate::db::open_db_no_migrate().expect_err("newer schema should fail closed");
691
692 assert!(
693 err.to_string().contains("only knows up to"),
694 "unexpected error: {err:#}"
695 );
696 Ok(())
697 }
698
699 #[test]
700 fn open_db_read_only_opens_existing_database_without_write_access() -> Result<()> {
701 let test_dir = ScopedTestDataDir::new("readonly-existing");
702 std::fs::create_dir_all(&test_dir.path)?;
703 let setup = Connection::open(test_dir.db_path())?;
704 setup.execute_batch(
705 "CREATE TABLE readonly_probe(id INTEGER PRIMARY KEY);
706 INSERT INTO readonly_probe(id) VALUES (1);",
707 )?;
708 drop(setup);
709
710 let conn = open_db_read_only()?;
711 let count: i64 =
712 conn.query_row("SELECT COUNT(*) FROM readonly_probe", [], |row| row.get(0))?;
713 assert_eq!(count, 1);
714
715 let err = conn
716 .execute("INSERT INTO readonly_probe(id) VALUES (2)", [])
717 .expect_err("read-only connection must reject writes");
718 assert_eq!(err.sqlite_error_code(), Some(rusqlite::ErrorCode::ReadOnly));
719 Ok(())
720 }
721
722 fn create_current_schema_missing_migration(path: &Path, missing_version: i64) -> Result<()> {
723 if let Some(parent) = path.parent() {
724 std::fs::create_dir_all(parent)?;
725 }
726 let conn = Connection::open(path)?;
727 conn.execute_batch(
728 "PRAGMA journal_mode=WAL;
729 PRAGMA foreign_keys=OFF;
730 PRAGMA writable_schema=ON;",
731 )?;
732 for migration in crate::migrate::MIGRATIONS
733 .iter()
734 .filter(|migration| migration.version != missing_version)
735 {
736 conn.execute_batch(migration.sql)?;
737 }
738 conn.execute_batch(
739 "CREATE TABLE _schema_migrations (
740 version INTEGER PRIMARY KEY,
741 name TEXT NOT NULL,
742 applied_at_epoch INTEGER NOT NULL
743 );",
744 )?;
745 for migration in crate::migrate::MIGRATIONS {
746 conn.execute(
747 "INSERT INTO _schema_migrations (version, name, applied_at_epoch)
748 VALUES (?1, ?2, 1700000000)",
749 params![migration.version, migration.name],
750 )?;
751 }
752 conn.execute_batch(&format!(
753 "PRAGMA writable_schema=OFF;
754 PRAGMA user_version = {};
755 PRAGMA foreign_keys=ON;",
756 crate::migrate::latest_schema_version()
757 ))?;
758 Ok(())
759 }
760}