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