ghostbase-cli 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
5
5
  Author-email: 4yk5 <contact.4yk5@proton.me>
6
6
  License-Expression: MIT
@@ -342,7 +342,7 @@ class GhostBaseApp(App):
342
342
  yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
343
343
  yield Label(db_label, id="db-name-label")
344
344
  yield Label(safe_text, id="safe-badge", classes=safe_class)
345
- yield Label("v0.1.2", id="version-label")
345
+ yield Label("v0.1.4", id="version-label")
346
346
 
347
347
  with Horizontal(id="main-container"):
348
348
  with Vertical(id="sidebar"):
@@ -389,9 +389,14 @@ class GhostBaseApp(App):
389
389
  # Lifecycle
390
390
 
391
391
  async def on_mount(self):
392
- # Configure database engine level Read-Only mode if safe_mode is enabled
392
+ # Configure database engine level Read-Only mode if safe_mode is enabled (strictly fail-closed)
393
393
  if self.safe_mode:
394
- self.db.set_read_only(True)
394
+ try:
395
+ self.db.set_read_only(True)
396
+ except Exception as e:
397
+ self.notify(f"Security Alert: Failed to enforce Read-Only mode on database: {e}", severity="error")
398
+ self.exit(return_code=1)
399
+ return
395
400
 
396
401
  tables = self.db.get_tables()
397
402
  # Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
@@ -810,13 +815,18 @@ class GhostBaseApp(App):
810
815
  self.notify("Execution Plan analyzed successfully")
811
816
 
812
817
  def action_toggle_safe_mode(self):
813
- """Toggle Production Safe Mode (Ctrl+S)"""
814
- self.safe_mode = not self.safe_mode
818
+ """Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
819
+ new_mode = not self.safe_mode
815
820
  self._pending_destructive_query = None
816
821
  self._pending_destructive_time = 0.0
817
822
 
818
- # Enforce/revoke database session level Read-Only
819
- self.db.set_read_only(self.safe_mode)
823
+ # Enforce/revoke database session level Read-Only (Fail-closed)
824
+ try:
825
+ self.db.set_read_only(new_mode)
826
+ self.safe_mode = new_mode
827
+ except Exception as e:
828
+ self.notify(f"Cannot enable Safe Mode: {e}", severity="error", timeout=5)
829
+ return
820
830
 
821
831
  badge = self.query_one("#safe-badge", Label)
822
832
  if self.safe_mode:
@@ -905,7 +915,7 @@ class GhostBaseApp(App):
905
915
  else os.getcwd()
906
916
  )
907
917
  out = os.path.join(db_dir, "ghostbase_export.sql")
908
- Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out)
918
+ Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out, dialect=self.db.db_type)
909
919
  self.notify(f"Saved: {out}", timeout=5)
910
920
 
911
921
  # Status bar
@@ -52,7 +52,7 @@ class DatabaseEngine:
52
52
  "Run: pip install psycopg2-binary"
53
53
  )
54
54
  self.conn = psycopg2.connect(url)
55
- # Enable autocommit so transactions are not left IDLE and RETURNING queries commit immediately
55
+ # Enable autocommit so write transactions are not left IDLE and RETURNING queries commit immediately
56
56
  self.conn.autocommit = True
57
57
 
58
58
  def _connect_mysql(self, url: str):
@@ -65,20 +65,24 @@ class DatabaseEngine:
65
65
  "Run: pip install pymysql"
66
66
  )
67
67
  parsed = urllib.parse.urlparse(url)
68
+ # Use SSDictCursor (Server-Side streaming cursor) to protect client memory on massive queries
68
69
  self.conn = pymysql.connect(
69
70
  host=parsed.hostname or "localhost",
70
71
  port=parsed.port or 3306,
71
72
  user=parsed.username or "root",
72
73
  password=parsed.password or "",
73
74
  database=parsed.path.lstrip("/"),
74
- cursorclass=pymysql.cursors.DictCursor,
75
+ cursorclass=pymysql.cursors.SSDictCursor,
75
76
  autocommit=True,
76
77
  )
77
78
 
78
- def set_read_only(self, enabled: bool):
79
- """Configure database engine session-level Read-Only mode."""
79
+ def set_read_only(self, enabled: bool) -> bool:
80
+ """
81
+ Configure database engine session-level Read-Only mode.
82
+ Returns True if successful. Raises RuntimeError if enabling fails (fail-closed).
83
+ """
80
84
  if not self.conn:
81
- return
85
+ return False
82
86
  try:
83
87
  cur = self.conn.cursor()
84
88
  if self.db_type == "sqlite":
@@ -90,8 +94,11 @@ class DatabaseEngine:
90
94
  elif self.db_type == "mysql":
91
95
  mode = "READ ONLY" if enabled else "READ WRITE"
92
96
  cur.execute(f"SET SESSION TRANSACTION {mode};")
93
- except Exception:
94
- pass
97
+ return True
98
+ except Exception as e:
99
+ if enabled:
100
+ raise RuntimeError(f"Database engine failed to activate Read-Only mode: {e}")
101
+ return False
95
102
 
96
103
  def quote_table_name(self, table_name: str) -> str:
97
104
  """Properly quote table identifier for queries based on database engine."""
@@ -103,6 +110,9 @@ class DatabaseEngine:
103
110
  return f'"{s}"."{t}"'
104
111
  return f'"{table_name}"'
105
112
  elif self.db_type == "mysql":
113
+ if "." in table_name:
114
+ s, t = table_name.split(".", 1)
115
+ return f"`{s}`.`{t}`"
106
116
  return f"`{table_name}`"
107
117
  else: # sqlite
108
118
  return f"[{table_name}]"
@@ -347,10 +357,12 @@ class DatabaseEngine:
347
357
  def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
348
358
  """
349
359
  Extract and mask all string literals and quoted identifiers:
350
- - PostgreSQL dollar-quoted strings ($$ ... $$ or $tag$ ... $tag$)
351
- - Standard single-quoted strings ('...')
352
- - Standard double-quoted strings / identifiers ("...")
353
- - MySQL backtick identifiers (`...`)
360
+ - Plain untagged dollar-quotes: $$...$$
361
+ - Tagged dollar-quotes with strict matching backreference \2: $tag$...$tag$
362
+ - PostgreSQL E-strings: E'...' (supporting backslash escapes \')
363
+ - Standard single-quoted strings: '...' (supporting \' and '')
364
+ - Standard double-quoted strings / identifiers: "..."
365
+ - MySQL backtick identifiers: `...`
354
366
  """
355
367
  literals = []
356
368
  def _repl(m):
@@ -358,7 +370,7 @@ class DatabaseEngine:
358
370
  literals.append(m.group(0))
359
371
  return f" ___GB_LIT_{idx}___ "
360
372
 
361
- pattern = r"(\$(?:[a-zA-Z_]\w*)?\$[\s\S]*?\$(?:[a-zA-Z_]\w*)?\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
373
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`)"
362
374
  masked = re.sub(pattern, _repl, sql)
363
375
  return masked, literals
364
376
 
@@ -366,16 +378,17 @@ class DatabaseEngine:
366
378
  """
367
379
  Check query safety.
368
380
  - Strings and dollar-quotes are masked FIRST so inner text ('WHERE ...') does not bypass checks.
369
- - Comments (-- and /* */) are stripped second.
370
- - In Safe Mode: Enforces strict Read-Only operations.
381
+ - Comments (-- , /* */, and MySQL #) are stripped second.
382
+ - In Safe Mode: Enforces strict Read-Only operations, blocking all DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions.
371
383
  - In Normal Mode: Intercepts unconstrained destructive queries.
372
384
  """
373
385
  # 1. Mask all string literals and dollar-quoted strings FIRST
374
386
  masked, _ = self._mask_strings_and_literals(query)
375
387
 
376
- # 2. Strip block comments /* ... */ and line comments -- ...
388
+ # 2. Strip block comments /* ... */, line comments -- ..., and MySQL # ...
377
389
  q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
378
390
  q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
391
+ q_no_comments = re.sub(r"#[^\r\n]*", " ", q_no_comments)
379
392
 
380
393
  q_clean = q_no_comments.strip()
381
394
  if not q_clean:
@@ -397,13 +410,27 @@ class DatabaseEngine:
397
410
  (r"\bGRANT\b", "GRANT statement"),
398
411
  (r"\bREVOKE\b", "REVOKE statement"),
399
412
  (r"\bVACUUM\b", "VACUUM statement"),
413
+ (r"\bSET\b", "SET configuration change"),
414
+ (r"\bRESET\b", "RESET configuration change"),
415
+ (r"\bDO\b", "DO procedural block"),
416
+ (r"\bCALL\b", "CALL stored procedure"),
417
+ (r"\bMERGE\b", "MERGE statement"),
418
+ (r"\bSET_CONFIG\s*\(", "SET_CONFIG parameter modification"),
419
+ (r"\bSETVAL\s*\(", "SETVAL sequence mutation"),
400
420
  (r"\bNEXTVAL\s*\(", "NEXTVAL sequence advance"),
421
+ (r"\bCURRVAL\s*\(", "CURRVAL sequence check"),
422
+ (r"\bLASTVAL\s*\(", "LASTVAL sequence check"),
423
+ (r"\bPG_TERMINATE_BACKEND\b", "PG_TERMINATE_BACKEND admin call"),
424
+ (r"\bPG_CANCEL_BACKEND\b", "PG_CANCEL_BACKEND admin call"),
425
+ (r"\bLO_IMPORT\b", "LO_IMPORT filesystem read"),
426
+ (r"\bLO_EXPORT\b", "LO_EXPORT filesystem write"),
427
+ (r"\bDBLINK\b", "DBLINK external query execution"),
401
428
  (r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
402
429
  (r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
403
430
  ]
404
431
  for pattern, desc in write_keywords:
405
432
  if re.search(pattern, q_upper):
406
- return True, f"Safe Mode is active: write operations ({desc}) are strictly blocked"
433
+ return True, f"Safe Mode is active: write or state-altering operations ({desc}) are strictly blocked"
407
434
 
408
435
  if re.search(r"\bPRAGMA\b\s+\w+\s*=", q_upper):
409
436
  return True, "Safe Mode is active: PRAGMA configuration writes are blocked"
@@ -427,7 +454,8 @@ class DatabaseEngine:
427
454
  def explain_query(self, query: str) -> Dict[str, Any]:
428
455
  masked, literals = self._mask_strings_and_literals(query)
429
456
  clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
430
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().rstrip(";")
457
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
458
+ clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().rstrip(";")
431
459
  for idx, orig in enumerate(literals):
432
460
  clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
433
461
 
@@ -453,7 +481,7 @@ class DatabaseEngine:
453
481
  sql_lines = []
454
482
  for line in lines:
455
483
  stripped = line.strip()
456
- if stripped.startswith("--"):
484
+ if stripped.startswith(("--", "#")):
457
485
  comment_lines.append(stripped)
458
486
  else:
459
487
  sql_lines.append(line)
@@ -504,17 +532,63 @@ class DatabaseEngine:
504
532
 
505
533
  def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
506
534
  start = time.perf_counter()
535
+ cur = None
507
536
  try:
508
537
  # Check if query is DML/DDL write operation (even if it has RETURNING)
509
538
  masked, _ = self._mask_strings_and_literals(query)
510
539
  clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
511
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().upper()
540
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
541
+ clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().upper()
512
542
  is_write_dml = bool(
513
- re.search(r"\b(INSERT\s+INTO|UPDATE\s+|DELETE\s+FROM|DELETE\s+|REPLACE\s+INTO|CREATE\s+|DROP\s+|ALTER\s+|TRUNCATE\s+)\b", clean_q)
543
+ re.search(r"\b(INSERT\s+INTO|UPDATE\s+|DELETE\s+FROM|DELETE\s+|REPLACE\s+INTO|CREATE\s+|DROP\s+|ALTER\s+|TRUNCATE\s+|DO\s+|CALL\s+|MERGE\s+)\b", clean_q)
514
544
  )
515
545
 
546
+ # PostgreSQL streaming: Use server-side named cursor to prevent pulling entire table into client RAM
547
+ if self.db_type == "postgresql" and not is_write_dml:
548
+ stream_name = f"gb_stream_{int(time.perf_counter() * 1000000)}"
549
+ # Disable autocommit temporarily for named cursor creation in psycopg2
550
+ self.conn.autocommit = False
551
+ try:
552
+ cur = self.conn.cursor(name=stream_name)
553
+ cur.itersize = max_rows + 1
554
+ cur.execute(query)
555
+ if cur.description:
556
+ columns = [d[0] for d in cur.description]
557
+ raw = cur.fetchmany(max_rows + 1)
558
+ truncated = len(raw) > max_rows
559
+ display_rows = raw[:max_rows]
560
+ rows = [list(r.values()) if isinstance(r, dict) else list(r) for r in display_rows]
561
+ cur.close()
562
+ self.conn.commit() # Cleanly close read transaction without leaving idle connection
563
+ elapsed_ms = (time.perf_counter() - start) * 1000
564
+ return {
565
+ "success": True,
566
+ "columns": columns,
567
+ "rows": rows,
568
+ "row_count": len(rows),
569
+ "affected_rows": 0,
570
+ "truncated": truncated,
571
+ "elapsed_ms": round(elapsed_ms, 2),
572
+ "is_select": True,
573
+ "is_returning": False,
574
+ }
575
+ except Exception as e:
576
+ try:
577
+ self.conn.rollback()
578
+ except Exception:
579
+ pass
580
+ raise e
581
+ finally:
582
+ self.conn.autocommit = True
583
+
584
+ # MySQL optimization: Auto-apply LIMIT on SELECTs without LIMIT to prevent server unbuffered socket drain
585
+ exec_query = query
586
+ if self.db_type == "mysql" and not is_write_dml:
587
+ if not re.search(r"\bLIMIT\b", clean_q, flags=re.IGNORECASE):
588
+ exec_query = query.rstrip().rstrip(";") + f" LIMIT {max_rows + 1};"
589
+
516
590
  cur = self.conn.cursor()
517
- cur.execute(query)
591
+ cur.execute(exec_query)
518
592
  elapsed_ms = (time.perf_counter() - start) * 1000
519
593
 
520
594
  if cur.description:
@@ -569,6 +643,12 @@ class DatabaseEngine:
569
643
  "error": str(e),
570
644
  "elapsed_ms": round(elapsed_ms, 2),
571
645
  }
646
+ finally:
647
+ if cur:
648
+ try:
649
+ cur.close()
650
+ except Exception:
651
+ pass
572
652
 
573
653
  # --- Helpers ---
574
654
 
@@ -40,11 +40,14 @@ class Exporter:
40
40
  return deduped
41
41
 
42
42
  @staticmethod
43
- def _escape_ident(ident: str) -> str:
43
+ def _escape_ident(ident: str, dialect: str = "sqlite") -> str:
44
44
  s = str(ident).strip()
45
+ q = "`" if str(dialect).lower() == "mysql" else '"'
46
+ escaped_q = q * 2
47
+
45
48
  if "." in s:
46
49
  parts = s.split(".")
47
- return ".".join(f'"{p.replace(chr(34), chr(34)*2)}"' for p in parts)
50
+ return ".".join(f"{q}{p.replace(q, escaped_q)}{q}" for p in parts)
48
51
 
49
52
  reserved = {
50
53
  "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE",
@@ -59,8 +62,8 @@ class Exporter:
59
62
  "USER", "VALUES", "VIEW", "WHEN", "WHERE", "WITH"
60
63
  }
61
64
  if not s.isidentifier() or s.upper() in reserved:
62
- escaped = s.replace('"', '""')
63
- return f'"{escaped}"'
65
+ escaped = s.replace(q, escaped_q)
66
+ return f"{q}{escaped}{q}"
64
67
  return s
65
68
 
66
69
  @staticmethod
@@ -95,13 +98,13 @@ class Exporter:
95
98
  f.write("| " + " | ".join(clean_cells) + " |\n")
96
99
 
97
100
  @staticmethod
98
- def to_sql_inserts(table_name: str, columns: List[str], rows: List[List[Any]], output_path: str):
101
+ def to_sql_inserts(table_name: str, columns: List[str], rows: List[List[Any]], output_path: str, dialect: str = "sqlite"):
99
102
  table = (table_name or "exported_table").strip()
100
- escaped_table = Exporter._escape_ident(table)
101
- escaped_cols = [Exporter._escape_ident(c) for c in columns]
103
+ escaped_table = Exporter._escape_ident(table, dialect=dialect)
104
+ escaped_cols = [Exporter._escape_ident(c, dialect=dialect) for c in columns]
102
105
  cols_str = ", ".join(escaped_cols)
103
106
  with open(output_path, "w", encoding="utf-8") as f:
104
- f.write(f"-- GhostBase SQL Export: {table}\n")
107
+ f.write(f"-- GhostBase SQL Export: {table} ({dialect})\n")
105
108
  f.write(f"-- Total: {len(rows)} row(s)\n\n")
106
109
  for r in rows:
107
110
  vals = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
5
5
  Author-email: 4yk5 <contact.4yk5@proton.me>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghostbase-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "4yk5", email = "contact.4yk5@proton.me" }]
@@ -41,6 +41,21 @@ def test_safe_mode_and_comment_bypass():
41
41
  is_dest, r = db.check_safety(dollar_bypass, is_safe_mode=False)
42
42
  assert is_dest, f"VULNERABILITY: Dollar-quoted string containing WHERE fooled check_safety: {r}"
43
43
 
44
+ # Postgres dollar-quoted string with tag mismatch ($tag$ vs $other$)
45
+ mismatched_tag_query = "UPDATE users SET role = $tag$abc $other$ WHERE marker $tag$;"
46
+ is_dest, r = db.check_safety(mismatched_tag_query, is_safe_mode=False)
47
+ assert is_dest, f"VULNERABILITY: Dollar-quoted tag mismatch bypassed WHERE check: {r}"
48
+
49
+ # Postgres E'...' string with backslash escaped quote
50
+ e_string_query = "UPDATE users SET role = E'foo\\' WHERE false';"
51
+ is_dest, r = db.check_safety(e_string_query, is_safe_mode=False)
52
+ assert is_dest, f"VULNERABILITY: PostgreSQL E-string with escaped quote bypassed WHERE check: {r}"
53
+
54
+ # MySQL # comment bypass attempt
55
+ mysql_comment_query = "UPDATE users SET role = 'Member' # WHERE id = 1\n;"
56
+ is_dest, r = db.check_safety(mysql_comment_query, is_safe_mode=False)
57
+ assert is_dest, f"VULNERABILITY: MySQL # comment containing WHERE fooled check_safety: {r}"
58
+
44
59
  # Safe Mode STRICT read-only checks
45
60
  safe_queries = [
46
61
  "SELECT * FROM users WHERE id = 5;",
@@ -52,6 +67,7 @@ def test_safe_mode_and_comment_bypass():
52
67
  is_dest, r = db.check_safety(q, is_safe_mode=True)
53
68
  assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
54
69
 
70
+ # Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions
55
71
  unsafe_queries = [
56
72
  ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
57
73
  ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
@@ -61,7 +77,15 @@ def test_safe_mode_and_comment_bypass():
61
77
  ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
62
78
  ("CREATE TABLE test (id INT);", "CREATE"),
63
79
  ("SELECT nextval('users_id_seq');", "NEXTVAL"),
80
+ ("SELECT setval('users_id_seq', 1, false);", "SETVAL"),
81
+ ("SELECT set_config('default_transaction_read_only', 'off', false);", "SET_CONFIG"),
82
+ ("SELECT pg_terminate_backend(1234);", "PG_TERMINATE_BACKEND"),
64
83
  ("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
84
+ ("SET default_transaction_read_only = OFF;", "SET"),
85
+ ("RESET ALL;", "RESET"),
86
+ ("DO $body$ BEGIN UPDATE users SET role = 'Admin'; END $body$;", "DO"),
87
+ ("CALL custom_proc();", "CALL"),
88
+ ("MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN DELETE;", "MERGE"),
65
89
  ]
66
90
  for q, op in unsafe_queries:
67
91
  is_dest, r = db.check_safety(q, is_safe_mode=True)
@@ -72,7 +96,7 @@ def test_safe_mode_and_comment_bypass():
72
96
 
73
97
 
74
98
  def test_database_engine_level_readonly():
75
- print("[*] 2. Testing Database Engine-Level Read-Only Enforcement...")
99
+ print("[*] 2. Testing Database Engine-Level Read-Only & Fail-Closed Enforcement...")
76
100
  db = DatabaseEngine(":memory:")
77
101
  db.conn.execute("CREATE TABLE read_only_test (id INT, val TEXT);")
78
102
  db.conn.commit()
@@ -145,22 +169,27 @@ def test_sqlite_foreign_keys_enforced():
145
169
 
146
170
 
147
171
  def test_empty_sqlite_not_polluted():
148
- print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode...")
172
+ print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode (with actual on_mount)...")
173
+ import asyncio
149
174
  from app import GhostBaseApp
150
175
  with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
151
176
  empty_db_path = f.name
152
177
 
153
- try:
154
- # Launch in read-only / safe mode
178
+ async def _run():
155
179
  app = GhostBaseApp(db_path=empty_db_path, read_only=True, populate_sample=True)
156
- tables = app.db.get_tables()
157
- assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
158
- app.db.close()
180
+ async with app.run_test() as pilot:
181
+ await pilot.pause(0.1)
182
+ tables = app.db.get_tables()
183
+ assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
184
+ assert app.safe_mode is True
185
+
186
+ try:
187
+ asyncio.run(_run())
159
188
  finally:
160
189
  if os.path.exists(empty_db_path):
161
190
  os.remove(empty_db_path)
162
191
 
163
- print(" [+] Empty Database Integrity in Read-Only: PASS")
192
+ print(" [+] Empty Database Integrity in Read-Only (on_mount verified): PASS")
164
193
 
165
194
 
166
195
  def test_returning_dml_classification():
@@ -180,11 +209,12 @@ def test_returning_dml_classification():
180
209
  print(" [+] DML with RETURNING Classification: PASS")
181
210
 
182
211
 
183
- def test_exporter_dedup_and_ident_escaping():
184
- print("[*] 7. Testing Exporter JSON Types & SQL Identifier Escaping...")
212
+ def test_exporter_json_types_and_dialect_sql():
213
+ print("[*] 7. Testing Exporter JSON Types & Dialect-Aware SQL Escaping (MySQL vs Postgres/SQLite)...")
185
214
  tmp = tempfile.gettempdir()
186
215
  json_path = os.path.join(tmp, "test_types.json")
187
- sql_path = os.path.join(tmp, "test_escape.sql")
216
+ sql_pg_path = os.path.join(tmp, "test_pg.sql")
217
+ sql_my_path = os.path.join(tmp, "test_mysql.sql")
188
218
 
189
219
  # JSON export with Decimal, datetime, UUID, bytes, and duplicate column names
190
220
  cols = ["id", "id", "price", "created", "uid", "raw"]
@@ -206,17 +236,24 @@ def test_exporter_dedup_and_ident_escaping():
206
236
  assert data[0]["uid"] == "12345678-1234-5678-1234-567812345678"
207
237
  assert data[0]["raw"] == "binary_ghost"
208
238
 
209
- # SQL export schema-qualified tables & reserved words
239
+ # PostgreSQL / SQLite dialect SQL export (Double quotes)
210
240
  table_name = "analytics.order"
211
241
  cols_special = ["first name", "group", "price"]
212
242
  rows_special = [["Bob", "Admin", decimal.Decimal("10.50")]]
213
- Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_path)
214
- with open(sql_path, "r", encoding="utf-8") as f:
215
- sql_content = f.read()
243
+ Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_pg_path, dialect="postgresql")
244
+ with open(sql_pg_path, "r", encoding="utf-8") as f:
245
+ pg_content = f.read()
246
+
247
+ assert 'INSERT INTO "analytics"."order" ("first name", "group", price) VALUES (\'Bob\', \'Admin\', 10.50);' in pg_content
248
+
249
+ # MySQL dialect SQL export (Backticks)
250
+ Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_my_path, dialect="mysql")
251
+ with open(sql_my_path, "r", encoding="utf-8") as f:
252
+ my_content = f.read()
216
253
 
217
- assert 'INSERT INTO "analytics"."order" ("first name", "group", price) VALUES (\'Bob\', \'Admin\', 10.50);' in sql_content
254
+ assert 'INSERT INTO `analytics`.`order` (`first name`, `group`, price) VALUES (\'Bob\', \'Admin\', 10.50);' in my_content
218
255
 
219
- print(" [+] Exporter JSON Types & SQL Escaping: PASS")
256
+ print(" [+] Exporter JSON Types & Dialect-Aware SQL Escaping: PASS")
220
257
 
221
258
 
222
259
  def test_table_quoting_and_cte_export_detection():
@@ -232,6 +269,7 @@ def test_table_quoting_and_cte_export_detection():
232
269
  db_my = DatabaseEngine.__new__(DatabaseEngine)
233
270
  db_my.db_type = "mysql"
234
271
  assert db_my.quote_table_name("order") == "`order`"
272
+ assert db_my.quote_table_name("analytics.users") == "`analytics`.`users`"
235
273
 
236
274
  # CTE target detection test
237
275
  cte_query = """
@@ -266,7 +304,7 @@ def test_result_set_memory_limit():
266
304
 
267
305
  def main():
268
306
  print("\n" + "=" * 60)
269
- print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.2)")
307
+ print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.3)")
270
308
  print("=" * 60)
271
309
 
272
310
  test_safe_mode_and_comment_bypass()
@@ -275,12 +313,12 @@ def main():
275
313
  test_sqlite_foreign_keys_enforced()
276
314
  test_empty_sqlite_not_polluted()
277
315
  test_returning_dml_classification()
278
- test_exporter_dedup_and_ident_escaping()
316
+ test_exporter_json_types_and_dialect_sql()
279
317
  test_table_quoting_and_cte_export_detection()
280
318
  test_result_set_memory_limit()
281
319
 
282
320
  print("\n" + "=" * 60)
283
- print("ALL 9 CRITICAL & HIGH EDGE CASES VERIFIED AND 100% PASSING!")
321
+ print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
284
322
  print("=" * 60 + "\n")
285
323
 
286
324
 
File without changes
File without changes
File without changes
File without changes