Skip to content

Commit 74a571b

Browse files
committed
Fix mock sqlite - Add option to use plaintext credentials
1 parent e73c2c5 commit 74a571b

File tree

11 files changed

+314
-25
lines changed

11 files changed

+314
-25
lines changed

sqlit/domains/connections/app/credentials.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,27 +78,49 @@ def is_keyring_usable() -> bool:
7878
return _is_keyring_usable()
7979

8080

81+
_keyring_probe_error: str | None = None
82+
83+
84+
def get_keyring_probe_error() -> str | None:
85+
"""Return the last keyring probe error, if any."""
86+
return _keyring_probe_error
87+
88+
8189
def _is_keyring_usable() -> bool:
8290
"""Internal keyring probe (wrapped for profiling)."""
91+
global _keyring_probe_error
92+
_keyring_probe_error = None
93+
8394
try:
8495
import keyring
8596
except ImportError:
97+
_keyring_probe_error = "keyring module not installed"
8698
return False
8799

88-
try:
89-
backend = keyring.get_keyring()
90-
module_name = getattr(backend, "__module__", "") or ""
91-
priority = getattr(backend, "priority", None)
92-
if "keyring.backends.fail" in module_name:
93-
return False
94-
if isinstance(priority, (int, float)) and priority <= 0:
95-
return False
96-
97-
# Minimal probe: read-only call to surface obvious misconfiguration.
98-
keyring.get_password(KEYRING_SERVICE_NAME, f"probe:{secrets.token_hex(8)}")
99-
return True
100-
except Exception:
101-
return False
100+
# Retry probe to handle transient D-Bus/keyring daemon issues
101+
last_exc = None
102+
for attempt in range(3):
103+
try:
104+
backend = keyring.get_keyring()
105+
module_name = getattr(backend, "__module__", "") or ""
106+
priority = getattr(backend, "priority", None)
107+
if "keyring.backends.fail" in module_name:
108+
_keyring_probe_error = "No usable keyring backend found"
109+
return False
110+
if isinstance(priority, (int, float)) and priority <= 0:
111+
_keyring_probe_error = "Keyring backend has low priority"
112+
return False
113+
114+
# Minimal probe: read-only call to surface obvious misconfiguration.
115+
keyring.get_password(KEYRING_SERVICE_NAME, f"probe:{secrets.token_hex(8)}")
116+
return True
117+
except Exception as exc:
118+
last_exc = exc
119+
if attempt < 2:
120+
time.sleep(0.1) # Brief delay before retry
121+
122+
_keyring_probe_error = f"Keyring probe failed: {last_exc}"
123+
return False
102124

103125
class CredentialsService(ABC):
104126
"""Abstract base class for credential storage services."""
@@ -403,18 +425,23 @@ def delete_ssh_password(self, connection_name: str) -> None:
403425

404426
def build_credentials_service(settings_store: Any | None = None) -> CredentialsService:
405427
"""Build a credentials service with an optional settings store."""
406-
if is_keyring_usable():
407-
return KeyringCredentialsService()
408-
409428
if settings_store is None:
410429
from sqlit.domains.shell.store.settings import SettingsStore
411430

412431
settings_store = SettingsStore.get_instance()
413432

414433
settings = settings_store.load_all()
415-
allow_plaintext = bool(settings.get(ALLOW_PLAINTEXT_CREDENTIALS_SETTING))
416-
if allow_plaintext:
434+
allow_plaintext = settings.get(ALLOW_PLAINTEXT_CREDENTIALS_SETTING)
435+
436+
# If user explicitly chose plaintext, use it regardless of keyring availability
437+
if allow_plaintext is True:
417438
return PlaintextFileCredentialsService()
439+
440+
# Otherwise, try keyring first
441+
if is_keyring_usable():
442+
return KeyringCredentialsService()
443+
444+
# Keyring unavailable - fall back to in-memory (not persisted)
418445
return PlaintextCredentialsService()
419446

420447

sqlit/domains/connections/app/mock_profiles.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Callable
66
from dataclasses import dataclass, field
7+
from pathlib import Path
78

89
from sqlit.domains.connections.domain.config import ConnectionConfig
910
from sqlit.domains.connections.providers.adapters.base import ColumnInfo
@@ -64,12 +65,13 @@ def get_provider(self, db_type: str) -> DatabaseProvider:
6465

6566
def _create_sqlite_demo_profile() -> MockProfile:
6667
"""Create the sqlite-demo profile with pre-configured connection."""
68+
demo_db_path = Path(__file__).resolve().parents[4] / "docs" / "demos" / "demo.db"
6769
connections = [
6870
ConnectionConfig.from_dict(
6971
{
7072
"name": "Demo SQLite",
7173
"db_type": "sqlite",
72-
"endpoint": {"kind": "file", "path": "./docs/demos/demo.db"},
74+
"endpoint": {"kind": "file", "path": str(demo_db_path)},
7375
}
7476
),
7577
]

sqlit/domains/explorer/ui/mixins/tree.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,10 @@ async def work_async() -> None:
353353

354354
columns: list[Any] = []
355355
try:
356-
use_worker = bool(getattr(self.services.runtime, "process_worker", False))
356+
runtime = getattr(self.services, "runtime", None)
357+
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(
358+
getattr(getattr(runtime, "mock", None), "enabled", False)
359+
)
357360
client = None
358361
if use_worker and hasattr(self, "_get_process_worker_client_async"):
359362
client = await self._get_process_worker_client_async() # type: ignore[attr-defined]

sqlit/domains/explorer/ui/tree/loaders.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ async def work_async() -> None:
6262

6363
try:
6464
columns = []
65-
use_worker = bool(getattr(host.services.runtime, "process_worker", False))
65+
runtime = getattr(host.services, "runtime", None)
66+
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(
67+
getattr(getattr(runtime, "mock", None), "enabled", False)
68+
)
6669
if use_worker and hasattr(host, "_get_process_worker_client_async"):
6770
client = await host._get_process_worker_client_async() # type: ignore[attr-defined]
6871
else:
@@ -96,9 +99,10 @@ async def work_async() -> None:
9699
lambda: on_columns_loaded(host, node, db_name, schema_name, obj_name, columns),
97100
)
98101
except Exception as error:
102+
error_message = f"Error loading columns: {error}"
99103
host.set_timer(
100104
MIN_TIMER_DELAY_S,
101-
lambda: on_tree_load_error(host, node, f"Error loading columns: {error}"),
105+
lambda: on_tree_load_error(host, node, error_message),
102106
)
103107

104108
host.run_worker(work_async(), name=f"load-columns-{obj_name}", exclusive=False)
@@ -151,7 +155,10 @@ async def work_async() -> None:
151155

152156
try:
153157
items: list[Any] = []
154-
use_worker = bool(getattr(host.services.runtime, "process_worker", False))
158+
runtime = getattr(host.services, "runtime", None)
159+
use_worker = bool(getattr(runtime, "process_worker", False)) and not bool(
160+
getattr(getattr(runtime, "mock", None), "enabled", False)
161+
)
155162
client = None
156163
if use_worker and hasattr(host, "_get_process_worker_client_async"):
157164
client = await host._get_process_worker_client_async() # type: ignore[attr-defined]
@@ -183,9 +190,10 @@ async def work_async() -> None:
183190
lambda: on_folder_loaded(host, node, db_name, folder_type, items),
184191
)
185192
except Exception as error:
193+
error_message = f"Error loading: {error}"
186194
host.set_timer(
187195
MIN_TIMER_DELAY_S,
188-
lambda: on_tree_load_error(host, node, f"Error loading: {error}"),
196+
lambda: on_tree_load_error(host, node, error_message),
189197
)
190198

191199
host.run_worker(work_async(), name=f"load-folder-{folder_type}", exclusive=False)

sqlit/domains/process_worker/ui/mixins/process_worker_lifecycle.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ def _use_process_worker(self: QueryMixinHost, provider: Any) -> bool:
2020
runtime = getattr(self.services, "runtime", None)
2121
if not runtime or not getattr(runtime, "process_worker", False):
2222
return False
23+
if bool(getattr(getattr(runtime, "mock", None), "enabled", False)):
24+
return False
2325
return True
2426

2527
def _get_process_worker_client(self: QueryMixinHost) -> Any | None:
@@ -146,6 +148,8 @@ def _schedule_process_worker_warm(self: QueryMixinHost) -> None:
146148
runtime = getattr(self.services, "runtime", None)
147149
if runtime is None or not getattr(runtime, "process_worker_warm_on_idle", False):
148150
return
151+
if bool(getattr(getattr(runtime, "mock", None), "enabled", False)):
152+
return
149153
from sqlit.domains.shell.app.idle_scheduler import Priority, get_idle_scheduler
150154

151155
scheduler = get_idle_scheduler()
@@ -158,6 +162,8 @@ def _warm() -> None:
158162
return
159163
if not getattr(self.services.runtime, "process_worker_warm_on_idle", False):
160164
return
165+
if bool(getattr(getattr(self.services.runtime, "mock", None), "enabled", False)):
166+
return
161167
self.run_worker(
162168
self._get_process_worker_client_async(),
163169
name="process-worker-warm",

sqlit/domains/shell/app/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from .router import dispatch_command, register_command_handler
6+
from . import credentials as _credentials
67
from . import debug as _debug
78
from . import watchdog as _watchdog
89
from . import worker as _worker
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Credentials storage command handler."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from .router import register_command_handler
8+
9+
10+
def _migrate_credentials(
11+
app: Any,
12+
old_service: Any,
13+
new_service: Any,
14+
) -> tuple[int, int]:
15+
"""Migrate credentials from old service to new service.
16+
17+
Returns:
18+
Tuple of (migrated_count, error_count).
19+
"""
20+
migrated = 0
21+
errors = 0
22+
23+
for conn in getattr(app, "connections", []):
24+
name = getattr(conn, "name", None)
25+
if not name:
26+
continue
27+
28+
# Migrate database password
29+
try:
30+
db_pw = old_service.get_password(name)
31+
if db_pw:
32+
new_service.set_password(name, db_pw)
33+
migrated += 1
34+
except Exception:
35+
errors += 1
36+
37+
# Migrate SSH password
38+
try:
39+
ssh_pw = old_service.get_ssh_password(name)
40+
if ssh_pw:
41+
new_service.set_ssh_password(name, ssh_pw)
42+
migrated += 1
43+
except Exception:
44+
errors += 1
45+
46+
return migrated, errors
47+
48+
49+
def _handle_credentials_command(app: Any, cmd: str, args: list[str]) -> bool:
50+
if cmd != "credentials":
51+
return False
52+
53+
from sqlit.domains.connections.app.credentials import (
54+
ALLOW_PLAINTEXT_CREDENTIALS_SETTING,
55+
KeyringCredentialsService,
56+
PlaintextFileCredentialsService,
57+
build_credentials_service,
58+
is_keyring_usable,
59+
reset_credentials_service,
60+
)
61+
62+
value = args[0].lower() if args else ""
63+
64+
if value == "plaintext":
65+
settings = app.services.settings_store.load_all()
66+
was_plaintext = settings.get(ALLOW_PLAINTEXT_CREDENTIALS_SETTING) is True
67+
68+
# Try to migrate from keyring if switching
69+
migrated = 0
70+
if not was_plaintext and is_keyring_usable():
71+
try:
72+
old_service = KeyringCredentialsService()
73+
new_service = PlaintextFileCredentialsService()
74+
migrated, _ = _migrate_credentials(app, old_service, new_service)
75+
except Exception:
76+
pass # Migration is best-effort
77+
78+
# Enable plaintext storage
79+
settings[ALLOW_PLAINTEXT_CREDENTIALS_SETTING] = True
80+
app.services.settings_store.save_all(settings)
81+
82+
# Rebuild credentials service
83+
reset_credentials_service()
84+
app.services.credentials_service = build_credentials_service(app.services.settings_store)
85+
if hasattr(app.services.connection_store, "set_credentials_service"):
86+
app.services.connection_store.set_credentials_service(app.services.credentials_service)
87+
88+
msg = "Credentials will be stored as plaintext in ~/.sqlit/ (protected folder)"
89+
if migrated > 0:
90+
msg += f" ({migrated} password(s) migrated from keyring)"
91+
app.notify(msg)
92+
return True
93+
94+
if value == "keyring":
95+
from sqlit.shared.core.store import CONFIG_DIR
96+
97+
settings = app.services.settings_store.load_all()
98+
was_plaintext = settings.get(ALLOW_PLAINTEXT_CREDENTIALS_SETTING) is True
99+
100+
if not is_keyring_usable():
101+
app.notify("Keyring unavailable. Cannot switch to keyring storage.", severity="warning")
102+
return True
103+
104+
# Try to migrate from plaintext if switching
105+
migrated = 0
106+
if was_plaintext:
107+
try:
108+
old_service = PlaintextFileCredentialsService()
109+
new_service = KeyringCredentialsService()
110+
migrated, _ = _migrate_credentials(app, old_service, new_service)
111+
except Exception:
112+
pass # Migration is best-effort
113+
114+
# Clear plaintext credentials file after migration
115+
try:
116+
creds_file = CONFIG_DIR / "credentials.json"
117+
if creds_file.exists():
118+
creds_file.unlink()
119+
except Exception:
120+
pass # Best-effort cleanup
121+
122+
# Switch to keyring
123+
settings[ALLOW_PLAINTEXT_CREDENTIALS_SETTING] = False
124+
app.services.settings_store.save_all(settings)
125+
126+
# Rebuild credentials service
127+
reset_credentials_service()
128+
app.services.credentials_service = build_credentials_service(app.services.settings_store)
129+
if hasattr(app.services.connection_store, "set_credentials_service"):
130+
app.services.connection_store.set_credentials_service(app.services.credentials_service)
131+
132+
msg = "Credentials will be stored in system keyring"
133+
if migrated > 0:
134+
msg += f" ({migrated} password(s) migrated from plaintext)"
135+
app.notify(msg)
136+
return True
137+
138+
if not value:
139+
# Show current status
140+
settings = app.services.settings_store.load_all()
141+
allow_plaintext = settings.get(ALLOW_PLAINTEXT_CREDENTIALS_SETTING)
142+
keyring_ok = is_keyring_usable()
143+
144+
if allow_plaintext:
145+
app.notify("Credentials: plaintext (~/.sqlit/credentials.json)")
146+
elif keyring_ok:
147+
app.notify("Credentials: system keyring")
148+
else:
149+
app.notify("Credentials: keyring unavailable, passwords not persisted", severity="warning")
150+
return True
151+
152+
app.notify("Usage: :credentials [plaintext|keyring]", severity="warning")
153+
return True
154+
155+
156+
register_command_handler(_handle_credentials_command)

0 commit comments

Comments
 (0)