Skip to main content

remem/db/
crypto.rs

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    // Enforce foreign keys so ON DELETE CASCADE / SET NULL behave during the
261    // sqlcipher_export copy; foreign_keys defaults to OFF on every new
262    // connection (#244).
263    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}