diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50d6b86d..7127e0db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,7 +250,7 @@ jobs: services: oracle: - image: gvenzl/oracle-free:23-slim + image: gvenzl/oracle-free:23-slim@sha256:fbbd3023d5abc33e36d3814816e6fd740e8efabeaa70cf470ddeab5874a3f6f8 env: ORACLE_PASSWORD: TestPassword123! APP_USER: testuser @@ -287,6 +287,53 @@ jobs: ORACLE_SERVICE: FREEPDB1 run: uv run pytest tests/test_oracle.py -v --timeout=120 + - name: Require Oracle Native Network Encryption + env: + ORACLE_CONTAINER: ${{ job.services.oracle.id }} + run: | + docker exec --user root "$ORACLE_CONTAINER" sh -lc 'printf "\nSQLNET.CRYPTO_CHECKSUM_SERVER = required\nSQLNET.CRYPTO_CHECKSUM_TYPES_SERVER = (SHA512)\nSQLNET.ENCRYPTION_SERVER = required\nSQLNET.ENCRYPTION_TYPES_SERVER = (AES256)\n" >> /opt/oracle/oradata/dbconfig/FREE/sqlnet.ora' + docker restart "$ORACLE_CONTAINER" + for attempt in {1..60}; do + if docker exec "$ORACLE_CONTAINER" healthcheck.sh; then + exit 0 + fi + echo "Waiting for NNE-enabled Oracle restart ($attempt/60)" + sleep 2 + done + docker logs "$ORACLE_CONTAINER" + exit 1 + + - name: Install Oracle Instant Client + env: + INSTANT_CLIENT_URL: https://download.oracle.com/otn_software/linux/instantclient/2326300/instantclient-basiclite-linux.x64-23.26.3.0.0.zip + INSTANT_CLIENT_SHA256: 88c2b59d473f0d34615556dbee51cb90cce61a9104b4c1c772d13c344fba19c9 + run: | + sudo apt-get update + sudo apt-get install -y libaio1t64 || sudo apt-get install -y libaio1 + libaio_path=$(ldconfig -p | awk '/libaio\.so\.1t64/{path=$NF} END{print path}') + if [ -n "$libaio_path" ]; then + sudo ln -sf "$libaio_path" "$(dirname "$libaio_path")/libaio.so.1" + sudo ldconfig + fi + curl --fail --location --retry 3 "$INSTANT_CLIENT_URL" --output "$RUNNER_TEMP/instantclient.zip" + echo "$INSTANT_CLIENT_SHA256 $RUNNER_TEMP/instantclient.zip" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/instantclient" + unzip -q "$RUNNER_TEMP/instantclient.zip" -d "$RUNNER_TEMP/instantclient" + client_dir=$(find "$RUNNER_TEMP/instantclient" -mindepth 1 -maxdepth 1 -type d -name 'instantclient_*' -print -quit) + test -n "$client_dir" + echo "ORACLE_CLIENT_LIB_DIR=$client_dir" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$client_dir" >> "$GITHUB_ENV" + + - name: Verify Oracle Thin rejection and Thick connection + env: + SQLIT_ORACLE_NNE_TEST: "1" + ORACLE_HOST: localhost + ORACLE_PORT: 1521 + ORACLE_USER: testuser + ORACLE_PASSWORD: TestPassword123! + ORACLE_SERVICE: FREEPDB1 + run: uv run pytest tests/integration/test_oracle_nne.py -v --timeout=60 + test-mariadb: runs-on: ubuntu-latest needs: build diff --git a/sqlit/domains/connections/providers/oracle/adapter.py b/sqlit/domains/connections/providers/oracle/adapter.py index f9968a90..928d6a94 100644 --- a/sqlit/domains/connections/providers/oracle/adapter.py +++ b/sqlit/domains/connections/providers/oracle/adapter.py @@ -75,6 +75,8 @@ class OracleAdapter(DatabaseAdapter): connected user may access, including objects owned by other schemas. """ + _client_mode_default = "thin" + @property def name(self) -> str: return "Oracle" @@ -113,6 +115,45 @@ def supports_foreign_keys(self) -> bool: def test_query(self) -> str: return "SELECT 1 FROM DUAL" + def _ensure_client_mode(self, oracledb: Any, config: ConnectionConfig) -> None: + """Enable Thick mode before the first connection when requested. + + python-oracledb chooses one mode for the lifetime of the process, so + initialization must happen before ``connect()``. Calling + ``init_oracle_client()`` repeatedly with the same arguments is + supported by the driver. + """ + client_mode = str( + config.get_option("oracle_client_mode", self._client_mode_default) + ).strip().lower() + if client_mode == "thin": + is_thin_mode = getattr(oracledb, "is_thin_mode", None) + if callable(is_thin_mode) and is_thin_mode() is False: + raise ValueError( + "Oracle Thin mode cannot be selected after Thick mode was " + "initialized in this sqlit process. Restart sqlit before " + "opening this connection." + ) + return + if client_mode != "thick": + raise ValueError("Oracle client mode must be Thin or Thick") + + lib_dir = str( + config.get_option("oracle_client_lib_dir", "") or "" + ).strip() + try: + if lib_dir: + oracledb.init_oracle_client(lib_dir=lib_dir) + else: + oracledb.init_oracle_client() + except Exception as exc: + raise ValueError( + "Oracle Thick mode initialization failed. Install Oracle Client " + "libraries and make them available to sqlit before connecting. " + "Restart sqlit if a Thin-mode connection was already opened. " + f"Driver error: {exc}" + ) from exc + def connect(self, config: ConnectionConfig) -> Any: """Connect to Oracle database.""" oracledb = self._import_driver_module( @@ -122,6 +163,8 @@ def connect(self, config: ConnectionConfig) -> Any: package_name=self.install_package, ) + self._ensure_client_mode(oracledb, config) + # Fetch CLOB/BLOB values inline as str/bytes instead of LOB locators. # Locators need a live connection to be read, but results are pickled # across the process worker pipe after the query connection is closed, diff --git a/sqlit/domains/connections/providers/oracle/schema.py b/sqlit/domains/connections/providers/oracle/schema.py index 7aee63c9..db8d8311 100644 --- a/sqlit/domains/connections/providers/oracle/schema.py +++ b/sqlit/domains/connections/providers/oracle/schema.py @@ -35,6 +35,13 @@ def _get_oracle_protocol_options() -> tuple[SelectOption, ...]: ) +def _get_oracle_client_mode_options() -> tuple[SelectOption, ...]: + return ( + SelectOption("thin", "Thin (default)"), + SelectOption("thick", "Thick (Oracle Client)"), + ) + + def _oracle_connection_type_is_service_name(values: dict) -> bool: return values.get("oracle_connection_type", "service_name") != "sid" @@ -43,6 +50,10 @@ def _oracle_connection_type_is_sid(values: dict) -> bool: return values.get("oracle_connection_type") == "sid" +def _oracle_thick_mode_enabled(values: dict) -> bool: + return values.get("oracle_client_mode") == "thick" + + SCHEMA = ConnectionSchema( db_type="oracle", display_name="Oracle", @@ -100,6 +111,22 @@ def _oracle_connection_type_is_sid(values: dict) -> bool: options=_get_oracle_role_options(), default="normal", ), + SchemaField( + name="oracle_client_mode", + label="Client Mode", + field_type=FieldType.DROPDOWN, + options=_get_oracle_client_mode_options(), + default="thin", + description=("Thick mode requires separately installed Oracle Client libraries and supports Native Network Encryption."), + ), + SchemaField( + name="oracle_client_lib_dir", + label="Client Library Directory", + field_type=FieldType.DIRECTORY, + placeholder="/path/to/instantclient", + description=("Optional on Windows and macOS. On Linux, normally leave blank and configure ldconfig or LD_LIBRARY_PATH before starting sqlit."), + visible_when=_oracle_thick_mode_enabled, + ), ) + SSH_FIELDS, default_port="1521", diff --git a/sqlit/domains/connections/providers/oracle_legacy/adapter.py b/sqlit/domains/connections/providers/oracle_legacy/adapter.py index 5c58dd6d..397b39d9 100644 --- a/sqlit/domains/connections/providers/oracle_legacy/adapter.py +++ b/sqlit/domains/connections/providers/oracle_legacy/adapter.py @@ -13,8 +13,7 @@ class OracleLegacyAdapter(OracleAdapter): """Adapter for Oracle 11g and older using thick client mode.""" - _client_initialized = False - _client_lib_dir: str | None = None + _client_mode_default = "thick" @property def name(self) -> str: @@ -32,26 +31,6 @@ def install_package(self) -> str: def driver_import_names(self) -> tuple[str, ...]: return ("oracledb",) - def _ensure_thick_client(self, oracledb: Any, config: ConnectionConfig) -> None: - mode = str(config.get_option("oracle_client_mode", "thick")).lower() - if mode == "thin": - return - lib_dir = config.get_option("oracle_client_lib_dir") or None - if OracleLegacyAdapter._client_initialized: - return - try: - if lib_dir: - oracledb.init_oracle_client(lib_dir=str(lib_dir)) - else: - oracledb.init_oracle_client() - except Exception as exc: - raise ValueError( - "Oracle thick client initialization failed. Install Oracle Instant Client " - "and optionally set the client library path." - ) from exc - OracleLegacyAdapter._client_initialized = True - OracleLegacyAdapter._client_lib_dir = str(lib_dir) if lib_dir else None - def get_post_connect_warnings(self, config: ConnectionConfig) -> list[str]: mode = str(config.get_option("oracle_client_mode", "thick")).lower() if mode == "thin": @@ -60,16 +39,6 @@ def get_post_connect_warnings(self, config: ConnectionConfig) -> list[str]: ] return [] - def connect(self, config: ConnectionConfig) -> Any: - oracledb = self._import_driver_module( - "oracledb", - driver_name=self.name, - extra_name=self.install_extra, - package_name=self.install_package, - ) - self._ensure_thick_client(oracledb, config) - return super().connect(config) - def build_select_query(self, table: str, limit: int, database: str | None = None, schema: str | None = None) -> str: """Build a schema-aware SELECT query with Oracle 11g ROWNUM pagination.""" qualified = self.catalog_qualified_name(database, schema, table) diff --git a/tests/integration/test_oracle_nne.py b/tests/integration/test_oracle_nne.py new file mode 100644 index 00000000..8eebf2d6 --- /dev/null +++ b/tests/integration/test_oracle_nne.py @@ -0,0 +1,67 @@ +"""Real Thin/Thick connection checks against an NNE-required Oracle server.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + os.environ.get("SQLIT_ORACLE_NNE_TEST") != "1", + reason="Oracle NNE fixture is not enabled", + ), +] + +_CONNECT_SCRIPT = r""" +import os +import sys +import oracledb + +if sys.argv[1] == "thick": + oracledb.init_oracle_client(lib_dir=os.environ["ORACLE_CLIENT_LIB_DIR"]) + +connection = oracledb.connect( + user=os.environ.get("ORACLE_USER", "testuser"), + password=os.environ.get("ORACLE_PASSWORD", "TestPassword123!"), + dsn=( + f"{os.environ.get('ORACLE_HOST', '127.0.0.1')}:" + f"{os.environ.get('ORACLE_PORT', '1521')}/" + f"{os.environ.get('ORACLE_SERVICE', 'FREEPDB1')}" + ), +) +value = connection.cursor().execute("SELECT 1 FROM dual").fetchone()[0] +print(f"mode={'thin' if connection.thin else 'thick'} value={value}") +connection.close() +""" + + +def _connect(mode: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", _CONNECT_SCRIPT, mode], + text=True, + capture_output=True, + env=os.environ.copy(), + check=False, + timeout=30, + ) + + +def test_thin_mode_is_rejected_when_native_encryption_is_required() -> None: + result = _connect("thin") + + assert result.returncode != 0 + assert "DPY-3001" in result.stderr + + +def test_thick_mode_connects_with_instant_client() -> None: + if not os.environ.get("ORACLE_CLIENT_LIB_DIR"): + pytest.skip("Oracle Instant Client is not configured") + + result = _connect("thick") + + assert result.returncode == 0, result.stderr + assert "mode=thick value=1" in result.stdout diff --git a/tests/unit/test_oracle_adapter.py b/tests/unit/test_oracle_adapter.py index 3b5b3a44..bb6a74a5 100644 --- a/tests/unit/test_oracle_adapter.py +++ b/tests/unit/test_oracle_adapter.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -165,6 +165,169 @@ def test_unqualified_auxiliary_definition_preserves_oracle_uppercase_lookup(self OracleAdapter().get_sequence_definition(conn, "test_sequence") assert cursor.execute.call_args.args[1] == ("TEST_SEQUENCE", None) +class TestOracleAdapterClientMode: + """Regression coverage for Oracle databases that require Thick mode.""" + + def test_connect_defaults_to_thin_mode(self): + mock_oracledb = MagicMock() + + with patch.dict("sys.modules", {"oracledb": mock_oracledb}): + from sqlit.domains.connections.providers.oracle.adapter import OracleAdapter + + config = ConnectionConfig( + name="test", + db_type="oracle", + server="localhost", + port="1521", + database="ORCL", + username="testuser", + password="testpass", + ) + + OracleAdapter().connect(config) + + mock_oracledb.init_oracle_client.assert_not_called() + + def test_thin_mode_rejects_process_already_initialized_as_thick(self): + mock_oracledb = MagicMock() + mock_oracledb.is_thin_mode.return_value = False + + with patch.dict("sys.modules", {"oracledb": mock_oracledb}): + from sqlit.domains.connections.providers.oracle.adapter import OracleAdapter + + config = ConnectionConfig( + name="test", + db_type="oracle", + server="localhost", + port="1521", + database="ORCL", + username="testuser", + password="testpass", + options={"oracle_client_mode": "thin"}, + ) + + with pytest.raises(ValueError, match="Restart sqlit"): + OracleAdapter().connect(config) + + mock_oracledb.connect.assert_not_called() + + def test_connect_enables_thick_mode_before_connecting(self): + mock_oracledb = MagicMock() + + with patch.dict("sys.modules", {"oracledb": mock_oracledb}): + from sqlit.domains.connections.providers.oracle.adapter import OracleAdapter + + config = ConnectionConfig( + name="test", + db_type="oracle", + server="localhost", + port="1521", + database="ORCL", + username="testuser", + password="testpass", + options={"oracle_client_mode": "thick"}, + ) + + OracleAdapter().connect(config) + + mock_oracledb.init_oracle_client.assert_called_once_with() + assert mock_oracledb.method_calls.index(call.init_oracle_client()) < mock_oracledb.method_calls.index( + call.connect( + user="testuser", + password="testpass", + dsn="localhost:1521/ORCL", + ) + ) + + def test_connect_passes_configured_client_library_path(self): + mock_oracledb = MagicMock() + + with patch.dict("sys.modules", {"oracledb": mock_oracledb}): + from sqlit.domains.connections.providers.oracle.adapter import OracleAdapter + + config = ConnectionConfig( + name="test", + db_type="oracle", + server="localhost", + port="1521", + database="ORCL", + username="testuser", + password="testpass", + options={ + "oracle_client_mode": "thick", + "oracle_client_lib_dir": "/opt/oracle/instantclient", + }, + ) + + OracleAdapter().connect(config) + + mock_oracledb.init_oracle_client.assert_called_once_with( + lib_dir="/opt/oracle/instantclient" + ) + + def test_connect_reports_thick_mode_prerequisites(self): + mock_oracledb = MagicMock() + mock_oracledb.init_oracle_client.side_effect = RuntimeError("DPI-1047") + + with patch.dict("sys.modules", {"oracledb": mock_oracledb}): + from sqlit.domains.connections.providers.oracle.adapter import OracleAdapter + + config = ConnectionConfig( + name="test", + db_type="oracle", + server="localhost", + port="1521", + database="ORCL", + username="testuser", + password="testpass", + options={"oracle_client_mode": "thick"}, + ) + + with pytest.raises( + ValueError, match="Install Oracle Client libraries" + ) as exc_info: + OracleAdapter().connect(config) + + assert isinstance(exc_info.value.__cause__, RuntimeError) + assert "DPI-1047" in str(exc_info.value) + mock_oracledb.connect.assert_not_called() + + def test_schema_only_shows_library_directory_for_thick_mode(self): + from sqlit.domains.connections.providers.oracle.schema import SCHEMA + + fields = {field.name: field for field in SCHEMA.fields} + + assert fields["oracle_client_mode"].default == "thin" + assert [option.value for option in fields["oracle_client_mode"].options] == [ + "thin", + "thick", + ] + visible_when = fields["oracle_client_lib_dir"].visible_when + assert visible_when is not None + assert visible_when({"oracle_client_mode": "thin"}) is False + assert visible_when({"oracle_client_mode": "thick"}) is True + + def test_oracle_legacy_still_defaults_to_thick_mode(self): + mock_oracledb = MagicMock() + + with patch.dict("sys.modules", {"oracledb": mock_oracledb}): + from sqlit.domains.connections.providers.oracle_legacy.adapter import ( + OracleLegacyAdapter, + ) + + config = ConnectionConfig( + name="test", + db_type="oracle_legacy", + server="localhost", + port="1521", + database="ORCL", + username="testuser", + password="testpass", + ) + + OracleLegacyAdapter().connect(config) + + mock_oracledb.init_oracle_client.assert_called_once_with() class TestOracleAdapterRole: