1use std::path::{Path, PathBuf};
2
3use anyhow::{ensure, Context, Result};
4use rusqlite::Connection;
5
6pub(crate) const ALLOW_PLAINTEXT_ENV: &str = "REMEM_ALLOW_PLAINTEXT_DB";
7const RAW_KEY_PREFIX: &str = "v2:";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum CipherKey {
11 Raw(String),
12 Passphrase(String),
13}
14
15impl CipherKey {
16 pub(crate) fn stored_value(&self) -> String {
17 match self {
18 CipherKey::Raw(hex) => format!("{RAW_KEY_PREFIX}{hex}"),
19 CipherKey::Passphrase(value) => value.clone(),
20 }
21 }
22}
23
24pub(crate) fn parse_cipher_key(input: &str) -> Result<Option<CipherKey>> {
25 let key = input.trim();
26 if key.is_empty() {
27 return Ok(None);
28 }
29 if let Some(hex) = key.strip_prefix(RAW_KEY_PREFIX) {
30 validate_raw_key_hex(hex)?;
31 return Ok(Some(CipherKey::Raw(hex.to_string())));
32 }
33 Ok(Some(CipherKey::Passphrase(key.to_string())))
34}
35
36pub(crate) fn load_cipher_key() -> Result<Option<CipherKey>> {
37 if let Ok(key) = std::env::var("REMEM_CIPHER_KEY") {
38 if !key.is_empty() {
39 return parse_cipher_key(&key).context("parse REMEM_CIPHER_KEY");
40 }
41 }
42
43 let key_path = super::data_dir::try_data_dir()?.join(".key");
44 if key_path.exists() {
45 let key = std::fs::read_to_string(&key_path)
46 .with_context(|| format!("read SQLCipher key file {}", key_path.display()))?;
47 if let Some(parsed) = parse_cipher_key(&key)
48 .with_context(|| format!("parse SQLCipher key file {}", key_path.display()))?
49 {
50 return Ok(Some(parsed));
51 }
52 }
53 Ok(None)
54}
55
56pub(crate) fn plaintext_db_allowed() -> bool {
57 std::env::var(ALLOW_PLAINTEXT_ENV).as_deref() == Ok("1")
58}
59
60pub(crate) fn require_cipher_key_or_plaintext_override() -> Result<Option<CipherKey>> {
61 let key = load_cipher_key()?;
62 if key.is_none() && !plaintext_db_allowed() {
63 anyhow::bail!(
64 "refusing to open remem database without a SQLCipher key; run `remem encrypt` to create a key and encrypted database, or set {ALLOW_PLAINTEXT_ENV}=1 to explicitly allow an unencrypted database"
65 );
66 }
67 Ok(key)
68}
69
70pub(crate) fn configure_cipher(conn: &Connection, key: Option<&CipherKey>) -> Result<bool> {
71 if let Some(key) = key {
72 apply_cipher_key(conn, key)?;
73 if !can_read_schema(conn) {
74 anyhow::bail!("SQLCipher key was applied but the database schema is unreadable");
75 }
76 return Ok(true);
77 }
78
79 crate::log::error(
80 "db",
81 &format!("opening unencrypted remem database because {ALLOW_PLAINTEXT_ENV}=1 is set"),
82 );
83 Ok(false)
84}
85
86pub(crate) fn apply_cipher_key_if_available(conn: &Connection) -> Result<bool> {
87 if let Some(key) = load_cipher_key()? {
88 apply_cipher_key(conn, &key)?;
89 return Ok(true);
90 }
91 Ok(false)
92}
93
94pub(crate) fn apply_cipher_key(conn: &Connection, key: &CipherKey) -> Result<()> {
95 match key {
96 CipherKey::Raw(hex) => apply_raw_key_pragma(conn, "key", hex),
97 CipherKey::Passphrase(passphrase) => conn
98 .pragma_update(None, "key", passphrase)
99 .map_err(Into::into),
100 }
101}
102
103pub(crate) fn rekey_connection_to_raw(conn: &Connection, hex: &str) -> Result<()> {
104 apply_raw_key_pragma(conn, "rekey", hex)
105}
106
107fn apply_raw_key_pragma(conn: &Connection, pragma: &str, hex: &str) -> Result<()> {
108 validate_raw_key_hex(hex)?;
109 conn.execute_batch(&format!("PRAGMA {pragma} = \"x'{hex}'\";"))?;
110 Ok(())
111}
112
113pub(crate) fn legacy_passphrase_to_raw_hex(passphrase: &str) -> Result<&str> {
114 validate_raw_key_hex(passphrase)?;
115 Ok(passphrase)
116}
117
118fn validate_raw_key_hex(hex: &str) -> Result<()> {
119 if hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
120 return Ok(());
121 }
122 anyhow::bail!("raw SQLCipher key must be exactly 64 hex characters");
123}
124
125pub(crate) fn can_read_schema(conn: &Connection) -> bool {
126 conn.query_row("SELECT COUNT(*) FROM sqlite_master", [], |row| {
127 row.get::<_, i64>(0)
128 })
129 .is_ok()
130}
131
132pub fn generate_cipher_key() -> Result<String> {
133 generate_cipher_key_with(getrandom::fill)
134}
135
136fn generate_cipher_key_with<F>(fill_random: F) -> Result<String>
137where
138 F: FnOnce(&mut [u8]) -> std::result::Result<(), getrandom::Error>,
139{
140 use std::io::Write;
141
142 let mut key_bytes = [0u8; 32];
143 fill_random(&mut key_bytes).map_err(|e| {
144 anyhow::anyhow!(
145 "OS randomness unavailable while generating cipher key: {}",
146 e
147 )
148 })?;
149 let key: String = key_bytes
150 .iter()
151 .map(|byte| format!("{:02x}", byte))
152 .collect();
153
154 let data_dir = super::data_dir::try_data_dir()?;
155 std::fs::create_dir_all(&data_dir)?;
156 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159 let dir_perms = std::fs::Permissions::from_mode(0o700);
160 std::fs::set_permissions(&data_dir, dir_perms).map_err(|e| {
161 anyhow::anyhow!(
162 "cannot set data dir permissions to 0700 ({}): {}",
163 data_dir.display(),
164 e
165 )
166 })?;
167 }
168
169 let key_path = data_dir.join(".key");
170
171 #[cfg(unix)]
172 let mut file = {
173 use std::os::unix::fs::OpenOptionsExt;
174 std::fs::OpenOptions::new()
175 .mode(0o600)
176 .create_new(true)
177 .write(true)
178 .open(&key_path)
179 .map_err(|e| {
180 anyhow::anyhow!(
181 "cannot create cipher key file at {}: {}",
182 key_path.display(),
183 e
184 )
185 })?
186 };
187
188 #[cfg(not(unix))]
189 let mut file = std::fs::OpenOptions::new()
190 .create_new(true)
191 .write(true)
192 .open(&key_path)
193 .map_err(|e| {
194 anyhow::anyhow!(
195 "cannot create cipher key file at {}: {}",
196 key_path.display(),
197 e
198 )
199 })?;
200
201 if let Err(e) = file.write_all(CipherKey::Raw(key.clone()).stored_value().as_bytes()) {
202 drop(file);
203 let _ = std::fs::remove_file(&key_path);
204 return Err(anyhow::anyhow!(
205 "failed to write cipher key to {}: {}",
206 key_path.display(),
207 e
208 ));
209 }
210
211 #[cfg(unix)]
212 {
213 use std::os::unix::fs::PermissionsExt;
214 let file_perms = std::fs::Permissions::from_mode(0o600);
215 if let Err(e) = std::fs::set_permissions(&key_path, file_perms) {
216 drop(file);
217 let _ = std::fs::remove_file(&key_path);
218 return Err(anyhow::anyhow!(
219 "cannot enforce 0600 on cipher key file {}: {} (key file removed)",
220 key_path.display(),
221 e
222 ));
223 }
224 }
225
226 Ok(key)
227}
228
229pub fn encrypt_database(key: &CipherKey) -> Result<()> {
230 let db_file = super::core::try_db_path()?;
231 if !db_file.exists() {
232 anyhow::bail!("database not found: {}", db_file.display());
233 }
234
235 let encrypted_path = db_file.with_extension("db.enc");
236 let backup_path = db_file.with_extension("db.bak");
237 ensure!(
238 !encrypted_path.exists(),
239 "temporary encrypted database already exists at {}; move it aside before retrying encryption",
240 encrypted_path.display()
241 );
242 ensure!(
243 !backup_path.exists(),
244 "temporary plaintext migration backup already exists at {}; move it aside before retrying encryption",
245 backup_path.display()
246 );
247 let encrypted_path_str = encrypted_path.to_str().ok_or_else(|| {
248 anyhow::anyhow!(
249 "encrypted database path is not valid UTF-8: {}",
250 encrypted_path.display()
251 )
252 })?;
253 if encrypted_path_str.contains('\0') {
254 anyhow::bail!(
255 "encrypted database path contains a NUL byte: {}",
256 encrypted_path.display()
257 );
258 }
259 let conn = Connection::open(&db_file)?;
260 conn.execute_batch(
264 "PRAGMA foreign_keys=ON;
265 PRAGMA busy_timeout=5000;",
266 )?;
267 checkpoint_plaintext_wal_before_export(&conn)?;
268 let attach_key = attach_key_sql(key)?;
269 conn.execute(
270 &format!(
271 "ATTACH DATABASE '{}' AS encrypted KEY {}",
272 encrypted_path_str.replace('\'', "''"),
273 attach_key
274 ),
275 [],
276 )?;
277 conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))?;
278 conn.execute("DETACH DATABASE encrypted", [])?;
279 drop(conn);
280
281 remove_plaintext_sidecars_before_swap(&db_file)?;
282 std::fs::rename(&db_file, &backup_path).with_context(|| {
283 format!(
284 "move plaintext database {} to temporary migration backup {}",
285 db_file.display(),
286 backup_path.display()
287 )
288 })?;
289 if let Err(error) = std::fs::rename(&encrypted_path, &db_file) {
290 restore_plaintext_db_after_encrypt_failure(&backup_path, &db_file, error)?;
291 }
292 remove_plaintext_migration_backup(&backup_path)?;
293
294 crate::log::info(
295 "encrypt",
296 "database encrypted; plaintext migration backup removed",
297 );
298 Ok(())
299}
300
301fn restore_plaintext_db_after_encrypt_failure(
302 backup_path: &Path,
303 db_file: &Path,
304 install_error: std::io::Error,
305) -> Result<()> {
306 std::fs::rename(backup_path, db_file).with_context(|| {
307 format!(
308 "install encrypted database failed ({install_error}); also failed to restore plaintext database {} from {}",
309 db_file.display(),
310 backup_path.display()
311 )
312 })?;
313 Err(install_error).with_context(|| {
314 format!(
315 "install encrypted database at {}; plaintext database was restored",
316 db_file.display()
317 )
318 })
319}
320
321pub(crate) fn rollback_generated_key_after_encrypt_failure(
322 key_path: &Path,
323 generated_key: &CipherKey,
324 db_path: &Path,
325 encrypted_existed_before: bool,
326 backup_existed_before: bool,
327) -> Result<()> {
328 let mut errors = Vec::new();
329 let encrypted_path = db_path.with_extension("db.enc");
330 let backup_path = db_path.with_extension("db.bak");
331
332 if !db_path.exists() && !backup_existed_before && backup_path.exists() {
333 if let Err(error) = std::fs::rename(&backup_path, db_path) {
334 errors.push(format!(
335 "restore {} from {}: {}",
336 db_path.display(),
337 backup_path.display(),
338 error
339 ));
340 }
341 }
342 if !encrypted_existed_before && encrypted_path.exists() {
343 if let Err(error) = std::fs::remove_file(&encrypted_path) {
344 errors.push(format!("remove {}: {}", encrypted_path.display(), error));
345 }
346 }
347
348 match std::fs::read_to_string(key_path) {
349 Ok(contents) if contents == generated_key.stored_value() => {
350 if generated_key_should_be_kept_after_encrypt_failure(db_path)? {
351 return finish_generated_key_rollback(errors);
352 }
353 if let Err(error) = std::fs::remove_file(key_path) {
354 errors.push(format!("remove {}: {}", key_path.display(), error));
355 }
356 }
357 Ok(_) => errors.push(format!(
358 "leave {} because its contents changed after generation",
359 key_path.display()
360 )),
361 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
362 Err(error) => errors.push(format!("read {}: {}", key_path.display(), error)),
363 }
364
365 finish_generated_key_rollback(errors)
366}
367
368fn generated_key_should_be_kept_after_encrypt_failure(db_path: &Path) -> Result<bool> {
369 let mut file = match std::fs::File::open(db_path) {
370 Ok(file) => file,
371 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
372 Err(error) => {
373 return Err(error)
374 .with_context(|| format!("inspect remem database {}", db_path.display()));
375 }
376 };
377 let mut header = [0_u8; 16];
378 match std::io::Read::read_exact(&mut file, &mut header) {
379 Ok(()) => Ok(&header != b"SQLite format 3\0"),
380 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(true),
381 Err(error) => {
382 Err(error).with_context(|| format!("read remem database {}", db_path.display()))
383 }
384 }
385}
386
387fn finish_generated_key_rollback(errors: Vec<String>) -> Result<()> {
388 if errors.is_empty() {
389 Ok(())
390 } else {
391 anyhow::bail!("{}", errors.join("; "))
392 }
393}
394
395fn remove_plaintext_migration_backup(backup_path: &Path) -> Result<()> {
396 std::fs::remove_file(backup_path).with_context(|| {
397 format!(
398 "remove temporary plaintext migration backup {}",
399 backup_path.display()
400 )
401 })?;
402 Ok(())
403}
404
405fn checkpoint_plaintext_wal_before_export(conn: &Connection) -> Result<()> {
406 let (busy, log_pages, checkpointed_pages) =
407 conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
408 Ok((
409 row.get::<_, i64>(0)?,
410 row.get::<_, i64>(1)?,
411 row.get::<_, i64>(2)?,
412 ))
413 })?;
414 ensure!(
415 busy == 0 && log_pages == checkpointed_pages,
416 "plaintext SQLite WAL checkpoint incomplete before encryption \
417 (busy={busy}, log_pages={log_pages}, checkpointed_pages={checkpointed_pages}); \
418 close other remem processes and retry"
419 );
420 Ok(())
421}
422
423fn remove_plaintext_sidecars_before_swap(db_file: &Path) -> Result<()> {
424 let mut removable_sidecars = Vec::new();
425 for sidecar in sqlite_sidecar_paths(db_file)? {
426 match std::fs::symlink_metadata(&sidecar) {
427 Ok(metadata) if metadata.is_file() => {
428 removable_sidecars.push(sidecar);
429 }
430 Ok(_) => {
431 anyhow::bail!(
432 "refusing to remove non-file SQLite sidecar {}; move it aside before retrying encryption",
433 sidecar.display()
434 );
435 }
436 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
437 Err(error) => {
438 return Err(error).with_context(|| {
439 format!("inspect plaintext SQLite sidecar {}", sidecar.display())
440 })
441 }
442 }
443 }
444 for sidecar in removable_sidecars {
445 std::fs::remove_file(&sidecar)
446 .with_context(|| format!("remove plaintext SQLite sidecar {}", sidecar.display()))?;
447 }
448 Ok(())
449}
450
451fn sqlite_sidecar_paths(db_file: &Path) -> Result<[PathBuf; 3]> {
452 let file_name = db_file
453 .file_name()
454 .and_then(|name| name.to_str())
455 .ok_or_else(|| anyhow::anyhow!("invalid database file path {}", db_file.display()))?;
456 Ok([
457 db_file.with_file_name(format!("{file_name}-wal")),
458 db_file.with_file_name(format!("{file_name}-shm")),
459 db_file.with_file_name(format!("{file_name}-journal")),
460 ])
461}
462
463fn attach_key_sql(key: &CipherKey) -> Result<String> {
464 match key {
465 CipherKey::Raw(hex) => {
466 validate_raw_key_hex(hex)?;
467 Ok(format!("\"x'{hex}'\""))
468 }
469 CipherKey::Passphrase(passphrase) => Ok(format!("'{}'", passphrase.replace('\'', "''"))),
470 }
471}
472
473pub(crate) fn backup_cipher_key_file(key_path: &Path) -> Result<PathBuf> {
474 let file_name = key_path
475 .file_name()
476 .and_then(|name| name.to_str())
477 .ok_or_else(|| anyhow::anyhow!("invalid SQLCipher key file path {}", key_path.display()))?;
478 let key_backup_path = key_path.with_file_name(format!("{file_name}.bak"));
479 std::fs::copy(key_path, &key_backup_path).with_context(|| {
480 format!(
481 "backup SQLCipher key file {} to {}",
482 key_path.display(),
483 key_backup_path.display()
484 )
485 })?;
486 Ok(key_backup_path)
487}
488
489pub(crate) fn write_raw_key_file(key_path: &Path, raw_hex: &str) -> Result<()> {
490 validate_raw_key_hex(raw_hex)?;
491 write_key_file_atomic(key_path, &format!("{RAW_KEY_PREFIX}{raw_hex}"))
492}
493
494fn write_key_file_atomic(path: &Path, contents: &str) -> Result<()> {
495 let file_name = path
496 .file_name()
497 .and_then(|name| name.to_str())
498 .ok_or_else(|| anyhow::anyhow!("invalid SQLCipher key file path {}", path.display()))?;
499 let tmp_path = path.with_file_name(format!(
500 ".{file_name}.tmp-{}-{}",
501 std::process::id(),
502 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
503 ));
504 {
505 use std::io::Write;
506 #[cfg(unix)]
507 let file = {
508 use std::os::unix::fs::OpenOptionsExt;
509 std::fs::OpenOptions::new()
510 .mode(0o600)
511 .create_new(true)
512 .write(true)
513 .open(&tmp_path)
514 };
515 #[cfg(not(unix))]
516 let file = std::fs::OpenOptions::new()
517 .create_new(true)
518 .write(true)
519 .open(&tmp_path);
520 let mut file =
521 file.with_context(|| format!("create temp key file {}", tmp_path.display()))?;
522 file.write_all(contents.as_bytes())
523 .with_context(|| format!("write temp key file {}", tmp_path.display()))?;
524 file.sync_all()
525 .with_context(|| format!("sync temp key file {}", tmp_path.display()))?;
526 }
527 #[cfg(unix)]
528 {
529 use std::os::unix::fs::PermissionsExt;
530 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))
531 .with_context(|| format!("set permissions on {}", tmp_path.display()))?;
532 }
533 std::fs::rename(&tmp_path, path)
534 .with_context(|| format!("replace SQLCipher key file {}", path.display()))?;
535 Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::db::test_support::ScopedTestDataDir;
542
543 #[test]
544 fn open_db_refuses_plaintext_without_explicit_override() {
545 let test_dir = ScopedTestDataDir::new("cipher-fail-closed");
546 std::env::remove_var(ALLOW_PLAINTEXT_ENV);
547
548 let err = match crate::db::open_db() {
549 Ok(_) => panic!("open_db must fail closed without a cipher key"),
550 Err(err) => err,
551 };
552
553 let message = err.to_string();
554 assert!(message.contains("SQLCipher key"), "got: {message}");
555 assert!(
556 message.contains(ALLOW_PLAINTEXT_ENV),
557 "override must be explicit: {message}"
558 );
559 assert!(
560 !test_dir.db_path().exists(),
561 "fail-closed path must not create a plaintext database"
562 );
563 }
564
565 #[test]
566 fn open_db_allows_plaintext_only_with_explicit_override() -> Result<()> {
567 let test_dir = ScopedTestDataDir::new("cipher-plaintext-override");
568
569 let conn = crate::db::open_db()?;
570 let table_count: i64 =
571 conn.query_row("SELECT COUNT(*) FROM sqlite_master", [], |row| row.get(0))?;
572 assert!(table_count > 0);
573 drop(conn);
574
575 let header = std::fs::read(test_dir.db_path())?;
576 assert_eq!(&header[..16], b"SQLite format 3\0");
577 let log = std::fs::read_to_string(test_dir.path.join("remem.log"))?;
578 assert!(log.contains("opening unencrypted remem database"));
579 Ok(())
580 }
581
582 #[test]
583 fn parse_cipher_key_distinguishes_raw_and_legacy() -> Result<()> {
584 let raw_hex = "a".repeat(64);
585 assert_eq!(
586 parse_cipher_key(&format!("v2:{raw_hex}"))?,
587 Some(CipherKey::Raw(raw_hex.clone()))
588 );
589 assert_eq!(
590 parse_cipher_key(&raw_hex)?,
591 Some(CipherKey::Passphrase(raw_hex.clone()))
592 );
593 assert_eq!(parse_cipher_key(" \n")?, None);
594
595 let err = parse_cipher_key("v2:not-hex")
596 .expect_err("malformed raw key must fail closed")
597 .to_string();
598 assert!(err.contains("64 hex"), "got: {err}");
599 Ok(())
600 }
601
602 #[test]
603 fn generate_cipher_key_writes_64_hex_chars() -> Result<()> {
604 let test_dir = ScopedTestDataDir::new("cipher-key");
605 std::fs::create_dir_all(&test_dir.path)?;
606 std::env::remove_var("REMEM_CIPHER_KEY");
607
608 let key = generate_cipher_key()?;
609 assert_eq!(key.len(), 64);
610 assert!(key.chars().all(|ch| ch.is_ascii_hexdigit()));
611
612 let saved = std::fs::read_to_string(test_dir.path.join(".key"))?;
613 assert_eq!(saved, format!("v2:{key}"));
614 Ok(())
615 }
616
617 #[test]
618 fn raw_key_file_opens_existing_database() -> Result<()> {
619 let test_dir = ScopedTestDataDir::new("cipher-raw-open");
620 std::fs::create_dir_all(&test_dir.path)?;
621 std::env::remove_var("REMEM_CIPHER_KEY");
622 let raw_hex = "1".repeat(64);
623 std::fs::write(test_dir.path.join(".key"), format!("v2:{raw_hex}"))?;
624
625 {
626 let conn = Connection::open(test_dir.db_path())?;
627 configure_cipher(&conn, Some(&CipherKey::Raw(raw_hex.clone())))?;
628 conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])?;
629 conn.execute("INSERT INTO t (v) VALUES ('raw-ok')", [])?;
630 }
631
632 let header = std::fs::read(test_dir.db_path())?;
633 assert_ne!(&header[..16], b"SQLite format 3\0");
634
635 let conn = crate::db::open_db_read_only()?;
636 let value: String = conn.query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))?;
637 assert_eq!(value, "raw-ok");
638 Ok(())
639 }
640
641 #[test]
642 fn generate_cipher_key_fails_when_os_randomness_is_unavailable() {
643 let test_dir = ScopedTestDataDir::new("cipher-key-fail");
644 std::fs::create_dir_all(&test_dir.path).expect("test data dir should exist");
645
646 let err = generate_cipher_key_with(|_| Err(getrandom::Error::UNSUPPORTED))
647 .expect_err("cipher key generation should fail without OS randomness");
648
649 assert!(err.to_string().contains("OS randomness unavailable"));
650 assert!(!test_dir.path.join(".key").exists());
651 }
652
653 #[cfg(unix)]
654 #[test]
655 fn generate_cipher_key_writes_file_with_0600_and_dir_with_0700() -> Result<()> {
656 use std::os::unix::fs::PermissionsExt;
657
658 let test_dir = ScopedTestDataDir::new("cipher-key-perms");
659 std::fs::create_dir_all(&test_dir.path)?;
660
661 let _ = generate_cipher_key()?;
662
663 let file_mode = std::fs::metadata(test_dir.path.join(".key"))?
664 .permissions()
665 .mode()
666 & 0o777;
667 assert_eq!(file_mode, 0o600, "key file must be 0600");
668
669 let dir_mode = std::fs::metadata(&test_dir.path)?.permissions().mode() & 0o777;
670 assert_eq!(dir_mode, 0o700, "data dir must be 0700");
671 Ok(())
672 }
673
674 #[test]
675 fn encrypt_database_removes_plaintext_migration_backup() -> Result<()> {
676 let test_dir = ScopedTestDataDir::new("encrypt-quote'path");
677 std::fs::create_dir_all(&test_dir.path)?;
678
679 let db_path = test_dir.db_path();
680 {
681 let conn = Connection::open(&db_path)?;
682 conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])?;
683 conn.execute("INSERT INTO t (v) VALUES ('hello')", [])?;
684 }
685
686 let key = generate_cipher_key()?;
687 encrypt_database(&CipherKey::Raw(key.clone()))?;
688
689 assert!(
690 !test_dir.path.join("remem.db.bak").exists(),
691 "plaintext migration backup must be removed after encrypt"
692 );
693 assert_no_plaintext_sqlite_files(&test_dir.path)?;
694 assert!(db_path.exists(), "encrypted db should be at original path");
695 let conn = Connection::open(db_path)?;
696 configure_cipher(&conn, Some(&CipherKey::Raw(key)))?;
697 let value: String = conn.query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))?;
698 assert_eq!(value, "hello");
699 Ok(())
700 }
701
702 #[test]
703 fn plaintext_sidecar_cleanup_removes_sqlite_sidecar_files_before_swap() -> Result<()> {
704 let test_dir = ScopedTestDataDir::new("encrypt-sidecar-files");
705 std::fs::create_dir_all(&test_dir.path)?;
706 for suffix in ["wal", "shm", "journal"] {
707 std::fs::write(test_dir.path.join(format!("remem.db-{suffix}")), b"plain")?;
708 }
709
710 remove_plaintext_sidecars_before_swap(&test_dir.db_path())?;
711
712 for suffix in ["wal", "shm", "journal"] {
713 assert!(!test_dir.path.join(format!("remem.db-{suffix}")).exists());
714 }
715 Ok(())
716 }
717
718 #[test]
719 fn plaintext_sidecar_cleanup_rejects_non_file_sidecar_before_swap() -> Result<()> {
720 let test_dir = ScopedTestDataDir::new("encrypt-sidecar-non-file");
721 std::fs::create_dir_all(&test_dir.path)?;
722 let wal_path = test_dir.path.join("remem.db-wal");
723 std::fs::write(&wal_path, b"plaintext wal sidecar sentinel")?;
724 let shm_path = test_dir.path.join("remem.db-shm");
725 std::fs::create_dir(&shm_path)?;
726
727 let error = remove_plaintext_sidecars_before_swap(&test_dir.db_path())
728 .expect_err("non-file sidecar must fail before DB swap");
729
730 assert!(
731 error.to_string().contains("non-file SQLite sidecar"),
732 "got: {error}"
733 );
734 assert!(
735 wal_path.exists(),
736 "sidecar cleanup must preflight every sidecar before removing any file"
737 );
738 assert!(shm_path.is_dir(), "non-file sidecar must not be removed");
739 Ok(())
740 }
741
742 fn assert_no_plaintext_sqlite_files(dir: &Path) -> Result<()> {
743 for entry in std::fs::read_dir(dir)? {
744 let entry = entry?;
745 if !entry.file_type()?.is_file() {
746 continue;
747 }
748 let bytes = std::fs::read(entry.path())?;
749 if bytes.len() < 16 {
750 continue;
751 }
752 assert_ne!(
753 &bytes[..16],
754 b"SQLite format 3\0",
755 "{} must not be a plaintext SQLite database",
756 entry.path().display()
757 );
758 }
759 Ok(())
760 }
761
762 #[cfg(unix)]
763 #[test]
764 fn generate_cipher_key_refuses_to_overwrite_existing_key() -> Result<()> {
765 use std::io::Write;
766
767 let test_dir = ScopedTestDataDir::new("cipher-key-no-overwrite");
768 std::fs::create_dir_all(&test_dir.path)?;
769 let key_path = test_dir.path.join(".key");
770 let mut existing = std::fs::File::create(&key_path)?;
771 existing.write_all(b"preexisting-key")?;
772 drop(existing);
773
774 let err =
775 generate_cipher_key().expect_err("must not overwrite an existing cipher key file");
776 assert!(
777 err.to_string().contains("cannot create cipher key file"),
778 "unexpected error: {}",
779 err
780 );
781
782 let preserved = std::fs::read_to_string(&key_path)?;
783 assert_eq!(preserved, "preexisting-key");
784 Ok(())
785 }
786}