"""Integration tests for SQLCipher with real encryption (not mocked). These tests verify the actual SQLCipher encryption flow, including: - Registration followed by immediate login (the critical failing case) - Thread-safe sessions for metrics - Password verification (wrong password should fail) - Multi-user isolation """ import pytest from loguru import logger def _sqlcipher_available(): """Check if SQLCipher is available.""" try: from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) get_sqlcipher_module() return True except ImportError: return False @pytest.fixture def isolated_db_manager(tmp_path, monkeypatch): """Create DatabaseManager with isolated temp directory. Forces QueuePool (not StaticPool) so concurrent-write integration tests exercise the same pooling path used in production. Under the default TESTING=1 StaticPool all threads would share a single underlying SQLite connection, which can't serialize concurrent writes at the driver level. """ monkeypatch.setenv("LDR_DB_KDF_ITERATIONS", "1000") # Fast for testing monkeypatch.setenv("LDR_ALLOW_UNENCRYPTED", "false") # Require encryption from local_deep_research.database.encrypted_db import DatabaseManager from sqlalchemy.pool import QueuePool manager = DatabaseManager() manager._use_static_pool = False manager._pool_class = QueuePool # Point to temp directory manager.data_dir = tmp_path / "encrypted_databases" manager.data_dir.mkdir(parents=True, exist_ok=True) yield manager # Cleanup for username in list(manager.connections.keys()): manager.close_user_database(username) @pytest.mark.skipif( not _sqlcipher_available(), reason="SQLCipher not installed" ) class TestSQLCipherIntegration: """Real SQLCipher tests (not mocked).""" def test_register_then_immediate_login(self, isolated_db_manager): """ Critical test: Registration followed by immediate login. This is the exact flow that failed in UI tests. """ username = "testuser" password = "SecurePassword123!" # Register (create database) engine = isolated_db_manager.create_user_database(username, password) assert engine is not None # Close the connection (simulates end of registration request) isolated_db_manager.close_user_database(username) # Login (open existing database) - THIS IS WHERE IT FAILED engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Failed to open database after registration" # Verify database is functional from sqlalchemy import text with engine.connect() as conn: result = conn.execute(text("SELECT 1")) assert result.fetchone()[0] == 1 def test_thread_safe_session_after_create(self, isolated_db_manager): """Test thread-safe session works after database creation.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) # Create thread-safe session (used for metrics) session = isolated_db_manager.create_thread_safe_session_for_metrics( username, password ) assert session is not None # Verify session works from sqlalchemy import text result = session.execute(text("SELECT 1")) assert result.fetchone()[0] == 1 session.close() def test_wrong_password_fails(self, isolated_db_manager): """Verify that wrong password actually fails (encryption works).""" username = "testuser" password = "CorrectPassword123!" # Create database isolated_db_manager.create_user_database(username, password) isolated_db_manager.close_user_database(username) # Attempt to open with wrong password engine = isolated_db_manager.open_user_database( username, "WrongPassword!" ) assert engine is None, "Should fail with wrong password" def test_multiple_users_isolated(self, isolated_db_manager): """Test that multiple users have separate encrypted databases.""" users = [ ("user1", "Password1!"), ("user2", "Password2!"), ("user3", "Password3!"), ] # Create all users for username, password in users: isolated_db_manager.create_user_database(username, password) # Close all connections for username, _ in users: isolated_db_manager.close_user_database(username) # Reopen each user - verify each password only works for its database for username, password in users: # Correct password works engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, f"Failed to open {username}'s database" isolated_db_manager.close_user_database(username) # Wrong passwords fail for other_user, other_pass in users: if other_user != username: engine = isolated_db_manager.open_user_database( username, other_pass ) assert engine is None, ( f"{other_user}'s password should not work for {username}" ) def test_reopen_multiple_times(self, isolated_db_manager): """Test database can be opened and closed multiple times.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) # Open and close multiple times for i in range(5): isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, ( f"Failed to reopen database on iteration {i}" ) # Verify it works each time from sqlalchemy import text with engine.connect() as conn: result = conn.execute(text("SELECT 1")) assert result.fetchone()[0] == 1 def test_database_persists_data(self, isolated_db_manager): """Test that data persists across close/open cycles.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) # Insert data from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute( text( "CREATE TABLE IF NOT EXISTS test_data (id INTEGER, value TEXT)" ) ) conn.execute(text("INSERT INTO test_data VALUES (1, 'test_value')")) conn.commit() # Close and reopen isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None # Verify data persists with engine.connect() as conn: result = conn.execute( text("SELECT value FROM test_data WHERE id = 1") ) row = result.fetchone() assert row is not None assert row[0] == "test_value" def test_thread_safe_session_reuse(self, isolated_db_manager): """Test that thread-safe sessions can be created multiple times.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) # Create multiple thread-safe sessions from sqlalchemy import text for i in range(3): session = ( isolated_db_manager.create_thread_safe_session_for_metrics( username, password ) ) assert session is not None, ( f"Failed to create session on iteration {i}" ) # Verify session works result = session.execute(text("SELECT 1")) assert result.fetchone()[0] == 1 session.close() def test_concurrent_access_same_user(self, isolated_db_manager): """Test concurrent access to the same user's database.""" import threading username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) errors = [] success_count = [0] def worker(): try: session = ( isolated_db_manager.create_thread_safe_session_for_metrics( username, password ) ) from sqlalchemy import text result = session.execute(text("SELECT 1")) assert result.fetchone()[0] == 1 session.close() success_count[0] += 1 except Exception as e: errors.append(str(e)) # Run multiple threads threads = [threading.Thread(target=worker) for _ in range(5)] for t in threads: t.start() for t in threads: t.join(timeout=10) assert len(errors) == 0, f"Errors during concurrent access: {errors}" assert success_count[0] == 5, ( f"Only {success_count[0]}/5 threads succeeded" ) def test_change_password_works(self, isolated_db_manager): """Test changing password and reopening with new password.""" username = "testuser" old_password = "OldPassword123!" new_password = "NewPassword456!" # Create database with old password isolated_db_manager.create_user_database(username, old_password) # Insert some data to verify it persists after password change from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute( text( "CREATE TABLE IF NOT EXISTS test_data (id INTEGER, value TEXT)" ) ) conn.execute( text("INSERT INTO test_data VALUES (1, 'secret_data')") ) conn.commit() # Change password result = isolated_db_manager.change_password( username, old_password, new_password ) assert result is True, "Password change should succeed" # Old password should no longer work engine = isolated_db_manager.open_user_database(username, old_password) assert engine is None, "Old password should not work after change" # New password should work engine = isolated_db_manager.open_user_database(username, new_password) assert engine is not None, "New password should work after change" # Verify data persists after password change with engine.connect() as conn: result = conn.execute( text("SELECT value FROM test_data WHERE id = 1") ) row = result.fetchone() assert row is not None assert row[0] == "secret_data" def test_check_database_integrity(self, isolated_db_manager): """Test check_database_integrity() returns True for valid DB.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) # Check integrity - should pass for fresh database result = isolated_db_manager.check_database_integrity(username) assert result is True, "Integrity check should pass for valid database" def test_special_characters_in_password(self, isolated_db_manager): """Test passwords with quotes, backslashes, and special chars.""" username = "testuser" # Password with quotes, backslashes, and special characters password = "P@ss'w\"ord\\123!#$%^&*()" # Create database with special character password engine = isolated_db_manager.create_user_database(username, password) assert engine is not None # Close and reopen isolated_db_manager.close_user_database(username) # Should be able to reopen with same password engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Should open with special character password" # Verify database is functional from sqlalchemy import text with engine.connect() as conn: result = conn.execute(text("SELECT 1")) assert result.fetchone()[0] == 1 def test_unicode_in_password(self, isolated_db_manager): """Test passwords with unicode characters.""" username = "testuser" # Password with unicode characters password = "密码Пароль🔐Senha123!" # Create database engine = isolated_db_manager.create_user_database(username, password) assert engine is not None # Close and reopen isolated_db_manager.close_user_database(username) # Should be able to reopen with same unicode password engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Should open with unicode password" def test_empty_password_rejected(self, isolated_db_manager): """Test that empty password raises ValueError.""" username = "testuser" with pytest.raises( ValueError, match="password cannot be None or empty" ): isolated_db_manager.create_user_database(username, "") def test_none_password_rejected(self, isolated_db_manager): """Test that None password raises ValueError.""" username = "testuser" with pytest.raises( ValueError, match="password cannot be None or empty" ): isolated_db_manager.create_user_database(username, None) def test_empty_password_rejected_on_open(self, isolated_db_manager): """Test that empty password raises ValueError on open.""" username = "testuser" password = "SecurePassword123!" # Create database first isolated_db_manager.create_user_database(username, password) isolated_db_manager.close_user_database(username) # Attempt to open with empty password with pytest.raises( ValueError, match="password cannot be None or empty" ): isolated_db_manager.open_user_database(username, "") def test_corrupted_database_returns_none(self, isolated_db_manager): """Test opening corrupted DB file returns None gracefully.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) isolated_db_manager.close_user_database(username) # Corrupt the database file by overwriting with garbage db_path = isolated_db_manager._get_user_db_path(username) with open(db_path, "wb") as f: f.write(b"THIS IS NOT A VALID SQLITE DATABASE FILE" * 100) # Attempt to open - should return None, not crash engine = isolated_db_manager.open_user_database(username, password) assert engine is None, "Should return None for corrupted database" def test_nonexistent_user_returns_none(self, isolated_db_manager): """Test opening nonexistent user database returns None.""" engine = isolated_db_manager.open_user_database( "nonexistent_user", "password" ) assert engine is None, "Should return None for nonexistent user" def test_duplicate_user_rejected(self, isolated_db_manager): """Test creating duplicate user raises ValueError.""" username = "testuser" password = "SecurePassword123!" # Create first user isolated_db_manager.create_user_database(username, password) # Attempt to create same user again with pytest.raises(ValueError, match="Database already exists"): isolated_db_manager.create_user_database( username, "DifferentPassword!" ) # ========================================================================= # HIGH PRIORITY: Security Critical Tests # ========================================================================= def test_cipher_integrity_check_detects_tampering( self, isolated_db_manager ): """Test that cipher_integrity_check detects file tampering.""" username = "testuser" password = "SecurePassword123!" # Create database with some data isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute( text("CREATE TABLE test_data (id INTEGER, value TEXT)") ) conn.execute(text("INSERT INTO test_data VALUES (1, 'secret')")) conn.commit() isolated_db_manager.close_user_database(username) # Tamper with the database file (modify bytes in the middle) db_path = isolated_db_manager._get_user_db_path(username) with open(db_path, "r+b") as f: f.seek(1024) # Skip header, modify data pages original = f.read(100) f.seek(1024) # Flip some bytes tampered = bytes([b ^ 0xFF for b in original]) f.write(tampered) # Try to open tampered database - should fail or return None engine = isolated_db_manager.open_user_database(username, password) # Tampered database should either fail to open or fail integrity check if engine is not None: # If it opens, integrity check should fail integrity_ok = isolated_db_manager.check_database_integrity( username ) # Tampering may or may not be detected depending on which pages # were modified - this is expected behavior assert isinstance(integrity_ok, bool) logger.info( f"Integrity check after tampering returned: {integrity_ok}" ) def test_plaintext_header_not_present(self, isolated_db_manager): """Verify encrypted DB doesn't have SQLite plaintext header.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) # Write some data to ensure pages are written from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute( text("CREATE TABLE test_data (id INTEGER, value TEXT)") ) conn.execute(text("INSERT INTO test_data VALUES (1, 'secret')")) conn.commit() isolated_db_manager.close_user_database(username) # Read the database file header db_path = isolated_db_manager._get_user_db_path(username) with open(db_path, "rb") as f: header = f.read(16) # SQLite magic header is "SQLite format 3\0" sqlite_magic = b"SQLite format 3\x00" assert header != sqlite_magic, ( "Encrypted database should NOT have plaintext SQLite header" ) def test_cipher_status_returns_active(self, isolated_db_manager): """Verify PRAGMA cipher_status returns expected value for encrypted DB.""" username = "testuser" password = "SecurePassword123!" # Create and open database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: # cipher_status returns encryption status info try: result = conn.execute(text("PRAGMA cipher_status")) rows = result.fetchall() # cipher_status should return rows indicating encryption is active # The exact format varies by SQLCipher version assert len(rows) > 0, "cipher_status should return status info" except Exception: # Some versions may not support cipher_status # In that case, verify encryption via other means result = conn.execute(text("PRAGMA cipher_version")) version = result.fetchone() assert version is not None, "Should have cipher_version" def test_cipher_settings_match_configuration(self, isolated_db_manager): """Verify PRAGMA cipher_settings returns configured values.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: # Get cipher settings try: result = conn.execute(text("PRAGMA cipher_settings")) rows = result.fetchall() # Should have settings configured # Convert to dict for easier inspection settings = {} for row in rows: if len(row) >= 2: settings[row[0]] = row[1] # Verify some expected settings exist # (exact values depend on configuration) assert len(rows) > 0, "cipher_settings should return settings" except Exception: # Older SQLCipher may not have cipher_settings # Verify cipher is active via version check result = conn.execute(text("PRAGMA cipher_version")) version = result.fetchone() assert version is not None # ========================================================================= # MEDIUM PRIORITY: Compatibility & Edge Cases # ========================================================================= def test_wal_files_are_encrypted(self, isolated_db_manager, tmp_path): """Test that WAL files are also encrypted (no plaintext leakage).""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Enable WAL mode with engine.connect() as conn: conn.execute(text("PRAGMA journal_mode=WAL")) conn.commit() # Create table and insert data with recognizable pattern secret_data = "TOP_SECRET_DATA_12345" with engine.connect() as conn: conn.execute(text("CREATE TABLE secrets (id INTEGER, value TEXT)")) conn.execute( text(f"INSERT INTO secrets VALUES (1, '{secret_data}')") ) conn.commit() # Check for WAL file from pathlib import Path db_path = isolated_db_manager._get_user_db_path(username) wal_path = Path(str(db_path) + "-wal") shm_path = Path(str(db_path) + "-shm") # WAL file may or may not exist depending on checkpoint status if wal_path.exists(): with open(wal_path, "rb") as f: wal_content = f.read() # Secret data should NOT appear in plaintext in WAL assert secret_data.encode() not in wal_content, ( "Secret data should not appear in plaintext in WAL file" ) # Cleanup: close database and remove WAL/SHM files isolated_db_manager.close_user_database(username) for path in [wal_path, shm_path]: if path.exists(): path.unlink() def test_invalid_page_size_rejected(self, tmp_path): """Verify non-power-of-2 page sizes are rejected.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "test_pagesize.db" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Set key first cursor.execute("PRAGMA key = 'test_password'") # Try to set invalid page size (not power of 2) # SQLCipher should either reject this or use default cursor.execute("PRAGMA cipher_page_size = 5000") # Not power of 2 # Try to create a table to force the page size to be applied try: cursor.execute("CREATE TABLE test (id INTEGER)") conn.commit() # Check actual page size cursor.execute("PRAGMA page_size") actual_size = cursor.fetchone()[0] # Should be a power of 2 (SQLCipher enforces this) assert actual_size & (actual_size - 1) == 0, ( f"Page size {actual_size} is not a power of 2" ) except Exception as e: # SQLCipher properly rejected invalid page size - this is expected error_msg = str(e).lower() assert "error" in error_msg or "logic" in error_msg, ( f"Unexpected error message: {e}" ) finally: conn.close() def test_page_size_mismatch_fails(self, tmp_path): """Create DB with one page size, attempt to open with different size.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "test_pagesize_mismatch.db" # Create database with 4096 page size conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_page_size = 4096") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'data')") conn.commit() conn.close() # Try to open with different page size (1024) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_page_size = 1024") # Should fail to read data due to page size mismatch try: cursor.execute("SELECT * FROM test") cursor.fetchall() # Result unused - testing if query fails # If we get here, the database somehow worked (unexpected) # This might happen if SQLCipher auto-detects page size except Exception as e: # Expected: should fail with decryption error assert ( "file is not a database" in str(e).lower() or "decrypt" in str(e).lower() or "corrupt" in str(e).lower() or "error" in str(e).lower() ), f"Unexpected error: {e}" finally: conn.close() def test_key_after_operation_fails(self, tmp_path): """Verify PRAGMA key after SELECT fails with proper error.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "test_key_timing.db" # Create encrypted database first conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("CREATE TABLE test (id INTEGER)") conn.commit() conn.close() # Open and try to set key AFTER performing an operation conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Perform operation first (without key) try: cursor.execute("SELECT * FROM sqlite_master") except Exception as e: if isinstance(e, AssertionError): raise # Now try to set key - should have no effect or fail cursor.execute("PRAGMA key = 'test_password'") # Try to read data - should still fail because key was set too late try: cursor.execute("SELECT * FROM test") # If this succeeds, SQLCipher might handle late key differently cursor.fetchall() # Result unused - testing if query fails except Exception as e: # Expected: database should be inaccessible assert ( "file is not a database" in str(e).lower() or "no such table" in str(e).lower() or "error" in str(e).lower() ) finally: conn.close() def test_cipher_pragmas_after_operation_ignored(self, tmp_path): """Verify cipher pragmas set after first operation have no effect.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "test_pragma_timing.db" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Set key first cursor.execute("PRAGMA key = 'test_password'") # Create table (this triggers first operation) cursor.execute("CREATE TABLE test (id INTEGER)") conn.commit() # Get current KDF iterations cursor.execute("PRAGMA kdf_iter") cursor.fetchone() # Result unused - verifying pragma works # Try to change KDF iterations AFTER first operation cursor.execute("PRAGMA kdf_iter = 1") # Check if it changed (it shouldn't for an already-initialized DB) cursor.execute("PRAGMA kdf_iter") cursor.fetchone() # Result unused - testing pragma behavior # The KDF iter shouldn't change for already-opened database # (changes only affect new databases or rekey operations) conn.close() # ========================================================================= # LOWER PRIORITY: Robustness Tests # ========================================================================= def test_large_blob_storage(self, isolated_db_manager): """Store and retrieve large binary data (1MB+).""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text import os engine = isolated_db_manager.connections[username] # Create table for blob storage with engine.connect() as conn: conn.execute(text("CREATE TABLE blobs (id INTEGER, data BLOB)")) conn.commit() # Generate 1MB of random data large_data = os.urandom(1024 * 1024) # 1MB # Store the blob with engine.connect() as conn: # Use parameterized query for blob conn.execute( text("INSERT INTO blobs VALUES (1, :data)"), {"data": large_data}, ) conn.commit() # Close and reopen to ensure persistence isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None # Retrieve and verify with engine.connect() as conn: result = conn.execute(text("SELECT data FROM blobs WHERE id = 1")) row = result.fetchone() assert row is not None retrieved_data = row[0] assert retrieved_data == large_data, "Large blob data mismatch" def test_many_records_performance(self, isolated_db_manager): """Insert 10,000+ records, verify retrieval works.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Create table with engine.connect() as conn: conn.execute( text( "CREATE TABLE records (id INTEGER PRIMARY KEY, value TEXT)" ) ) conn.commit() # Insert 10,000 records in batches record_count = 10000 batch_size = 1000 with engine.connect() as conn: for batch_start in range(0, record_count, batch_size): values = ", ".join( f"({i}, 'value_{i}')" for i in range( batch_start, min(batch_start + batch_size, record_count) ) ) conn.execute( text(f"INSERT INTO records (id, value) VALUES {values}") ) conn.commit() # Verify count with engine.connect() as conn: result = conn.execute(text("SELECT COUNT(*) FROM records")) count = result.fetchone()[0] assert count == record_count, ( f"Expected {record_count}, got {count}" ) # Verify random access with engine.connect() as conn: result = conn.execute( text("SELECT value FROM records WHERE id = 5000") ) row = result.fetchone() assert row[0] == "value_5000" # Close and reopen isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None # Verify data persists with engine.connect() as conn: result = conn.execute(text("SELECT COUNT(*) FROM records")) count = result.fetchone()[0] assert count == record_count def test_rapid_open_close_cycles(self, isolated_db_manager): """Open/close database 100 times in rapid succession.""" username = "testuser" password = "SecurePassword123!" # Create database with some data isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) conn.execute(text("INSERT INTO test VALUES (1, 'persistent')")) conn.commit() # Rapid open/close cycles cycles = 100 for i in range(cycles): isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, f"Failed to open on cycle {i}" # Quick verification with engine.connect() as conn: result = conn.execute( text("SELECT value FROM test WHERE id = 1") ) row = result.fetchone() assert row[0] == "persistent", f"Data corrupted on cycle {i}" def test_file_is_not_database_error_plaintext(self, tmp_path): """Open regular SQLite DB with SQLCipher, verify meaningful error.""" import sqlite3 # Create a regular (unencrypted) SQLite database plain_db_path = tmp_path / "plain.db" conn = sqlite3.connect(str(plain_db_path)) cursor = conn.cursor() cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'plaintext_data')") conn.commit() conn.close() # Try to open with SQLCipher and a password from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() conn = pysqlcipher3.connect(str(plain_db_path)) cursor = conn.cursor() # Set a key (which will try to decrypt) cursor.execute("PRAGMA key = 'some_password'") # Try to read - should fail with meaningful error try: cursor.execute("SELECT * FROM test") cursor.fetchall() # Result unused - testing if query fails # If we get here with data, something is wrong # (SQLCipher shouldn't be able to read encrypted as plaintext) assert False, ( "Should not be able to read plaintext DB with password" ) except Exception as e: error_msg = str(e).lower() # Should get "file is not a database" or similar assert ( "not a database" in error_msg or "file is encrypted" in error_msg or "error" in error_msg ), f"Expected meaningful error, got: {e}" finally: conn.close() def test_key_with_null_bytes_handled(self, tmp_path): """Test password containing null bytes (potential truncation issue).""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "test_null_key.db" # Password with embedded null bytes # Note: This tests if SQLCipher properly handles or rejects such passwords password_with_null = "pass\x00word\x00end" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() try: # Try to set key with null bytes # Using raw key format to ensure bytes are passed correctly cursor.execute(f"PRAGMA key = '{password_with_null}'") cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() conn.close() # Try to reopen with same password conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password_with_null}'") cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert len(result) == 1, "Should be able to read with same password" except Exception as e: # If SQLCipher rejects null bytes, that's also acceptable # as long as it handles gracefully error_msg = str(e).lower() assert ( "error" in error_msg or "invalid" in error_msg or "not a database" in error_msg or "null" in error_msg ), f"Unexpected error: {e}" finally: conn.close() def test_database_file_appears_random(self, isolated_db_manager): """Statistical test that encrypted file has high entropy.""" import math username = "testuser" password = "SecurePassword123!" # Create database with predictable data isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Insert lots of predictable data (low entropy content) with engine.connect() as conn: conn.execute( text("CREATE TABLE entropy_test (id INTEGER, value TEXT)") ) # Insert repetitive data for i in range(100): conn.execute( text( f"INSERT INTO entropy_test VALUES ({i}, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')" ) ) conn.commit() isolated_db_manager.close_user_database(username) # Read the database file db_path = isolated_db_manager._get_user_db_path(username) with open(db_path, "rb") as f: data = f.read() # Calculate Shannon entropy def calculate_entropy(data): if not data: return 0 byte_counts = [0] * 256 for byte in data: byte_counts[byte] += 1 entropy = 0 data_len = len(data) for count in byte_counts: if count > 0: probability = count / data_len entropy -= probability * math.log2(probability) return entropy entropy = calculate_entropy(data) # Well-encrypted data should have high entropy (close to 8 bits) # Unencrypted SQLite with repetitive data would have much lower entropy # We expect at least 7.5 bits of entropy for encrypted data assert entropy > 7.0, ( f"Encrypted database entropy {entropy:.2f} is too low, " "suggesting weak or no encryption" ) def test_cipher_version_available(self, isolated_db_manager): """Verify SQLCipher version is available and valid.""" username = "testuser" password = "SecurePassword123!" # Create database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_version")) version = result.fetchone() assert version is not None, "cipher_version should return a value" assert version[0] is not None, "cipher_version should not be None" # Version should be a string like "4.5.1" or similar version_str = str(version[0]) assert len(version_str) > 0, "cipher_version should not be empty" def test_quick_check_passes(self, isolated_db_manager): """Verify PRAGMA quick_check passes for valid encrypted database.""" username = "testuser" password = "SecurePassword123!" # Create database with data isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) conn.execute(text("INSERT INTO test VALUES (1, 'data')")) conn.commit() # Run quick_check result = conn.execute(text("PRAGMA quick_check")) check_result = result.fetchone() assert check_result is not None assert check_result[0] == "ok", ( f"quick_check failed: {check_result[0]}" ) # ========================================================================= # ADDITIONAL TESTS: Based on SQLCipher API Documentation # ========================================================================= def test_cipher_provider_available(self, isolated_db_manager): """Verify cipher_provider returns the crypto library name.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_provider")) provider = result.fetchone() assert provider is not None, "cipher_provider should return a value" provider_name = str(provider[0]).lower() # Should be one of: openssl, libtomcrypt, commoncrypto, nss valid_providers = ["openssl", "libtomcrypt", "commoncrypto", "nss"] assert any(p in provider_name for p in valid_providers), ( f"Unknown cipher provider: {provider[0]}" ) def test_cipher_provider_version_available(self, isolated_db_manager): """Verify cipher_provider_version returns version info.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_provider_version")) version = result.fetchone() assert version is not None, ( "cipher_provider_version should return a value" ) assert len(str(version[0])) > 0, ( "Provider version should not be empty" ) def test_cipher_kdf_algorithm_queryable(self, isolated_db_manager): """Verify cipher_kdf_algorithm returns the KDF algorithm.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_kdf_algorithm")) kdf = result.fetchone() assert kdf is not None, "cipher_kdf_algorithm should return a value" kdf_name = str(kdf[0]).upper() # Should be PBKDF2_HMAC_SHA512, PBKDF2_HMAC_SHA256, or PBKDF2_HMAC_SHA1 # DevSkim: ignore DS126858 - Testing valid SQLCipher KDF algorithms including legacy SHA1 valid_kdfs = [ "PBKDF2_HMAC_SHA512", "PBKDF2_HMAC_SHA256", "PBKDF2_HMAC_SHA1", # DevSkim: ignore DS126858 ] assert any(k in kdf_name for k in valid_kdfs), ( f"Unknown KDF algorithm: {kdf[0]}" ) def test_cipher_hmac_algorithm_queryable(self, isolated_db_manager): """Verify cipher_hmac_algorithm returns the HMAC algorithm.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_hmac_algorithm")) hmac = result.fetchone() assert hmac is not None, ( "cipher_hmac_algorithm should return a value" ) hmac_name = str(hmac[0]).upper() # Should be HMAC_SHA512, HMAC_SHA256, or HMAC_SHA1 # DevSkim: ignore DS126858 - Testing valid SQLCipher HMAC algorithms including legacy SHA1 valid_hmacs = [ "HMAC_SHA512", "HMAC_SHA256", "HMAC_SHA1", # DevSkim: ignore DS126858 ] assert any(h in hmac_name for h in valid_hmacs), ( f"Unknown HMAC algorithm: {hmac[0]}" ) def test_raw_hex_key_bypasses_pbkdf2(self, tmp_path): """Test using raw hex key (x'...') which bypasses PBKDF2.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "test_raw_key.db" # 256-bit raw key in hex (64 hex chars = 32 bytes) raw_key = ( "2DD29CA851E7B56E4697B0E1F08507293D761A05CE4D1B628663F411A8086D99" ) # Create database with raw key conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = \"x'{raw_key}'\"") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'raw_key_data')") conn.commit() conn.close() # Reopen with same raw key conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = \"x'{raw_key}'\"") cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "raw_key_data", "Should read data with raw key" conn.close() # Different raw key should fail # Generate a clearly wrong key (all zeros) - not a real secret wrong_key = "0" * 64 # 256-bit key of all zeros for testing conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = \"x'{wrong_key}'\"") try: cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert False, "Should fail with wrong raw key" except Exception as e: assert ( "not a database" in str(e).lower() or "file is encrypted" in str(e).lower() ) finally: conn.close() def test_attach_database_with_different_key(self, tmp_path): """Test ATTACH DATABASE with a different encryption key.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # Create main database main_db_path = tmp_path / "main.db" conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'main_password'") cursor.execute("CREATE TABLE main_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO main_data VALUES (1, 'main_value')") conn.commit() conn.close() # Create secondary database with different key second_db_path = tmp_path / "second.db" conn = pysqlcipher3.connect(str(second_db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'second_password'") cursor.execute("CREATE TABLE second_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO second_data VALUES (2, 'second_value')") conn.commit() conn.close() # Open main and attach secondary with its key conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'main_password'") # Attach with KEY clause cursor.execute( f"ATTACH DATABASE '{second_db_path}' AS second KEY 'second_password'" ) # Query from both databases cursor.execute("SELECT value FROM main_data WHERE id = 1") main_result = cursor.fetchone() assert main_result[0] == "main_value" cursor.execute("SELECT value FROM second.second_data WHERE id = 2") second_result = cursor.fetchone() assert second_result[0] == "second_value" conn.close() def test_sqlcipher_export_to_encrypted(self, tmp_path): """Test sqlcipher_export to migrate between databases.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # Create source database source_path = tmp_path / "source.db" conn = pysqlcipher3.connect(str(source_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'source_pass'") cursor.execute("CREATE TABLE data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO data VALUES (1, 'exported_value')") cursor.execute("INSERT INTO data VALUES (2, 'another_value')") conn.commit() conn.close() # Export to new database with different key dest_path = tmp_path / "dest.db" conn = pysqlcipher3.connect(str(source_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'source_pass'") # Attach destination with different key cursor.execute(f"ATTACH DATABASE '{dest_path}' AS dest KEY 'dest_pass'") # Export data cursor.execute("SELECT sqlcipher_export('dest')") cursor.execute("DETACH DATABASE dest") conn.close() # Verify destination with its own key conn = pysqlcipher3.connect(str(dest_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'dest_pass'") cursor.execute("SELECT COUNT(*) FROM data") count = cursor.fetchone()[0] assert count == 2, f"Expected 2 rows, got {count}" cursor.execute("SELECT value FROM data WHERE id = 1") result = cursor.fetchone() assert result[0] == "exported_value" conn.close() def test_very_long_password(self, isolated_db_manager): """Test with an extremely long password (10000+ characters).""" username = "testuser" # Generate a 10000 character password password = "A" * 10000 + "SecureEnd!" # Create database with very long password engine = isolated_db_manager.create_user_database(username, password) assert engine is not None from sqlalchemy import text with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) conn.execute(text("INSERT INTO test VALUES (1, 'long_pass_data')")) conn.commit() # Close and reopen isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Should open with very long password" with engine.connect() as conn: result = conn.execute(text("SELECT value FROM test WHERE id = 1")) assert result.fetchone()[0] == "long_pass_data" def test_vacuum_preserves_encryption(self, isolated_db_manager): """Test that VACUUM command preserves encryption.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Create data with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) for i in range(100): conn.execute( text(f"INSERT INTO test VALUES ({i}, 'value_{i}')") ) conn.commit() # Delete some data with engine.connect() as conn: conn.execute(text("DELETE FROM test WHERE id > 50")) conn.commit() # Run VACUUM with engine.connect() as conn: conn.execute(text("VACUUM")) conn.commit() # Close and reopen to verify encryption still works isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Should open after VACUUM" # Verify data integrity with engine.connect() as conn: result = conn.execute(text("SELECT COUNT(*) FROM test")) count = result.fetchone()[0] assert count == 51, f"Expected 51 rows after delete, got {count}" # Verify file is still encrypted (no plaintext header) db_path = isolated_db_manager._get_user_db_path(username) with open(db_path, "rb") as f: header = f.read(16) assert header != b"SQLite format 3\x00", ( "Should still be encrypted after VACUUM" ) def test_transaction_rollback(self, isolated_db_manager): """Test that transaction rollback works correctly with encryption.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Create table with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) conn.execute(text("INSERT INTO test VALUES (1, 'original')")) conn.commit() # Start transaction, make changes, then rollback with engine.connect() as conn: conn.execute( text("UPDATE test SET value = 'modified' WHERE id = 1") ) conn.execute(text("INSERT INTO test VALUES (2, 'new_row')")) # Rollback instead of commit conn.rollback() # Verify rollback worked with engine.connect() as conn: result = conn.execute(text("SELECT value FROM test WHERE id = 1")) value = result.fetchone()[0] assert value == "original", f"Rollback failed: got {value}" result = conn.execute(text("SELECT COUNT(*) FROM test")) count = result.fetchone()[0] assert count == 1, f"Rollback failed: got {count} rows" def test_cipher_memory_security_queryable(self, isolated_db_manager): """Test that cipher_memory_security can be queried.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_memory_security")) mem_security = result.fetchone() assert mem_security is not None, ( "cipher_memory_security should return a value" ) # Value is 0 or 1 (may be returned as string) value = int(mem_security[0]) assert value in (0, 1), ( f"cipher_memory_security should be 0 or 1, got {value}" ) def test_kdf_iter_queryable(self, isolated_db_manager): """Test that kdf_iter returns the iteration count.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA kdf_iter")) kdf_iter = result.fetchone() assert kdf_iter is not None, "kdf_iter should return a value" # Should be a positive integer (default is 256000 for SQLCipher 4) # May be returned as string value = int(kdf_iter[0]) assert value > 0, f"kdf_iter should be positive, got {value}" def test_empty_database_encrypted(self, isolated_db_manager): """Test that even an empty database is encrypted.""" username = "testuser" password = "SecurePassword123!" # Create empty database (no tables) isolated_db_manager.create_user_database(username, password) isolated_db_manager.close_user_database(username) # Verify file is encrypted (no plaintext header) db_path = isolated_db_manager._get_user_db_path(username) with open(db_path, "rb") as f: header = f.read(16) assert header != b"SQLite format 3\x00", ( "Empty database should still be encrypted" ) # Should still be openable with correct password engine = isolated_db_manager.open_user_database(username, password) assert engine is not None def test_concurrent_writes(self, isolated_db_manager): """Test concurrent write operations to encrypted database.""" import threading username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Create table with engine.connect() as conn: conn.execute( text( "CREATE TABLE concurrent_test (id INTEGER PRIMARY KEY, value TEXT)" ) ) conn.commit() errors = [] write_count = [0] lock = threading.Lock() def writer(thread_id): try: session = ( isolated_db_manager.create_thread_safe_session_for_metrics( username, password ) ) for i in range(10): unique_id = thread_id * 100 + i session.execute( text( f"INSERT INTO concurrent_test VALUES ({unique_id}, 'thread_{thread_id}')" ) ) session.commit() with lock: write_count[0] += 1 session.close() except Exception as e: errors.append(f"Thread {thread_id}: {e}") # Run multiple writers threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)] for t in threads: t.start() for t in threads: t.join(timeout=30) assert len(errors) == 0, f"Concurrent write errors: {errors}" assert write_count[0] == 50, f"Expected 50 writes, got {write_count[0]}" # Verify all data was written with engine.connect() as conn: result = conn.execute(text("SELECT COUNT(*) FROM concurrent_test")) count = result.fetchone()[0] assert count == 50, f"Expected 50 rows, got {count}" def test_reader_not_blocked_by_concurrent_writer(self, isolated_db_manager): """Under DEFERRED isolation + WAL mode, a reader must not block behind an active writer. Previously IMMEDIATE isolation forced every transaction — including pure SELECTs — to take a RESERVED write lock, serialising readers behind writers and causing the login-hang symptom. After the IMMEDIATE → DEFERRED change, a reader should complete while a writer is still in its transaction. """ import threading import time username = "reader_writer_user" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute( text("CREATE TABLE rw_test (id INTEGER PRIMARY KEY, v INT)") ) conn.execute(text("INSERT INTO rw_test VALUES (1, 100)")) conn.commit() writer_started = threading.Event() writer_release = threading.Event() reader_done = threading.Event() reader_elapsed_ms = [None] errors = [] def writer(): try: sess = ( isolated_db_manager.create_thread_safe_session_for_metrics( username, password ) ) # Open a write transaction and hold it until we say so. sess.execute(text("UPDATE rw_test SET v = 200 WHERE id = 1")) writer_started.set() writer_release.wait(timeout=5) sess.commit() sess.close() except Exception as e: errors.append(f"writer: {e}") def reader(): try: writer_started.wait(timeout=5) start = time.perf_counter() sess = ( isolated_db_manager.create_thread_safe_session_for_metrics( username, password ) ) result = sess.execute( text("SELECT v FROM rw_test WHERE id = 1") ).fetchone() sess.rollback() sess.close() reader_elapsed_ms[0] = (time.perf_counter() - start) * 1000 # Must see the pre-commit snapshot value (100), not 200. assert result[0] == 100, ( f"Reader saw uncommitted write (got {result[0]})" ) reader_done.set() except Exception as e: errors.append(f"reader: {e}") reader_done.set() wt = threading.Thread(target=writer, daemon=True) rt = threading.Thread(target=reader, daemon=True) wt.start() rt.start() # The reader should finish quickly (well under 1s). If it takes # anywhere near busy_timeout (10s), we regressed into the # IMMEDIATE behaviour. assert reader_done.wait(timeout=3), ( "Reader was blocked by writer — IMMEDIATE-style serialisation" ) writer_release.set() wt.join(timeout=5) rt.join(timeout=5) assert not errors, f"errors: {errors}" assert reader_elapsed_ms[0] < 1000, ( f"Reader took {reader_elapsed_ms[0]:.0f}ms — " "too long for a concurrent read under WAL + DEFERRED" ) def test_cipher_use_hmac_queryable(self, isolated_db_manager): """Test that cipher_use_hmac returns expected value.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_use_hmac")) use_hmac = result.fetchone() assert use_hmac is not None, "cipher_use_hmac should return a value" # Default should be 1 (enabled) for SQLCipher 4 # May be returned as string value = int(use_hmac[0]) assert value in (0, 1), ( f"cipher_use_hmac should be 0 or 1, got {value}" ) def test_cipher_page_size_queryable(self, isolated_db_manager): """Test that cipher_page_size returns the configured page size.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: result = conn.execute(text("PRAGMA cipher_page_size")) page_size = result.fetchone() assert page_size is not None, ( "cipher_page_size should return a value" ) # Should be a power of 2 between 512 and 65536 # May be returned as string size = int(page_size[0]) assert 512 <= size <= 65536, f"Page size {size} out of range" assert size & (size - 1) == 0, f"Page size {size} not power of 2" def test_fts_with_encryption(self, isolated_db_manager): """Test Full-Text Search works with encryption.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Create FTS table with engine.connect() as conn: try: conn.execute( text("CREATE VIRTUAL TABLE docs USING fts5(title, content)") ) conn.execute( text( "INSERT INTO docs VALUES ('First Doc', 'This is searchable content')" ) ) conn.execute( text( "INSERT INTO docs VALUES ('Second Doc', 'More text to search through')" ) ) conn.commit() # Search using FTS result = conn.execute( text("SELECT title FROM docs WHERE docs MATCH 'searchable'") ) rows = result.fetchall() assert len(rows) == 1, f"Expected 1 match, got {len(rows)}" assert rows[0][0] == "First Doc" except Exception as e: # FTS5 may not be compiled in if "no such module" in str(e).lower(): pytest.skip("FTS5 not available") raise def test_savepoint_rollback(self, isolated_db_manager): """Test SAVEPOINT and ROLLBACK TO work with encryption.""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) conn.execute(text("INSERT INTO test VALUES (1, 'original')")) # Create savepoint conn.execute(text("SAVEPOINT sp1")) conn.execute(text("INSERT INTO test VALUES (2, 'after_savepoint')")) # Verify insert result = conn.execute(text("SELECT COUNT(*) FROM test")) assert result.fetchone()[0] == 2 # Rollback to savepoint conn.execute(text("ROLLBACK TO sp1")) # Verify rollback result = conn.execute(text("SELECT COUNT(*) FROM test")) assert result.fetchone()[0] == 1 conn.commit() # ========================================================================= # ADDITIONAL TESTS: Version Compatibility & Migration # ========================================================================= def test_cipher_compatibility_mode(self, tmp_path): """Test PRAGMA cipher_compatibility to open SQLCipher 3.x formatted databases.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "compat_test.db" # Create database with SQLCipher 3 compatibility settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Set compatibility mode to SQLCipher 3 cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'compat_data')") conn.commit() conn.close() # Reopen with same compatibility mode conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result is not None, "Should read data with compatibility mode" assert result[0] == "compat_data" conn.close() def test_cipher_migrate_upgrades_database(self, tmp_path): """Test cipher_migrate to upgrade database from legacy settings.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "migrate_test.db" # Create database with legacy SQLCipher 3 settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute("CREATE TABLE legacy_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO legacy_data VALUES (1, 'old_format_data')") cursor.execute("INSERT INTO legacy_data VALUES (2, 'more_data')") conn.commit() conn.close() # Open with legacy settings and migrate to current format conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_compatibility = 3") # Verify data is accessible before migration cursor.execute("SELECT COUNT(*) FROM legacy_data") count_before = cursor.fetchone()[0] assert count_before == 2, "Should have 2 rows before migration" # Run cipher_migrate to upgrade to current format try: cursor.execute("PRAGMA cipher_migrate") result = cursor.fetchone() # cipher_migrate returns 0 on success, 1 on failure if result is not None: assert result[0] == 0, f"cipher_migrate failed: {result[0]}" except Exception as e: # Some versions may not support cipher_migrate if "not an error" not in str(e).lower(): pytest.skip(f"cipher_migrate not supported: {e}") conn.close() # Now open without compatibility mode (using current defaults) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Don't set compatibility mode - use defaults try: cursor.execute("SELECT COUNT(*) FROM legacy_data") count_after = cursor.fetchone()[0] assert count_after == 2, "Data should persist after migration" except Exception as e: if isinstance(e, AssertionError): raise # If migration didn't work, database may still require compat mode # This is acceptable - we're testing the migration API exists finally: conn.close() def test_cipher_salt_retrievable(self, tmp_path): """Test that cipher_salt can retrieve the 16-byte salt.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "salt_test.db" # Create database conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("CREATE TABLE test (id INTEGER)") conn.commit() # Retrieve the salt cursor.execute("PRAGMA cipher_salt") salt = cursor.fetchone() conn.close() assert salt is not None, "cipher_salt should return a value" salt_value = str(salt[0]) # Salt should be 32 hex characters (16 bytes) # Remove any 'x' prefix if present salt_hex = ( salt_value.replace("x'", "").replace("'", "").replace("X'", "") ) assert len(salt_hex) == 32, ( f"Salt should be 32 hex chars (16 bytes), got {len(salt_hex)}: {salt_hex}" ) # Verify it's valid hex try: int(salt_hex, 16) except ValueError: pytest.fail(f"Salt is not valid hex: {salt_hex}") def test_plaintext_header_with_salt(self, tmp_path): """Test plaintext header mode for iOS WAL compatibility.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "plaintext_header_test.db" # Create database with plaintext header # Note: plaintext_header_size must be set BEFORE the first operation conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Set plaintext header size (first 32 bytes unencrypted for iOS) cursor.execute("PRAGMA cipher_plaintext_header_size = 32") cursor.execute("CREATE TABLE test (id INTEGER, secret TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'sensitive_data')") conn.commit() # Get the salt for external management (must be retrieved while still open) cursor.execute("PRAGMA cipher_salt") salt = cursor.fetchone() salt_value = salt[0] if salt else None conn.close() # Verify file header contains SQLite magic bytes with open(db_path, "rb") as f: header = f.read(32) # First 16 bytes should be SQLite magic sqlite_magic = b"SQLite format 3\x00" assert header[:16] == sqlite_magic, ( "Plaintext header should contain SQLite magic bytes" ) # But the rest of the file should be encrypted with open(db_path, "rb") as f: f.seek(32) # Skip plaintext header encrypted_portion = f.read(100) # Check that sensitive data is NOT in plaintext assert b"sensitive_data" not in encrypted_portion, ( "Data should be encrypted after plaintext header" ) # Reopen database - with plaintext header, salt is stored in # the unencrypted portion, so we just need the key and header size conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_plaintext_header_size = 32") # When using plaintext header, we need to set cipher_salt if the salt # was moved to the plaintext portion if salt_value: # Set the salt - format depends on how it was retrieved try: cursor.execute(f'PRAGMA cipher_salt = "{salt_value}"') except Exception as e: if isinstance(e, AssertionError): raise # If this fails, the salt may already be readable from header try: cursor.execute("SELECT secret FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "sensitive_data", "Should read encrypted data" except Exception as e: # Plaintext header mode has specific requirements that vary by version # If we can't reopen, verify the core feature works: header is plaintext # and data is encrypted error_lower = str(e).lower() if ( "hmac check failed" in error_lower or "not a database" in error_lower or "logic error" in error_lower or "sql error" in error_lower ): # This is acceptable - we verified the header is plaintext # and data is encrypted. The reopen may require additional config # that varies by SQLCipher version pass else: raise finally: conn.close() # ========================================================================= # ADDITIONAL TESTS: Security Edge Cases # ========================================================================= def test_cipher_add_random_entropy(self, tmp_path): """Test adding external entropy to the random number generator.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "entropy_test.db" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Add external entropy (hex string) # This mixes additional randomness into SQLCipher's RNG entropy = "DEADBEEF" * 8 # 32 bytes of entropy try: cursor.execute(f"PRAGMA cipher_add_random = \"x'{entropy}'\"") except Exception as e: if isinstance(e, AssertionError): raise # Some versions may not support this or have different syntax # Verify database still functions cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert len(result) == 1, "Database should function after adding entropy" conn.close() def test_hmac_disabled_mode(self, tmp_path): """Test database with HMAC disabled (less secure, smaller pages).""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "no_hmac_test.db" # Create database with HMAC disabled conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_use_hmac = 0") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'no_hmac_data')") conn.commit() conn.close() # Reopen with same settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("PRAGMA cipher_use_hmac = 0") cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "no_hmac_data", ( "Should read data with HMAC disabled" ) # cipher_integrity_check requires HMAC - should fail or return error try: cursor.execute("PRAGMA cipher_integrity_check") cursor.fetchone() # Result unused - testing integrity check behavior # With HMAC disabled, integrity check may fail or return empty # This is expected behavior except Exception as e: if isinstance(e, AssertionError): raise # Expected - integrity check not available without HMAC conn.close() def test_different_kdf_algorithms(self, tmp_path): """Test database creation with different KDF algorithms.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # DevSkim: ignore DS126858 - Testing different KDF algorithms including legacy SHA1 kdf_algorithms = [ "PBKDF2_HMAC_SHA256", "PBKDF2_HMAC_SHA512", "PBKDF2_HMAC_SHA1", # DevSkim: ignore DS126858 ] for kdf in kdf_algorithms: db_path = tmp_path / f"kdf_{kdf}.db" try: conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute(f"PRAGMA cipher_kdf_algorithm = {kdf}") cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() conn.close() # Reopen with same KDF conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute(f"PRAGMA cipher_kdf_algorithm = {kdf}") cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert len(result) == 1, f"Should read data with KDF {kdf}" conn.close() except Exception as e: # Some KDF algorithms may not be supported if ( "not supported" in str(e).lower() or "invalid" in str(e).lower() ): continue raise def test_different_hmac_algorithms(self, tmp_path): """Test database creation with different HMAC algorithms.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # DevSkim: ignore DS126858 - Testing different HMAC algorithms including legacy SHA1 hmac_algorithms = [ "HMAC_SHA256", "HMAC_SHA512", "HMAC_SHA1", # DevSkim: ignore DS126858 ] for hmac_algo in hmac_algorithms: db_path = tmp_path / f"hmac_{hmac_algo}.db" try: conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute(f"PRAGMA cipher_hmac_algorithm = {hmac_algo}") cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() conn.close() # Reopen with same HMAC algorithm conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute(f"PRAGMA cipher_hmac_algorithm = {hmac_algo}") cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert len(result) == 1, ( f"Should read data with HMAC {hmac_algo}" ) conn.close() except Exception as e: # Some HMAC algorithms may not be supported if ( "not supported" in str(e).lower() or "invalid" in str(e).lower() ): continue raise # ========================================================================= # ADDITIONAL TESTS: Database Operations # ========================================================================= def test_delete_journal_mode(self, isolated_db_manager): """Test encryption works with DELETE journal mode (non-WAL).""" username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Set journal mode to DELETE with engine.connect() as conn: result = conn.execute(text("PRAGMA journal_mode = DELETE")) mode = result.fetchone()[0] assert mode.lower() == "delete", f"Expected DELETE mode, got {mode}" conn.commit() # Create and manipulate data with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER, value TEXT)")) conn.execute( text("INSERT INTO test VALUES (1, 'delete_mode_data')") ) conn.commit() # Close and reopen isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None # Verify data persists with engine.connect() as conn: result = conn.execute(text("SELECT value FROM test WHERE id = 1")) assert result.fetchone()[0] == "delete_mode_data" def test_memory_database_with_key(self, tmp_path): """Test :memory: database behavior with encryption key.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # Create in-memory database with key conn = pysqlcipher3.connect(":memory:") cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Memory databases may or may not support encryption # We're testing that the API doesn't crash try: cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'memory_data')") conn.commit() cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "memory_data" # Check if encryption is actually applied cursor.execute("PRAGMA cipher_version") version = cursor.fetchone() # Memory DB should still report cipher version assert version is not None except Exception as e: # If memory DB doesn't support encryption, that's acceptable # We're documenting the behavior if "not a database" in str(e).lower(): pytest.skip("Memory databases may not support encryption") raise finally: conn.close() def test_backup_preserves_encryption(self, tmp_path): """Test that file copy preserves encryption.""" import shutil from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() original_path = tmp_path / "original.db" backup_path = tmp_path / "backup.db" # Create original encrypted database conn = pysqlcipher3.connect(str(original_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'backup_password'") cursor.execute("CREATE TABLE secrets (id INTEGER, data TEXT)") cursor.execute("INSERT INTO secrets VALUES (1, 'confidential')") conn.commit() conn.close() # Copy file shutil.copy(original_path, backup_path) # Verify backup requires same password conn = pysqlcipher3.connect(str(backup_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'backup_password'") cursor.execute("SELECT data FROM secrets WHERE id = 1") result = cursor.fetchone() assert result[0] == "confidential", "Backup should have same data" conn.close() # Wrong password should fail on backup conn = pysqlcipher3.connect(str(backup_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'wrong_password'") try: cursor.execute("SELECT * FROM secrets") pytest.fail("Wrong password should fail on backup") except Exception as e: assert "not a database" in str(e).lower() finally: conn.close() def test_database_shrinks_after_vacuum(self, tmp_path): """Test that VACUUM reduces file size and encryption remains intact.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "vacuum_size_test.db" # Create database with lots of data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("CREATE TABLE big_data (id INTEGER, data TEXT)") # Insert lots of data big_string = "X" * 1000 for i in range(1000): cursor.execute(f"INSERT INTO big_data VALUES ({i}, '{big_string}')") conn.commit() # Delete most data cursor.execute("DELETE FROM big_data WHERE id > 100") conn.commit() # Size shouldn't change much without VACUUM size_after_delete = db_path.stat().st_size # Run VACUUM cursor.execute("VACUUM") conn.close() # Size should be significantly smaller size_after_vacuum = db_path.stat().st_size assert size_after_vacuum < size_after_delete, ( f"VACUUM should reduce size: {size_after_delete} -> {size_after_vacuum}" ) # Verify encryption still works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") cursor.execute("SELECT COUNT(*) FROM big_data") count = cursor.fetchone()[0] assert count == 101, f"Expected 101 rows, got {count}" conn.close() # Verify file is still encrypted with open(db_path, "rb") as f: header = f.read(16) assert header != b"SQLite format 3\x00", ( "Should still be encrypted after VACUUM" ) # ========================================================================= # ADDITIONAL TESTS: Error Handling # ========================================================================= def test_rekey_with_wrong_current_password(self, isolated_db_manager): """Test that rekey fails gracefully with wrong current password.""" username = "testuser" password = "CorrectPassword123!" # Create database isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] with engine.connect() as conn: conn.execute(text("CREATE TABLE test (id INTEGER)")) conn.execute(text("INSERT INTO test VALUES (1)")) conn.commit() isolated_db_manager.close_user_database(username) # Attempt to change password with wrong current password result = isolated_db_manager.change_password( username, "WrongPassword!", "NewPassword123!" ) # Should fail assert result is False, "Rekey should fail with wrong current password" # Original password should still work engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Original password should still work" with engine.connect() as conn: result = conn.execute(text("SELECT * FROM test")) assert len(result.fetchall()) == 1 def test_attach_with_wrong_key_fails(self, tmp_path): """Test ATTACH DATABASE with wrong key provides meaningful error.""" from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # Create main database main_db_path = tmp_path / "main_attach.db" conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'main_key'") cursor.execute("CREATE TABLE main_data (id INTEGER)") conn.commit() conn.close() # Create secondary database with different key second_db_path = tmp_path / "second_attach.db" conn = pysqlcipher3.connect(str(second_db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'second_key'") cursor.execute("CREATE TABLE second_data (id INTEGER)") conn.commit() conn.close() # Open main and try to attach secondary with WRONG key conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'main_key'") # Attach with wrong key - may fail at ATTACH or at query time # depending on SQLCipher version try: cursor.execute( f"ATTACH DATABASE '{second_db_path}' AS second KEY 'wrong_key'" ) # If ATTACH succeeded, try to access the attached database cursor.execute("SELECT * FROM second.second_data") cursor.fetchall() # Result unused - testing if query fails pytest.fail( "Should fail when accessing database attached with wrong key" ) except Exception as e: error_msg = str(e).lower() # Should get a meaningful error about the database assert ( "not a database" in error_msg or "no such table" in error_msg or "unable to open" in error_msg or "encrypted" in error_msg or "file is encrypted" in error_msg or "error" in error_msg ), f"Expected meaningful error, got: {e}" finally: conn.close() # ========================================================================= # ADDITIONAL TESTS: Gap Coverage (Official SQLCipher Test Suite Alignment) # ========================================================================= def test_multiple_key_pragma_calls_safe(self, tmp_path): """Verify setting wrong key then correct key works (official test: multiple-key-calls-safe). This test ensures that if a user attempts to open with the wrong key and then provides the correct key, the database will authenticate correctly without corruption. This is important for applications that may retry key entry. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "multi_key_test.db" correct_password = "correct_password_123" wrong_password = "wrong_password_456" # Create encrypted database conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{correct_password}'") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'secret_data')") conn.commit() conn.close() # Reopen - first with wrong key (should fail silently until first op) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{wrong_password}'") # Try an operation - this should fail try: cursor.execute("SELECT * FROM test") cursor.fetchall() wrong_key_succeeded = True except Exception: wrong_key_succeeded = False assert not wrong_key_succeeded, "Operation with wrong key should fail" conn.close() # Now set the correct key on a fresh connection conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{correct_password}'") # This should now work cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result is not None, "Should be able to read with correct key" assert result[0] == "secret_data", "Data should be intact" conn.close() def test_rekey_in_wal_mode(self, tmp_path): """Verify rekey works correctly with WAL journal mode. WAL mode + rekey is a known problem area in SQLCipher. This test ensures that rekeying a database operating in WAL mode works correctly and that WAL files (if present) are properly re-encrypted. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "wal_rekey_test.db" original_password = "original_pass_123" new_password = "new_pass_456" # Create database with WAL mode conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") cursor.execute("PRAGMA journal_mode = WAL") # Verify WAL mode is active cursor.execute("PRAGMA journal_mode") mode = cursor.fetchone()[0] if mode.lower() != "wal": conn.close() pytest.skip("WAL mode not available in this configuration") # Create table and insert data cursor.execute("CREATE TABLE wal_test (id INTEGER, value TEXT)") cursor.execute( "INSERT INTO wal_test VALUES (1, 'wal_data_before_rekey')" ) cursor.execute("INSERT INTO wal_test VALUES (2, 'more_wal_data')") conn.commit() # Perform rekey while in WAL mode cursor.execute(f"PRAGMA rekey = '{new_password}'") conn.commit() conn.close() # Verify WAL file is properly handled (may or may not exist) wal_path = tmp_path / "wal_rekey_test.db-wal" # If WAL file exists, verify it doesn't contain plaintext data if wal_path.exists(): with open(wal_path, "rb") as f: wal_content = f.read() assert b"wal_data_before_rekey" not in wal_content, ( "WAL file should not contain plaintext data" ) # Reopen with new password and verify data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{new_password}'") cursor.execute("SELECT COUNT(*) FROM wal_test") count = cursor.fetchone()[0] assert count == 2, f"Expected 2 rows, got {count}" cursor.execute("SELECT value FROM wal_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "wal_data_before_rekey", ( "Data should persist after rekey" ) conn.close() # Verify old password no longer works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") try: cursor.execute("SELECT * FROM wal_test") pytest.fail("Old password should no longer work") except Exception as e: assert "not a database" in str(e).lower() finally: conn.close() def test_kdf_iterations_mismatch_fails(self, tmp_path): """Verify mismatched KDF iterations causes 'file is not a database'. This is a common migration failure cause. When a database is created with specific KDF iteration count, it must be opened with the same count. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "kdf_mismatch_test.db" password = "test_password_123" # Create database with high iteration count (SQLCipher 4 default: 256000) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Uses the default high iteration count (256000 for SQLCipher 4) cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() conn.close() # Try to open with different (lower) iteration count conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Set a different KDF iteration count before key cursor.execute("PRAGMA cipher_default_kdf_iter = 1000") cursor.execute(f"PRAGMA key = '{password}'") # This should fail because KDF iterations don't match try: cursor.execute("SELECT * FROM test") cursor.fetchall() # If it succeeds, check if kdf_iter was ignored cursor.execute("PRAGMA kdf_iter") actual_kdf = cursor.fetchone()[0] if int(actual_kdf) != 1000: # cipher_default_kdf_iter was likely ignored, which is acceptable conn.close() pytest.skip( "cipher_default_kdf_iter not supported in this version" ) pytest.fail("Should fail with mismatched KDF iterations") except Exception as e: error_msg = str(e).lower() assert "not a database" in error_msg or "error" in error_msg, ( f"Expected 'file is not a database' error, got: {e}" ) finally: conn.close() def test_rekey_on_empty_database(self, tmp_path): """Verify rekey behavior on brand new empty database. Official test case: rekey-as-first-op-on-empty. Tests what happens when rekey is called on a database that has been keyed but has no tables. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "empty_rekey_test.db" original_password = "original_pass" new_password = "new_pass" # Create new database, set key but don't create any tables conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") # Immediately try to rekey (no tables created yet) # SQLCipher should handle this gracefully try: cursor.execute(f"PRAGMA rekey = '{new_password}'") conn.commit() rekey_succeeded = True except Exception: rekey_succeeded = False conn.close() if rekey_succeeded: # If rekey succeeded, verify new password works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{new_password}'") # Should be able to create tables with new key cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert len(result) == 1, ( "Should be able to use rekeyed empty database" ) conn.close() else: # If rekey failed, verify original password still works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") # Should still be usable cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() conn.close() def test_rekey_with_same_password(self, tmp_path): """Verify rekeying with identical password doesn't corrupt database. Edge case: what happens when you rekey a database with the same password? This should not corrupt the database. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "same_pass_rekey_test.db" password = "same_password_123" # Create database with data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'original_data')") cursor.execute("INSERT INTO test VALUES (2, 'more_data')") conn.commit() # Rekey with the SAME password cursor.execute(f"PRAGMA rekey = '{password}'") conn.commit() conn.close() # Verify database is not corrupted conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT COUNT(*) FROM test") count = cursor.fetchone()[0] assert count == 2, ( f"Expected 2 rows after same-password rekey, got {count}" ) cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "original_data", "Data should be intact" # Run integrity check cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] assert integrity.lower() == "ok", f"Integrity check failed: {integrity}" conn.close() def test_attach_database_inherits_default_key(self, tmp_path): """Verify ATTACH without KEY uses main database key. Official test: attach-database-with-default-key. When attaching a database without specifying a KEY parameter, it should use the main database's key. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() shared_password = "shared_key_123" # Create main database main_db_path = tmp_path / "main_default_key.db" conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{shared_password}'") cursor.execute("CREATE TABLE main_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO main_data VALUES (1, 'main_value')") conn.commit() conn.close() # Create second database with the SAME password second_db_path = tmp_path / "second_default_key.db" conn = pysqlcipher3.connect(str(second_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{shared_password}'") cursor.execute("CREATE TABLE second_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO second_data VALUES (1, 'second_value')") conn.commit() conn.close() # Open main and attach second WITHOUT specifying KEY conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{shared_password}'") # Attach without KEY parameter - should use main database key # Note: This behavior may vary by SQLCipher version try: cursor.execute(f"ATTACH DATABASE '{second_db_path}' AS second") # Try to access the attached database cursor.execute("SELECT value FROM second.second_data WHERE id = 1") result = cursor.fetchone() assert result[0] == "second_value", ( "Should access attached DB with inherited key" ) # Verify main database still works cursor.execute("SELECT value FROM main_data WHERE id = 1") result = cursor.fetchone() assert result[0] == "main_value", "Main DB should still work" except Exception as e: error_msg = str(e).lower() # If ATTACH without key doesn't inherit key, it may fail # This is acceptable - we're documenting the behavior if "not a database" in error_msg or "unable to open" in error_msg: pytest.skip( "This SQLCipher version requires explicit KEY for ATTACH" ) raise finally: conn.close() def test_schema_alteration_persists(self, isolated_db_manager): """Verify ALTER TABLE changes persist through database reopen. Official test: alter-schema. Tests that schema modifications like ALTER TABLE ADD COLUMN are properly persisted in encrypted databases. """ username = "testuser" password = "SecurePassword123!" isolated_db_manager.create_user_database(username, password) from sqlalchemy import text engine = isolated_db_manager.connections[username] # Create initial table with engine.connect() as conn: conn.execute( text("CREATE TABLE alter_test (id INTEGER PRIMARY KEY)") ) conn.execute(text("INSERT INTO alter_test VALUES (1)")) conn.commit() # Alter table - add new column with engine.connect() as conn: conn.execute(text("ALTER TABLE alter_test ADD COLUMN new_col TEXT")) conn.execute( text( "UPDATE alter_test SET new_col = 'added_value' WHERE id = 1" ) ) conn.commit() # Verify change before close with engine.connect() as conn: result = conn.execute( text("SELECT new_col FROM alter_test WHERE id = 1") ) assert result.fetchone()[0] == "added_value" # Close and reopen database isolated_db_manager.close_user_database(username) engine = isolated_db_manager.open_user_database(username, password) assert engine is not None, "Should reopen database" # Verify schema change persisted with engine.connect() as conn: # Check column exists result = conn.execute(text("PRAGMA table_info(alter_test)")) columns = [row[1] for row in result.fetchall()] assert "new_col" in columns, "Added column should persist" # Check data persisted result = conn.execute( text("SELECT new_col FROM alter_test WHERE id = 1") ) assert result.fetchone()[0] == "added_value", ( "Data in new column should persist" ) def test_cipher_default_compatibility_pragma(self, tmp_path): """Verify cipher_default_compatibility sets process-wide default. Tests that cipher_default_compatibility pragma works for setting a process-wide default compatibility mode for all new connections. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "default_compat_test.db" # Create in-memory connection to set default compatibility setup_conn = pysqlcipher3.connect(":memory:") setup_cursor = setup_conn.cursor() try: # Set process-wide default compatibility to SQLCipher 3 setup_cursor.execute("PRAGMA cipher_default_compatibility = 3") except Exception as e: setup_conn.close() if "error" in str(e).lower() or "not an error" in str(e).lower(): pytest.skip("cipher_default_compatibility not supported") raise setup_conn.close() # Now create a new database - it should use SQLCipher 3 settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Verify compatibility settings are applied (query should not error) # Note: cipher_default_compatibility may not persist to individual DBs # The important thing is the pragma doesn't error try: cursor.execute("PRAGMA cipher_compatibility") cursor.fetchone() # Just verify the query succeeds except Exception as e: if isinstance(e, AssertionError): raise # Some versions may not support querying compatibility # Create and verify database works cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() cursor.execute("SELECT * FROM test") result = cursor.fetchall() assert len(result) == 1, ( "Database should work after setting default compat" ) conn.close() # Reset to default (SQLCipher 4 compatibility) reset_conn = pysqlcipher3.connect(":memory:") reset_cursor = reset_conn.cursor() try: reset_cursor.execute("PRAGMA cipher_default_compatibility = 4") except Exception as e: if isinstance(e, AssertionError): raise # May not be supported reset_conn.close() def test_uri_key_parameter(self, tmp_path): """Verify encryption key can be provided via URI parameter. Tests opening a database with the key specified in the URI connection string rather than via PRAGMA key. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "uri_key_test.db" password = "uri_test_password" # First, create the database traditionally conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'uri_data')") conn.commit() conn.close() # Now try to open using URI with key parameter # URI format: file:path?key=password uri = f"file:{db_path}?key={password}" try: # Try connecting with URI - requires uri=True parameter conn = pysqlcipher3.connect(uri, uri=True) cursor = conn.cursor() # Key should already be set from URI cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "uri_data", "Should read data via URI key" conn.close() except TypeError: # If uri parameter not supported, try without it try: conn = pysqlcipher3.connect(uri) cursor = conn.cursor() cursor.execute("SELECT value FROM test WHERE id = 1") result = cursor.fetchone() assert result[0] == "uri_data", "Should read data via URI key" conn.close() except Exception as e: if ( "uri" in str(e).lower() or "not a database" in str(e).lower() ): pytest.skip( "URI key parameter not supported in this version" ) raise except Exception as e: error_msg = str(e).lower() if "not a database" in error_msg or "unable to open" in error_msg: pytest.skip("URI key parameter not supported in this version") raise def test_memory_database_integrity_check_behavior(self, tmp_path): """Verify cipher_integrity_check behavior on :memory: database. Official test: memory-integrity-check-should-fail. Tests what happens when cipher_integrity_check is called on an in-memory database (which has no file to verify). """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() # Open :memory: database with key conn = pysqlcipher3.connect(":memory:") cursor = conn.cursor() cursor.execute("PRAGMA key = 'test_password'") # Create some data cursor.execute("CREATE TABLE test (id INTEGER)") cursor.execute("INSERT INTO test VALUES (1)") conn.commit() # Call cipher_integrity_check on memory database try: cursor.execute("PRAGMA cipher_integrity_check") cursor.fetchone() # Memory DB integrity check behavior varies by version # It may return an error message, empty result, or skip # Memory databases don't have files, so integrity check # should either fail, return error, or return empty # All of these behaviors are acceptable except Exception as e: # It's acceptable for cipher_integrity_check to raise an error # on memory databases since there's no file to check error_msg = str(e).lower() # Verify it's a reasonable error about the memory database assert ( "memory" in error_msg or "undefined" in error_msg or "error" in error_msg or "not" in error_msg ), f"Unexpected error for memory DB integrity check: {e}" finally: conn.close() def test_rekey_attached_database_by_name(self, tmp_path): """Verify rekey can target attached database by schema name. Official test: rekey-database-by-name. Tests that PRAGMA schema.rekey can be used to rekey an attached database without affecting the main database. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() main_password = "main_pass_123" attached_original_password = "attached_pass_123" attached_new_password = "attached_new_pass_456" # Create main database main_db_path = tmp_path / "main_rekey_by_name.db" conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{main_password}'") cursor.execute("CREATE TABLE main_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO main_data VALUES (1, 'main_value')") conn.commit() conn.close() # Create attached database attached_db_path = tmp_path / "attached_rekey_by_name.db" conn = pysqlcipher3.connect(str(attached_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{attached_original_password}'") cursor.execute("CREATE TABLE attached_data (id INTEGER, value TEXT)") cursor.execute("INSERT INTO attached_data VALUES (1, 'attached_value')") conn.commit() conn.close() # Open main and attach second database conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{main_password}'") cursor.execute( f"ATTACH DATABASE '{attached_db_path}' AS other KEY '{attached_original_password}'" ) # Verify both databases accessible cursor.execute("SELECT value FROM main_data WHERE id = 1") assert cursor.fetchone()[0] == "main_value" cursor.execute("SELECT value FROM other.attached_data WHERE id = 1") assert cursor.fetchone()[0] == "attached_value" # Rekey ONLY the attached database using schema qualifier try: cursor.execute(f"PRAGMA other.rekey = '{attached_new_password}'") rekey_worked = True except Exception as e: rekey_worked = False rekey_error = str(e) # Schema-qualified rekey may not be supported in all versions if "error" in rekey_error.lower(): conn.close() pytest.skip("Schema-qualified PRAGMA rekey not supported") raise conn.commit() conn.close() if rekey_worked: # Verify main database still uses original password conn = pysqlcipher3.connect(str(main_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{main_password}'") cursor.execute("SELECT value FROM main_data WHERE id = 1") assert cursor.fetchone()[0] == "main_value", ( "Main DB should still use original password" ) conn.close() # Verify attached database now uses new password conn = pysqlcipher3.connect(str(attached_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{attached_new_password}'") cursor.execute("SELECT value FROM attached_data WHERE id = 1") assert cursor.fetchone()[0] == "attached_value", ( "Attached DB should use new password" ) conn.close() # Verify attached database's original password no longer works conn = pysqlcipher3.connect(str(attached_db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{attached_original_password}'") try: cursor.execute("SELECT * FROM attached_data") pytest.fail( "Original password should no longer work on attached DB" ) except Exception as e: assert "not a database" in str(e).lower() finally: conn.close() # ========================================================================= # ADDITIONAL TESTS: Codec Error Recovery & Backup API (Gap Coverage) # Based on official SQLCipher test suite: sqlcipher-codecerror.test, # sqlcipher-backup.test, sqlcipher-integrity.test # ========================================================================= def test_codec_error_recovery_delete_mode(self, tmp_path): """Test database recovery after simulated write failure in DELETE journal mode. Based on official test: codec-error-journal-delete. Verifies that transaction rollback preserves data integrity when encryption encounters issues during write operations. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "codec_error_delete_mode.db" password = "test_password_123" # Create database with DELETE journal mode conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA journal_mode = DELETE") # Verify DELETE mode cursor.execute("PRAGMA journal_mode") mode = cursor.fetchone()[0] assert mode.lower() == "delete", f"Expected DELETE mode, got {mode}" # Create table and insert initial data cursor.execute("CREATE TABLE recovery_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO recovery_test VALUES (1, 'original_data')") cursor.execute( "INSERT INTO recovery_test VALUES (2, 'more_original_data')" ) conn.commit() # Start a transaction, make changes, then rollback # This simulates what happens when an error occurs during write cursor.execute("BEGIN TRANSACTION") cursor.execute( "UPDATE recovery_test SET value = 'modified' WHERE id = 1" ) cursor.execute("INSERT INTO recovery_test VALUES (3, 'new_row')") # Rollback the transaction (simulates recovery from error) conn.rollback() # Verify original data is intact cursor.execute("SELECT value FROM recovery_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "original_data", ( "Original data should be preserved after rollback" ) cursor.execute("SELECT COUNT(*) FROM recovery_test") count = cursor.fetchone()[0] assert count == 2, f"Expected 2 rows after rollback, got {count}" conn.close() # Verify database is not corrupted - reopen and check conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] assert integrity.lower() == "ok", ( f"Integrity check failed after recovery: {integrity}" ) cursor.execute("SELECT COUNT(*) FROM recovery_test") count = cursor.fetchone()[0] assert count == 2, "Data should persist correctly after reopen" conn.close() def test_codec_error_recovery_wal_mode(self, tmp_path): """Test database recovery after simulated write failure in WAL mode. Based on official test: codec-error-journal-wal. WAL mode has different recovery characteristics than DELETE mode. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "codec_error_wal_mode.db" password = "test_password_123" # Create database with WAL mode conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA journal_mode = WAL") # Verify WAL mode cursor.execute("PRAGMA journal_mode") mode = cursor.fetchone()[0] if mode.lower() != "wal": conn.close() pytest.skip("WAL mode not available in this configuration") # Create table and insert initial data cursor.execute( "CREATE TABLE wal_recovery_test (id INTEGER, value TEXT)" ) cursor.execute( "INSERT INTO wal_recovery_test VALUES (1, 'wal_original')" ) cursor.execute( "INSERT INTO wal_recovery_test VALUES (2, 'wal_more_data')" ) conn.commit() # Start a transaction, make changes, then rollback cursor.execute("BEGIN TRANSACTION") cursor.execute( "UPDATE wal_recovery_test SET value = 'wal_modified' WHERE id = 1" ) cursor.execute( "INSERT INTO wal_recovery_test VALUES (3, 'wal_new_row')" ) # Rollback (simulates error recovery in WAL mode) conn.rollback() # Verify original data is intact cursor.execute("SELECT value FROM wal_recovery_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "wal_original", ( "Original data should be preserved after WAL rollback" ) cursor.execute("SELECT COUNT(*) FROM wal_recovery_test") count = cursor.fetchone()[0] assert count == 2, f"Expected 2 rows after WAL rollback, got {count}" conn.close() # Check WAL file doesn't contain plaintext data wal_path = tmp_path / "codec_error_wal_mode.db-wal" if wal_path.exists(): with open(wal_path, "rb") as f: wal_content = f.read() assert b"wal_original" not in wal_content, ( "WAL file should not contain plaintext data" ) # Reopen and verify integrity conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] assert integrity.lower() == "ok", ( f"Integrity check failed after WAL recovery: {integrity}" ) conn.close() def test_decryption_failure_during_read(self, tmp_path): """Test behavior when decryption fails during read operation. Based on official test: codec-error-journal-wal-read. When a page is corrupted, decryption should fail with HMAC check failure. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "decryption_failure_test.db" password = "test_password_123" # Create database with multiple pages of data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Create table and insert enough data to span multiple pages cursor.execute("CREATE TABLE multi_page_test (id INTEGER, data TEXT)") # Insert many rows to ensure multiple pages large_string = "X" * 500 for i in range(100): cursor.execute( f"INSERT INTO multi_page_test VALUES ({i}, '{large_string}')" ) conn.commit() conn.close() # Get file size to determine where to corrupt file_size = db_path.stat().st_size # Corrupt a page in the middle of the file (not the first page) # The first page contains the header, so we corrupt a data page with open(db_path, "r+b") as f: # Seek to middle of file corrupt_offset = file_size // 2 f.seek(corrupt_offset) original_bytes = f.read(32) f.seek(corrupt_offset) # Flip all bits to ensure corruption corrupted = bytes([b ^ 0xFF for b in original_bytes]) f.write(corrupted) # Try to read from corrupted database conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Attempt to read data - should eventually hit the corrupted page try: cursor.execute("SELECT COUNT(*) FROM multi_page_test") cursor.fetchone() cursor.execute("SELECT * FROM multi_page_test") cursor.fetchall() # If we get here, corruption might not have affected readable pages # Run integrity check to detect corruption cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] # Integrity check should detect the corruption if integrity.lower() != "ok": # Corruption detected - expected behavior pass except Exception as e: error_msg = str(e).lower() # Expected errors for corrupted encrypted database assert ( "corrupt" in error_msg or "hmac" in error_msg or "not a database" in error_msg or "decrypt" in error_msg or "disk" in error_msg or "i/o" in error_msg or "malformed" in error_msg ), f"Expected decryption/corruption error, got: {e}" finally: conn.close() def test_backup_api_encrypted_to_encrypted(self, tmp_path): """Test SQLite backup API between two encrypted databases. Based on official test: backup-encrypted-encrypted. Uses Python's connection.backup() method to copy data between encrypted databases while preserving encryption. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() source_path = tmp_path / "backup_source.db" dest_path = tmp_path / "backup_dest.db" source_password = "source_pass_123" dest_password = "dest_pass_456" # Create source encrypted database with data source_conn = pysqlcipher3.connect(str(source_path)) source_cursor = source_conn.cursor() source_cursor.execute(f"PRAGMA key = '{source_password}'") source_cursor.execute( "CREATE TABLE backup_test (id INTEGER, value TEXT)" ) source_cursor.execute( "INSERT INTO backup_test VALUES (1, 'backup_value_1')" ) source_cursor.execute( "INSERT INTO backup_test VALUES (2, 'backup_value_2')" ) source_cursor.execute( "INSERT INTO backup_test VALUES (3, 'backup_value_3')" ) source_conn.commit() # Create destination encrypted database dest_conn = pysqlcipher3.connect(str(dest_path)) dest_cursor = dest_conn.cursor() dest_cursor.execute(f"PRAGMA key = '{dest_password}'") # Need to initialize the database with at least one operation dest_cursor.execute("SELECT 1") dest_conn.commit() # Perform backup using Python's backup API try: source_conn.backup(dest_conn) backup_succeeded = True except AttributeError: # backup() method not available in this Python/SQLite version backup_succeeded = False pytest.skip("backup() method not available") except Exception as e: # Backup between differently-keyed databases may fail # This is expected - documenting the behavior error_msg = str(e).lower() if ( "readonly" in error_msg or "not a database" in error_msg or "encrypted" in error_msg ): backup_succeeded = False else: raise source_conn.close() dest_conn.close() if backup_succeeded: # Verify destination has the source's data # Note: After backup, dest uses source's encryption dest_conn = pysqlcipher3.connect(str(dest_path)) dest_cursor = dest_conn.cursor() dest_cursor.execute(f"PRAGMA key = '{source_password}'") try: dest_cursor.execute("SELECT COUNT(*) FROM backup_test") count = dest_cursor.fetchone()[0] assert count == 3, f"Expected 3 rows in backup, got {count}" dest_cursor.execute( "SELECT value FROM backup_test WHERE id = 1" ) result = dest_cursor.fetchone() assert result[0] == "backup_value_1", ( "Backup data should match source" ) except Exception as e: if isinstance(e, AssertionError): raise # If source key doesn't work, backup may have failed silently finally: dest_conn.close() def test_backup_api_blocks_plaintext_to_encrypted(self, tmp_path): """Test backup from plaintext to encrypted fails appropriately. Based on official test: backup-plain-encrypted. Verifies that attempting to backup a plaintext database to an encrypted database produces an appropriate error. """ import sqlite3 from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() plain_path = tmp_path / "plain_source.db" encrypted_path = tmp_path / "encrypted_dest.db" password = "encrypted_pass_123" # Create plaintext source database plain_conn = sqlite3.connect(str(plain_path)) plain_cursor = plain_conn.cursor() plain_cursor.execute("CREATE TABLE plain_data (id INTEGER, value TEXT)") plain_cursor.execute("INSERT INTO plain_data VALUES (1, 'plain_value')") plain_conn.commit() # Create encrypted destination database encrypted_conn = pysqlcipher3.connect(str(encrypted_path)) encrypted_cursor = encrypted_conn.cursor() encrypted_cursor.execute(f"PRAGMA key = '{password}'") encrypted_cursor.execute("SELECT 1") # Initialize encrypted_conn.commit() # Attempt backup from plaintext to encrypted try: plain_conn.backup(encrypted_conn) backup_succeeded = True except AttributeError: pytest.skip("backup() method not available") except TypeError: # Cross-module backup (sqlite3 -> sqlcipher) not supported # This is expected - documents that plain sqlite3.Connection # cannot backup to sqlcipher3.Connection backup_succeeded = False except Exception as e: backup_succeeded = False error_msg = str(e).lower() # Expect some kind of error about incompatible databases assert ( "error" in error_msg or "readonly" in error_msg or "database" in error_msg or "connection" in error_msg ), f"Expected database error, got: {e}" plain_conn.close() encrypted_conn.close() if backup_succeeded: # If backup succeeded, verify the dest is now plaintext (overwrote encryption) # or verify it still has the plaintext data accessible # This behavior documents what actually happens verify_conn = pysqlcipher3.connect(str(encrypted_path)) verify_cursor = verify_conn.cursor() verify_cursor.execute(f"PRAGMA key = '{password}'") try: verify_cursor.execute("SELECT * FROM plain_data") # If this works, backup overwrote the encrypted DB with plaintext except Exception as e: if isinstance(e, AssertionError): raise # If this fails, the backup may have been blocked or corrupted the DB finally: verify_conn.close() def test_integrity_check_detects_last_page_tampering(self, tmp_path): """Test cipher_integrity_check detects tampering on last page. Based on official test: version-4-integrity-check-invalid-last-page. The last page of an encrypted database is important for integrity because it contains crucial metadata. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "last_page_tampering_test.db" password = "test_password_123" # Create database with multiple pages conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Get page size for calculations cursor.execute("PRAGMA page_size") page_size = int(cursor.fetchone()[0]) # Create enough data to span multiple pages cursor.execute("CREATE TABLE tampering_test (id INTEGER, data TEXT)") large_string = "Y" * 500 for i in range(200): # Create lots of data cursor.execute( f"INSERT INTO tampering_test VALUES ({i}, '{large_string}')" ) conn.commit() conn.close() # Get file size and tamper with the last page file_size = db_path.stat().st_size last_page_start = file_size - page_size # Ensure we're tampering with actual data, not before the file if last_page_start > 0: with open(db_path, "r+b") as f: f.seek(last_page_start + 100) # 100 bytes into last page original = f.read(64) f.seek(last_page_start + 100) # Flip bits to tamper tampered = bytes([b ^ 0xFF for b in original]) f.write(tampered) # Try to open and run integrity check conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") tampering_detected = False try: # cipher_integrity_check should detect HMAC failures cursor.execute("PRAGMA cipher_integrity_check") result = cursor.fetchall() # If cipher_integrity_check returns errors, tampering was detected if result and len(result) > 0: for row in result: if row[0] and "ok" not in str(row[0]).lower(): tampering_detected = True break # Also try regular integrity_check cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] if integrity.lower() != "ok": tampering_detected = True except Exception as e: # Exception during integrity check also indicates tampering detected error_msg = str(e).lower() if ( "corrupt" in error_msg or "hmac" in error_msg or "disk" in error_msg or "malformed" in error_msg ): tampering_detected = True finally: conn.close() # Tampering should be detected (either via integrity check or exception). # The 64-byte XOR-0xFF flip above invalidates the per-page HMAC, which # cipher_integrity_check / integrity_check are required to report. assert tampering_detected, "Last page tampering should be detectable" def test_integrity_check_with_plaintext_header(self, tmp_path): """Test cipher_integrity_check works with plaintext header enabled. Based on official test: integrity-check-plaintext-header. Verifies that integrity checks function correctly when the database uses a plaintext header (for iOS compatibility). """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "plaintext_header_integrity_test.db" password = "test_password_123" # Create database with plaintext header conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA cipher_plaintext_header_size = 32") # Create table and insert data cursor.execute("CREATE TABLE header_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO header_test VALUES (1, 'header_data_1')") cursor.execute("INSERT INTO header_test VALUES (2, 'header_data_2')") conn.commit() # Run cipher_integrity_check try: cursor.execute("PRAGMA cipher_integrity_check") result = cursor.fetchall() # Should return 'ok' or empty result for valid database integrity_ok = True if result: for row in result: if row[0] and "ok" not in str(row[0]).lower(): if str(row[0]).strip(): # Non-empty error integrity_ok = False break assert integrity_ok, ( f"Integrity check failed with plaintext header: {result}" ) except Exception as e: error_msg = str(e).lower() # Some versions may not support cipher_integrity_check if "undefined" in error_msg or "no such" in error_msg: pytest.skip("cipher_integrity_check not supported") raise # Also verify standard integrity_check cursor.execute("PRAGMA integrity_check") std_integrity = cursor.fetchone()[0] assert std_integrity.lower() == "ok", ( f"Standard integrity check failed: {std_integrity}" ) conn.close() # Verify file has plaintext header (SQLite magic bytes) with open(db_path, "rb") as f: header = f.read(16) assert header == b"SQLite format 3\x00", ( "File should have plaintext SQLite header" ) def test_key_change_mid_session(self, tmp_path): """Test behavior when attempting to change key mid-session. Based on official test: change-key-middle. Tests what happens when PRAGMA rekey is called after performing database operations (not just after opening). """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "mid_session_key_change.db" original_password = "original_pass_123" new_password = "new_pass_456" # Create database and perform operations conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") # Create table and insert data cursor.execute("CREATE TABLE mid_session_test (id INTEGER, value TEXT)") cursor.execute( "INSERT INTO mid_session_test VALUES (1, 'initial_data')" ) conn.commit() # Perform more operations (we're mid-session now) cursor.execute("SELECT * FROM mid_session_test") cursor.fetchall() cursor.execute("INSERT INTO mid_session_test VALUES (2, 'more_data')") conn.commit() # Now attempt to rekey mid-session try: cursor.execute(f"PRAGMA rekey = '{new_password}'") conn.commit() rekey_succeeded = True except Exception: rekey_succeeded = False conn.close() if rekey_succeeded: # Verify new password works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{new_password}'") cursor.execute("SELECT COUNT(*) FROM mid_session_test") count = cursor.fetchone()[0] assert count == 2, f"Expected 2 rows, got {count}" cursor.execute("SELECT value FROM mid_session_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "initial_data", ( "Data should persist after mid-session rekey" ) conn.close() # Verify old password no longer works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") try: cursor.execute("SELECT * FROM mid_session_test") pytest.fail("Old password should not work after rekey") except Exception as e: assert "not a database" in str(e).lower() finally: conn.close() else: # If rekey failed mid-session, verify database is not corrupted # and original password still works conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") cursor.execute("SELECT COUNT(*) FROM mid_session_test") count = cursor.fetchone()[0] assert count == 2, "Data should be intact even if rekey failed" conn.close() def test_sqlcipher_export_unencrypted_to_encrypted(self, tmp_path): """Test sqlcipher_export to encrypt a plaintext database. Based on official test: unencrypted-to-encrypted-export. Uses sqlcipher_export() to create an encrypted copy of a plaintext SQLite database. """ import sqlite3 from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() plain_path = tmp_path / "plaintext_source.db" encrypted_path = tmp_path / "encrypted_export.db" password = "export_password_123" # Create plaintext database with standard sqlite3 plain_conn = sqlite3.connect(str(plain_path)) plain_cursor = plain_conn.cursor() plain_cursor.execute( "CREATE TABLE export_test (id INTEGER, value TEXT)" ) plain_cursor.execute( "INSERT INTO export_test VALUES (1, 'export_value_1')" ) plain_cursor.execute( "INSERT INTO export_test VALUES (2, 'export_value_2')" ) plain_cursor.execute( "INSERT INTO export_test VALUES (3, 'export_value_3')" ) plain_conn.commit() plain_conn.close() # Open plaintext database with SQLCipher (no key) conn = pysqlcipher3.connect(str(plain_path)) cursor = conn.cursor() # Don't set key - it's plaintext # Verify we can read plaintext data cursor.execute("SELECT COUNT(*) FROM export_test") count = cursor.fetchone()[0] assert count == 3, "Should read plaintext data" # Attach encrypted destination with key cursor.execute( f"ATTACH DATABASE '{encrypted_path}' AS encrypted KEY '{password}'" ) # Export to encrypted database try: cursor.execute("SELECT sqlcipher_export('encrypted')") cursor.execute("DETACH DATABASE encrypted") export_succeeded = True except Exception as e: export_succeeded = False export_error = str(e) conn.close() if export_succeeded: # Verify encrypted database has the data conn = pysqlcipher3.connect(str(encrypted_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT COUNT(*) FROM export_test") count = cursor.fetchone()[0] assert count == 3, f"Expected 3 rows in export, got {count}" cursor.execute("SELECT value FROM export_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "export_value_1", "Exported data should match" conn.close() # Verify file is actually encrypted (no plaintext header) with open(encrypted_path, "rb") as f: header = f.read(16) assert header != b"SQLite format 3\x00", ( "Exported database should be encrypted" ) else: pytest.skip(f"sqlcipher_export not supported: {export_error}") def test_migration_failure_handling(self, tmp_path): """Test cipher_migrate behavior on incompatible database. Based on official test: migrate-failure. Verifies that cipher_migrate handles errors gracefully when attempting to migrate a database with incompatible settings. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "migrate_failure_test.db" password = "test_password_123" # Create database with SQLCipher 4 default settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE migrate_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO migrate_test VALUES (1, 'original_data')") conn.commit() conn.close() # Try to open with wrong settings and then migrate conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Set incompatible settings before key cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute(f"PRAGMA key = '{password}'") # This should fail because database was created with SQLCipher 4 settings try: cursor.execute("SELECT * FROM migrate_test") cursor.fetchall() # If this succeeded, cipher_compatibility may have been ignored conn.close() pytest.skip( "cipher_compatibility pragma may not affect existing databases" ) except Exception as e: if isinstance(e, AssertionError): raise # Expected - can't read with wrong settings # Now try to migrate - this should fail because settings don't match try: cursor.execute("PRAGMA cipher_migrate") result = cursor.fetchone() # cipher_migrate returns 0 on success, non-zero on failure if result and result[0] != 0: # Migration failed as expected pass except Exception as e: # Migration failure is expected behavior error_msg = str(e).lower() assert ( "error" in error_msg or "not" in error_msg or "fail" in error_msg ), f"Expected migration error, got: {e}" finally: conn.close() # Verify original database is not corrupted # (should still open with original settings) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Don't set cipher_compatibility - use defaults try: cursor.execute("SELECT value FROM migrate_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "original_data", ( "Original data should be intact after failed migration" ) except Exception as e: if isinstance(e, AssertionError): raise # If this fails, database may have been affected # This documents the actual behavior finally: conn.close() def test_plaintext_header_migration_delete_mode(self, tmp_path): """Test migrating database to use plaintext header offset. Based on official test: plaintext-header-migrate-journal-delete. Tests converting a standard encrypted database to use a plaintext header for iOS compatibility. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "plaintext_header_migration.db" password = "test_password_123" # Create standard encrypted database (no plaintext header) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA journal_mode = DELETE") cursor.execute( "CREATE TABLE header_migrate_test (id INTEGER, value TEXT)" ) cursor.execute( "INSERT INTO header_migrate_test VALUES (1, 'migrate_data_1')" ) cursor.execute( "INSERT INTO header_migrate_test VALUES (2, 'migrate_data_2')" ) conn.commit() conn.close() # Verify file does NOT have plaintext header with open(db_path, "rb") as f: header = f.read(16) assert header != b"SQLite format 3\x00", ( "Original database should be fully encrypted" ) # Now export to a new database with plaintext header export_path = tmp_path / "plaintext_header_export.db" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Attach destination with plaintext header cursor.execute( f"ATTACH DATABASE '{export_path}' AS ptheader KEY '{password}'" ) # Set plaintext header on the attached database cursor.execute("PRAGMA ptheader.cipher_plaintext_header_size = 32") try: cursor.execute("SELECT sqlcipher_export('ptheader')") cursor.execute("DETACH DATABASE ptheader") export_succeeded = True except Exception as e: export_succeeded = False export_error = str(e) conn.close() if export_succeeded: # Verify exported database has plaintext header with open(export_path, "rb") as f: header = f.read(16) if header == b"SQLite format 3\x00": # Plaintext header successfully applied # Verify data is accessible conn = pysqlcipher3.connect(str(export_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA cipher_plaintext_header_size = 32") try: cursor.execute("SELECT COUNT(*) FROM header_migrate_test") count = cursor.fetchone()[0] assert count == 2, ( f"Expected 2 rows after migration, got {count}" ) except Exception as e: if isinstance(e, AssertionError): raise # May need additional configuration finally: conn.close() else: # Plaintext header not applied - may need different approach pass else: pytest.skip( f"Plaintext header migration not supported: {export_error}" ) def test_invalid_salt_specification_fails(self, tmp_path): """Test that invalid salt specification causes HMAC failure. Based on official test: raw-key-with-invalid-salt-spec. When using a raw key with an incorrect salt, the HMAC check should fail. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "invalid_salt_test.db" # Create database with a raw key (bypasses PBKDF2) # Raw key format: x'<64 hex chars>' # DevSkim: ignore DS173237 - Test dummy key, not a real secret raw_key = ( "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F" ) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = \"x'{raw_key}'\"") cursor.execute("CREATE TABLE salt_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO salt_test VALUES (1, 'salt_data')") conn.commit() conn.close() # Try to open with different (wrong) salt # DevSkim: ignore DS173237 - Test dummy salt, not a real secret wrong_salt = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Set wrong salt before key try: cursor.execute(f"PRAGMA cipher_salt = \"x'{wrong_salt}'\"") cursor.execute(f"PRAGMA key = \"x'{raw_key}'\"") # Attempt to read - should fail with HMAC error cursor.execute("SELECT * FROM salt_test") cursor.fetchall() # If we get here, wrong salt was either ignored or not checked # Check if salt was actually applied cursor.execute("PRAGMA cipher_salt") used_salt = cursor.fetchone() conn.close() # Document the behavior - some versions may ignore explicit salt # when the key format doesn't include salt specification if used_salt and used_salt[0] != f"x'{wrong_salt}'": pytest.skip("cipher_salt pragma may be ignored with raw keys") except Exception as e: conn.close() error_msg = str(e).lower() # Expected: HMAC check failure or "not a database" error assert ( "hmac" in error_msg or "not a database" in error_msg or "file is encrypted" in error_msg or "error" in error_msg or "decrypt" in error_msg ), f"Expected HMAC/decryption error with wrong salt, got: {e}" # ========================================================================= # SQLCipher 4.7+ Behavior & 2025 Features # ========================================================================= def test_select_before_key_fails_4_7(self, tmp_path): """Verify SELECT 1 fails before PRAGMA key is set (SQLCipher 4.7+ behavior). Breaking change in 4.7.0: Schema-less statements no longer work before keying. Previously, SELECT 1 would succeed before PRAGMA key; now it fails. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "select_before_key_test.db" password = "test_password_123" # Create an encrypted database first conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE pre_key_test (id INTEGER)") cursor.execute("INSERT INTO pre_key_test VALUES (1)") conn.commit() conn.close() # Reopen without setting key first conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Try SELECT 1 WITHOUT setting key first # In SQLCipher 4.7+, this should fail pre_key_select_failed = False try: cursor.execute("SELECT 1") cursor.fetchone() # If we get here, either: # 1. SQLCipher version < 4.7.0 (older behavior) # 2. The database is somehow readable without key except Exception as e: pre_key_select_failed = True error_msg = str(e).lower() # Should get an error about the database being encrypted assert ( "not a database" in error_msg or "file is encrypted" in error_msg or "encrypted" in error_msg or "error" in error_msg ), f"Unexpected error type: {e}" # Now set key and verify SELECT 1 works cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT 1") result = cursor.fetchone() assert result[0] == 1, "SELECT 1 should work after key is set" conn.close() # Document the observed behavior (may vary by SQLCipher version) if not pre_key_select_failed: # Older SQLCipher version - document this pytest.skip( "SELECT before key succeeded - SQLCipher version may be < 4.7.0" ) def test_select_sqlite_master_before_key_fails(self, tmp_path): """Verify schema query fails before PRAGMA key is set. Tests that SELECT * FROM sqlite_master fails without key. This is a fundamental security requirement for encrypted databases. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "schema_before_key_test.db" password = "test_password_123" # Create an encrypted database with a table conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE schema_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO schema_test VALUES (1, 'secret_data')") conn.commit() conn.close() # Reopen without setting key conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Try to query schema WITHOUT key schema_query_failed = False try: cursor.execute("SELECT * FROM sqlite_master") result = cursor.fetchall() # If we can read schema without key, that's a security concern if result: # We got schema data without key - this shouldn't happen pass except Exception as e: schema_query_failed = True error_msg = str(e).lower() # Expected errors for encrypted databases assert ( "not a database" in error_msg or "file is encrypted" in error_msg or "encrypted" in error_msg or "error" in error_msg ), f"Unexpected error: {e}" # Now set key and verify query works cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [row[0] for row in cursor.fetchall()] assert "schema_test" in tables, ( "Should find schema_test table after key is set" ) conn.close() # Schema should not be readable without key assert schema_query_failed, ( "Schema query should fail without key on encrypted database" ) def test_cipher_log_level_settable(self, tmp_path): """Test PRAGMA cipher_log_level can be set and queried. New in SQLCipher 4.6.0 - default log output to stderr/logcat. Log levels: NONE=0, ERROR=1, WARN=2, INFO=3, DEBUG=4, TRACE=5 """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "cipher_log_level_test.db" password = "test_password_123" conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Test setting different log levels log_levels = { "NONE": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5, } pragma_supported = True for level_name, level_value in log_levels.items(): try: cursor.execute(f"PRAGMA cipher_log_level = {level_value}") cursor.execute("PRAGMA cipher_log_level") result = cursor.fetchone() if result is not None: # Verify the level was set (value may be returned differently) current_level = result[0] # Accept either numeric or string representation assert ( current_level == level_value or str(current_level).upper() == level_name or ( isinstance(current_level, int) and 0 <= current_level <= 5 ) ), f"Log level setting failed for {level_name}" except Exception as e: error_msg = str(e).lower() if "undefined" in error_msg or "no such" in error_msg: pragma_supported = False break # Other errors might be version-specific # Verify database still functions after log level changes cursor.execute("CREATE TABLE log_test (id INTEGER)") cursor.execute("INSERT INTO log_test VALUES (1)") cursor.execute("SELECT * FROM log_test") assert cursor.fetchone()[0] == 1, "Database should function normally" conn.close() if not pragma_supported: pytest.skip( "cipher_log_level pragma not supported (version < 4.6.0)" ) def test_cipher_status_returns_keyed_state(self, tmp_path): """Test PRAGMA cipher_status returns correct keyed state. cipher_status returns 1 if database is keyed and not in error state. Useful for verifying encryption is properly configured. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "cipher_status_test.db" password = "test_password_123" # Create encrypted database conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE status_test (id INTEGER)") conn.commit() conn.close() # Reopen and check cipher_status conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Check status BEFORE key - should indicate not ready try: cursor.execute("PRAGMA cipher_status") _before_key_status = cursor.fetchone() # Status before key may be 0 or None except Exception: _before_key_status = None # noqa: F841 # Set key cursor.execute(f"PRAGMA key = '{password}'") # Check status AFTER key - should be 1 (ready) try: cursor.execute("PRAGMA cipher_status") after_key_status = cursor.fetchone() if after_key_status is not None: # After keying successfully, status should be 1 # (or non-zero indicating ready state) status_value = after_key_status[0] assert status_value in (1, "1", "ok", True), ( f"Expected keyed status, got: {status_value}" ) except Exception as e: error_msg = str(e).lower() if "undefined" in error_msg or "no such" in error_msg: pytest.skip("cipher_status pragma not supported") raise # Verify database operations work cursor.execute("SELECT COUNT(*) FROM status_test") conn.close() def test_cipher_migrate_version_4_database_returns_error(self, tmp_path): """Test cipher_migrate on already-v4 database returns appropriate error. Bug fix in SQLCipher 4.8.0 improved error handling for this case. cipher_migrate should fail gracefully when database is already v4. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "cipher_migrate_v4_test.db" password = "test_password_123" # Create database with SQLCipher 4 defaults conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # Verify we're using SQLCipher 4 settings cursor.execute("PRAGMA cipher_version") _version = cursor.fetchone()[0] # noqa: F841 cursor.execute("CREATE TABLE migrate_v4_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO migrate_v4_test VALUES (1, 'v4_data')") conn.commit() conn.close() # Reopen and attempt cipher_migrate conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") # cipher_migrate on already-v4 database should return failure (non-zero) # or indicate that no migration is needed try: cursor.execute("PRAGMA cipher_migrate") result = cursor.fetchone() if result is not None: migrate_result = result[0] # cipher_migrate returns: # 0 = success (migration performed) # 1 = failure (no migration needed or error) # Already on v4 should return non-zero (no migration needed) assert migrate_result != 0, ( f"cipher_migrate on v4 DB should return non-zero, " f"got: {migrate_result}" ) except Exception as e: error_msg = str(e).lower() if "undefined" in error_msg or "no such" in error_msg: pytest.skip("cipher_migrate pragma not supported") # Other errors are acceptable - migration not needed pass # Verify database is not corrupted after failed migration attempt cursor.execute("SELECT value FROM migrate_v4_test WHERE id = 1") result = cursor.fetchone() assert result[0] == "v4_data", ( "Data should be intact after failed cipher_migrate" ) conn.close() def test_hmac_check_failure_clear_error(self, tmp_path): """Test HMAC check failure provides clear error message. Common issue: users get confusing "file is not a database" errors. This test documents expected error format and verifies error messages contain useful diagnostic information. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "hmac_error_test.db" correct_password = "correct_password_123" wrong_password = "wrong_password_456" # Create encrypted database conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{correct_password}'") cursor.execute("CREATE TABLE hmac_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO hmac_test VALUES (1, 'secret_data')") conn.commit() conn.close() # Reopen with WRONG key conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{wrong_password}'") # Attempt query - should fail with HMAC error hmac_failure_detected = False error_message = "" try: cursor.execute("SELECT * FROM hmac_test") cursor.fetchall() # If we get here, wrong key somehow worked (shouldn't happen) pytest.fail("Query with wrong password should fail") except Exception as e: hmac_failure_detected = True error_message = str(e).lower() conn.close() # Verify error message contains useful diagnostic info # Common error messages include: # - "file is not a database" # - "file is encrypted or is not a database" # - "HMAC validation failed" # - "decrypt failed" assert hmac_failure_detected, "Wrong password should cause failure" # Check for common error patterns error_patterns = [ "not a database", "encrypted", "hmac", "decrypt", "sqlite_master", "error", ] has_useful_error = any( pattern in error_message for pattern in error_patterns ) assert has_useful_error, ( f"Error should contain useful diagnostic info. Got: {error_message}" ) def test_concurrent_open_close_no_deadlock(self, tmp_path): """Test multiple threads can open/close same database without deadlock. Thread safety improvements in SQLCipher 4.9.0 address potential deadlocks during concurrent access. """ import threading import time from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "concurrent_test.db" password = "test_password_123" # Create encrypted database first conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE concurrent_test (id INTEGER, value TEXT)") for i in range(10): cursor.execute( f"INSERT INTO concurrent_test VALUES ({i}, 'data_{i}')" ) conn.commit() conn.close() # Track results from threads results = [] errors = [] lock = threading.Lock() def thread_operation(thread_id): """Open database, execute query, close.""" try: conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT COUNT(*) FROM concurrent_test") count = cursor.fetchone()[0] conn.close() with lock: results.append((thread_id, count)) except Exception as e: with lock: errors.append((thread_id, str(e))) # Spawn 5 threads to do concurrent open/close threads = [] for i in range(5): t = threading.Thread(target=thread_operation, args=(i,)) threads.append(t) # Start all threads for t in threads: t.start() # Wait for completion with timeout (deadlock detection) timeout = 30 # seconds start_time = time.time() for t in threads: remaining = timeout - (time.time() - start_time) if remaining > 0: t.join(timeout=remaining) if t.is_alive(): pytest.fail( "Thread deadlock detected - operation did not complete" ) # Check results if errors: # Some errors may be acceptable (e.g., database locked briefly) acceptable_errors = ["locked", "busy"] for thread_id, error in errors: error_lower = error.lower() if not any(ae in error_lower for ae in acceptable_errors): pytest.fail(f"Thread {thread_id} failed with: {error}") # At least some threads should have succeeded assert len(results) >= 1, ( "At least one thread should complete successfully" ) # All successful threads should see correct count for thread_id, count in results: assert count == 10, f"Thread {thread_id} got wrong count: {count}" def test_library_shutdown_thread_safety(self, tmp_path): """Test library handles connection close while operations may be active. Thread safety improvements in SQLCipher 4.9.0 for shutdown sequences. Tests graceful handling when closing connection from another thread. """ import threading import time from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "shutdown_safety_test.db" password = "test_password_123" # Create database with substantial data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE shutdown_test (id INTEGER, value TEXT)") for i in range(100): cursor.execute( f"INSERT INTO shutdown_test VALUES ({i}, 'data_{i}')" ) conn.commit() conn.close() # Use separate connections for reader and closer read_errors = [] read_count = [0] stop_reading = threading.Event() def continuous_reader(): """Continuously read from database until stopped.""" try: reader_conn = pysqlcipher3.connect(str(db_path)) reader_cursor = reader_conn.cursor() reader_cursor.execute(f"PRAGMA key = '{password}'") while not stop_reading.is_set(): try: reader_cursor.execute( "SELECT COUNT(*) FROM shutdown_test" ) reader_cursor.fetchone() read_count[0] += 1 except Exception as e: error_msg = str(e).lower() # Database closed/locked errors are expected if not any( x in error_msg for x in ["closed", "locked", "cannot operate"] ): read_errors.append(str(e)) break reader_conn.close() except Exception as e: error_msg = str(e).lower() if not any(x in error_msg for x in ["closed", "locked"]): read_errors.append(str(e)) # Start reader thread reader_thread = threading.Thread(target=continuous_reader) reader_thread.start() # Let reader run briefly time.sleep(0.1) # Signal stop and wait for reader stop_reading.set() reader_thread.join(timeout=5) if reader_thread.is_alive(): pytest.fail("Reader thread did not stop gracefully") # Check that some reads completed assert read_count[0] >= 1, ( "Reader should have completed at least one read" ) # Check for unexpected errors (not related to normal shutdown) critical_errors = [ e for e in read_errors if "crash" in e.lower() or "segfault" in e.lower() or "abort" in e.lower() ] assert not critical_errors, ( f"Critical errors during shutdown: {critical_errors}" ) # ======================================================================== # SQLCipher 4.x Compatibility Tests (January 2026) # Based on GitHub issues #98, #4025, #16482 and iOS compatibility requirements # ======================================================================== def test_rekey_preserves_row_count(self, tmp_path): """Verify rekey doesn't delete data (addresses GitHub issue #98). Issue #98 reported that rekey operation could result in empty database. This test verifies exact row count before and after rekey matches. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "rekey_row_count_test.db" original_password = "original_password_123" # DevSkim: ignore DS117838 new_password = "new_password_456" # DevSkim: ignore DS117838 num_rows = 1000 # Create database with substantial data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") cursor.execute( "CREATE TABLE rekey_test (id INTEGER PRIMARY KEY, value TEXT)" ) # Insert 1000 rows for i in range(num_rows): cursor.execute( f"INSERT INTO rekey_test (value) VALUES ('data_{i}')" ) conn.commit() # Get row count before rekey cursor.execute("SELECT COUNT(*) FROM rekey_test") count_before = cursor.fetchone()[0] assert count_before == num_rows, ( f"Setup failed: expected {num_rows}, got {count_before}" ) # Perform rekey cursor.execute(f"PRAGMA rekey = '{new_password}'") conn.commit() # Get row count after rekey (same connection) cursor.execute("SELECT COUNT(*) FROM rekey_test") count_after_same_conn = cursor.fetchone()[0] conn.close() # Reopen with new password and verify count conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{new_password}'") cursor.execute("SELECT COUNT(*) FROM rekey_test") count_after_reopen = cursor.fetchone()[0] conn.close() # Verify row counts match assert count_after_same_conn == num_rows, ( f"Row count changed after rekey (same conn): " f"expected {num_rows}, got {count_after_same_conn}" ) assert count_after_reopen == num_rows, ( f"Row count changed after rekey (reopen): " f"expected {num_rows}, got {count_after_reopen}" ) def test_rekey_preserves_data_content(self, tmp_path): """Verify specific data values survive rekey operation. Addresses GitHub issue #98 - ensures not just row count but actual data content is preserved including Unicode and various data types. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "rekey_data_content_test.db" original_password = "original_pass" # DevSkim: ignore DS117838 new_password = "new_pass" # DevSkim: ignore DS117838 # Test data with various types test_data = [ (1, "simple text", 42, 3.14159), (2, "Unicode: こんにちは 🎉 émojis", 100, 2.71828), (3, "Special chars: <>&\"'", -999, 0.0), (4, "Long text" * 100, 2147483647, 1e308), (5, "", 0, -1e308), ] # Create database with test data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{original_password}'") cursor.execute(""" CREATE TABLE content_test ( id INTEGER PRIMARY KEY, text_col TEXT, int_col INTEGER, real_col REAL ) """) for row in test_data: cursor.execute("INSERT INTO content_test VALUES (?, ?, ?, ?)", row) conn.commit() # Perform rekey cursor.execute(f"PRAGMA rekey = '{new_password}'") conn.commit() conn.close() # Reopen with new password and verify data conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{new_password}'") cursor.execute( "SELECT id, text_col, int_col, real_col FROM content_test " "ORDER BY id" ) results = cursor.fetchall() conn.close() # Verify each row matches original data assert len(results) == len(test_data), ( f"Row count mismatch: expected {len(test_data)}, got {len(results)}" ) for original, retrieved in zip(test_data, results): assert original[0] == retrieved[0], ( f"ID mismatch: expected {original[0]}, got {retrieved[0]}" ) assert original[1] == retrieved[1], ( f"Text mismatch for id {original[0]}" ) assert original[2] == retrieved[2], ( f"Integer mismatch for id {original[0]}" ) # Float comparison with tolerance assert abs(original[3] - retrieved[3]) < 1e-10 or ( original[3] == retrieved[3] ), f"Float mismatch for id {original[0]}" def test_rekey_on_plaintext_db_fails(self, tmp_path): """PRAGMA rekey cannot encrypt a plaintext database. Addresses DBeaver issue #16482 - rekey on plaintext DB should fail gracefully, not corrupt the database. SQLCipher requires the DB to already be encrypted before rekey can be used. """ import sqlite3 from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "plaintext_rekey_test.db" new_password = "new_password" # DevSkim: ignore DS117838 # Create plaintext SQLite database using standard sqlite3 conn = sqlite3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("CREATE TABLE test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO test VALUES (1, 'test_data')") conn.commit() conn.close() # Verify file is plaintext (has SQLite magic) with open(db_path, "rb") as f: header = f.read(16) assert header[:6] == b"SQLite", ( "Test setup failed: database should be plaintext" ) # Try to rekey plaintext database using sqlcipher conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Don't set key (it's plaintext) - just try rekey # We expect this to either fail or be a no-op (not actually encrypt) try: cursor.execute(f"PRAGMA rekey = '{new_password}'") conn.commit() except Exception as e: if isinstance(e, AssertionError): raise # Expected - rekey on plaintext may fail conn.close() # Verify database is still accessible (not corrupted) conn = sqlite3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("SELECT * FROM test") result = cursor.fetchall() conn.close() assert len(result) == 1, ( "Database should still be accessible after failed rekey" ) assert result[0] == (1, "test_data"), ( "Data should be preserved after failed rekey" ) # Verify file is still plaintext with open(db_path, "rb") as f: header = f.read(16) assert header[:6] == b"SQLite", ( "Database should remain plaintext (rekey should not encrypt it)" ) def test_hmac_disabled_database_opens(self, tmp_path): """Test cipher_use_hmac=OFF mode works correctly. Addresses SQLiteBrowser issue #4025 - databases created with cipher_use_hmac=OFF should still be openable and functional. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "hmac_off_test.db" password = "test_password" # DevSkim: ignore DS117838 # Create database with HMAC disabled conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA cipher_use_hmac = OFF") # Insert test data cursor.execute("CREATE TABLE hmac_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO hmac_test VALUES (1, 'hmac_off_data')") cursor.execute("INSERT INTO hmac_test VALUES (2, 'more_data')") conn.commit() conn.close() # Reopen with same settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA cipher_use_hmac = OFF") # Verify data is accessible cursor.execute("SELECT * FROM hmac_test ORDER BY id") results = cursor.fetchall() conn.close() assert len(results) == 2, "Should retrieve both rows" assert results[0] == (1, "hmac_off_data") assert results[1] == (2, "more_data") # Verify wrong password still fails (encryption still works) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA key = 'wrong_password'") cursor.execute("PRAGMA cipher_use_hmac = OFF") wrong_password_error = None try: cursor.execute("SELECT * FROM hmac_test") cursor.fetchall() except Exception as e: wrong_password_error = str(e) conn.close() assert wrong_password_error is not None, ( "Wrong password should still fail even with HMAC disabled" ) def test_16kb_page_size(self, tmp_path): """Test with 16KB page size (Google Play Store requirement). Addresses android-database-sqlcipher issue #664 - Google Play requires 16KB page alignment for Android 15+ apps. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "page_16kb_test.db" password = "test_password" # DevSkim: ignore DS117838 page_size = 16384 # 16KB # Create database with 16KB page size conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute(f"PRAGMA cipher_page_size = {page_size}") # Create table and insert data cursor.execute( "CREATE TABLE large_page_test (id INTEGER PRIMARY KEY, data TEXT)" ) # Insert enough data to span multiple pages for i in range(100): cursor.execute( f"INSERT INTO large_page_test (data) VALUES " f"('{('x' * 1000)}_{i}')" ) conn.commit() # Verify page size was applied cursor.execute("PRAGMA cipher_page_size") actual_page_size = int(cursor.fetchone()[0]) conn.close() # Reopen and verify data persistence conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute(f"PRAGMA cipher_page_size = {page_size}") cursor.execute("SELECT COUNT(*) FROM large_page_test") count = cursor.fetchone()[0] conn.close() assert count == 100, f"All rows should persist, got {count}" assert actual_page_size == page_size, ( f"Page size should be {page_size}, got {actual_page_size}" ) # Verify file size is aligned to page size (rough check) file_size = db_path.stat().st_size assert file_size > page_size, ( "Database file should be larger than one page" ) def test_plaintext_header_with_external_salt(self, tmp_path): """Create DB with plaintext header and external salt management. For iOS compatibility (Mozilla issue #2100, SQLCipher #352). iOS requires plaintext header to prevent 0xdead10cc termination. """ import secrets from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "external_salt_test.db" password = "test_password" # DevSkim: ignore DS117838 # Generate random 16-byte salt (32 hex chars) external_salt = secrets.token_hex(16) # Create database with plaintext header and external salt conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # IMPORTANT: cipher_plaintext_header_size and cipher_salt must be # set BEFORE PRAGMA key cursor.execute("PRAGMA cipher_plaintext_header_size = 32") cursor.execute(f"PRAGMA cipher_salt = \"x'{external_salt}'\"") cursor.execute(f"PRAGMA key = '{password}'") # Create table and insert data cursor.execute("CREATE TABLE salt_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO salt_test VALUES (1, 'external_salt_data')") conn.commit() conn.close() # Verify file has SQLite magic bytes (plaintext header) with open(db_path, "rb") as f: header = f.read(32) # First 16 bytes should contain SQLite header string for plaintext # header mode has_sqlite_magic = b"SQLite" in header if has_sqlite_magic: # Plaintext header applied - verify we can reopen conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA cipher_plaintext_header_size = 32") cursor.execute(f"PRAGMA cipher_salt = \"x'{external_salt}'\"") cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT * FROM salt_test") result = cursor.fetchall() conn.close() assert len(result) == 1 assert result[0] == (1, "external_salt_data") else: # Some SQLCipher versions may not support this feature pytest.skip( "Plaintext header with external salt not supported in " "this SQLCipher version" ) def test_extract_salt_for_external_storage(self, tmp_path): """Extract salt via PRAGMA cipher_salt for external storage. Tests the ability to extract the salt from an encrypted database for external storage (needed for iOS plaintext header mode). """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "extract_salt_test.db" password = "test_password" # DevSkim: ignore DS117838 # Create standard encrypted database conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE salt_extract_test (id INTEGER)") cursor.execute("INSERT INTO salt_extract_test VALUES (1)") conn.commit() # Extract salt cursor.execute("PRAGMA cipher_salt") salt_result = cursor.fetchone() conn.close() if salt_result and salt_result[0]: salt = salt_result[0] # Salt should be a hex string (with or without x'' wrapper) salt_str = str(salt) # Remove x'' wrapper if present if salt_str.startswith("x'") and salt_str.endswith("'"): salt_hex = salt_str[2:-1] else: salt_hex = salt_str # Salt should be 32 hex characters (16 bytes) # Note: some versions may return different formats assert len(salt_hex) >= 16, ( f"Salt should be at least 16 chars, got {len(salt_hex)}" ) # Verify salt contains only hex characters try: int(salt_hex[:32], 16) is_valid_hex = True except ValueError: is_valid_hex = False assert is_valid_hex, f"Salt should be hex string, got: {salt_hex}" else: pytest.skip("PRAGMA cipher_salt not supported in this version") def test_reopen_with_extracted_salt(self, tmp_path): """Reopen plaintext header DB using stored salt. Tests the full workflow of creating a plaintext header database, extracting the salt, and reopening with that salt. """ import secrets from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "reopen_salt_test.db" password = "test_password" # DevSkim: ignore DS117838 # Generate external salt external_salt = secrets.token_hex(16) # Create database with plaintext header conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() try: cursor.execute("PRAGMA cipher_plaintext_header_size = 32") cursor.execute(f"PRAGMA cipher_salt = \"x'{external_salt}'\"") cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("CREATE TABLE reopen_test (id INTEGER, data TEXT)") cursor.execute("INSERT INTO reopen_test VALUES (1, 'test_value')") cursor.execute( "INSERT INTO reopen_test VALUES (2, 'another_value')" ) conn.commit() conn.close() # Simulate storing salt externally (like iOS Keychain) stored_salt = external_salt # Reopen using stored salt conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute("PRAGMA cipher_plaintext_header_size = 32") cursor.execute(f"PRAGMA cipher_salt = \"x'{stored_salt}'\"") cursor.execute(f"PRAGMA key = '{password}'") # Verify data accessible cursor.execute("SELECT * FROM reopen_test ORDER BY id") results = cursor.fetchall() conn.close() assert len(results) == 2, f"Expected 2 rows, got {len(results)}" assert results[0] == (1, "test_value") assert results[1] == (2, "another_value") except Exception as e: conn.close() error_msg = str(e).lower() if ( "cipher_plaintext_header" in error_msg or "not supported" in error_msg ): pytest.skip( "Plaintext header mode not supported in this version" ) raise def test_cipher_compatibility_3_after_key(self, tmp_path): """cipher_compatibility must be set AFTER key pragma. Based on pysqlcipher3 documentation - when opening v3 databases, cipher_compatibility = 3 must be set AFTER PRAGMA key. """ from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() db_path = tmp_path / "compat_order_test.db" password = "test_password" # DevSkim: ignore DS117838 # Create database with v3 compatibility settings conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute("CREATE TABLE compat_test (id INTEGER, value TEXT)") cursor.execute("INSERT INTO compat_test VALUES (1, 'v3_compat_data')") conn.commit() conn.close() # Reopen with CORRECT order: key THEN compatibility conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute("SELECT * FROM compat_test") result = cursor.fetchall() conn.close() assert len(result) == 1, "Data should be accessible" assert result[0] == (1, "v3_compat_data") # Test that setting compatibility BEFORE key may cause issues # (This documents the correct order requirement) conn = pysqlcipher3.connect(str(db_path)) cursor = conn.cursor() # Wrong order: compatibility before key cursor.execute("PRAGMA cipher_compatibility = 3") cursor.execute(f"PRAGMA key = '{password}'") # This might work or fail depending on SQLCipher version try: cursor.execute("SELECT * FROM compat_test") result = cursor.fetchall() # If it works, that's fine - we're just documenting behavior except Exception as e: if isinstance(e, AssertionError): raise # If it fails, that demonstrates why order matters conn.close() def test_add_encryption_to_plaintext_db(self, tmp_path): """Use sqlcipher_export to add encryption to plaintext DB. Based on pysqlcipher3 test coverage - tests the recommended method for encrypting an existing plaintext database using ATTACH and sqlcipher_export(). """ import sqlite3 from local_deep_research.database.sqlcipher_compat import ( get_sqlcipher_module, ) pysqlcipher3 = get_sqlcipher_module() plaintext_path = tmp_path / "plaintext_source.db" encrypted_path = tmp_path / "encrypted_dest.db" password = "encryption_password" # DevSkim: ignore DS117838 # Create plaintext database with standard sqlite3 conn = sqlite3.connect(str(plaintext_path)) cursor = conn.cursor() cursor.execute( "CREATE TABLE export_test (id INTEGER PRIMARY KEY, name TEXT)" ) cursor.execute("INSERT INTO export_test VALUES (1, 'Alice')") cursor.execute("INSERT INTO export_test VALUES (2, 'Bob')") cursor.execute("INSERT INTO export_test VALUES (3, 'Charlie')") conn.commit() conn.close() # Verify source is plaintext with open(plaintext_path, "rb") as f: header = f.read(16) assert header[:6] == b"SQLite", "Source should be plaintext" # Open plaintext database with sqlcipher (no key) conn = pysqlcipher3.connect(str(plaintext_path)) cursor = conn.cursor() # Attach encrypted destination cursor.execute( f"ATTACH DATABASE '{encrypted_path}' AS encrypted KEY '{password}'" ) # Export to encrypted database cursor.execute("SELECT sqlcipher_export('encrypted')") # Detach cursor.execute("DETACH DATABASE encrypted") conn.close() # Verify encrypted database is actually encrypted with open(encrypted_path, "rb") as f: header = f.read(16) assert header[:6] != b"SQLite", ( "Encrypted database should not have plaintext header" ) # Open encrypted database and verify data conn = pysqlcipher3.connect(str(encrypted_path)) cursor = conn.cursor() cursor.execute(f"PRAGMA key = '{password}'") cursor.execute("SELECT * FROM export_test ORDER BY id") results = cursor.fetchall() conn.close() assert len(results) == 3, f"Expected 3 rows, got {len(results)}" assert results[0] == (1, "Alice") assert results[1] == (2, "Bob") assert results[2] == (3, "Charlie")