ghostbase-cli 0.1.3__tar.gz → 0.1.5__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.3
3
+ Version: 0.1.5
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
@@ -314,7 +314,8 @@ class GhostBaseApp(App):
314
314
  self.db_path = db_path or "sample.db"
315
315
  self.populate_sample = populate_sample
316
316
  self.db = DatabaseEngine(self.db_path)
317
- self.safe_mode = read_only
317
+ self._cli_read_only = bool(read_only)
318
+ self.safe_mode = bool(read_only)
318
319
  self.last_columns: list = []
319
320
  self.last_rows: list = []
320
321
  self.last_query_table: str = None
@@ -334,15 +335,19 @@ class GhostBaseApp(App):
334
335
  if self.db.db_type != "sqlite"
335
336
  else os.path.basename(self.db_path)
336
337
  )
337
- safe_text = "[SAFE: ON]" if self.safe_mode else "[SAFE: OFF]"
338
- safe_class = "" if self.safe_mode else "unsafe"
338
+ if self._cli_read_only:
339
+ safe_text = "[SAFE: LOCKED]"
340
+ safe_class = ""
341
+ else:
342
+ safe_text = "[SAFE: ON]" if self.safe_mode else "[SAFE: OFF]"
343
+ safe_class = "" if self.safe_mode else "unsafe"
339
344
 
340
345
  with Horizontal(id="header-bar"):
341
346
  yield Label("GHOSTBASE", id="title-label")
342
347
  yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
343
348
  yield Label(db_label, id="db-name-label")
344
349
  yield Label(safe_text, id="safe-badge", classes=safe_class)
345
- yield Label("v0.1.3", id="version-label")
350
+ yield Label("v0.1.5", id="version-label")
346
351
 
347
352
  with Horizontal(id="main-container"):
348
353
  with Vertical(id="sidebar"):
@@ -389,13 +394,14 @@ class GhostBaseApp(App):
389
394
  # Lifecycle
390
395
 
391
396
  async def on_mount(self):
392
- # Configure database engine level Read-Only mode if safe_mode is enabled (fail-closed)
397
+ # Configure database engine level Read-Only mode if safe_mode is enabled (strictly fail-closed)
393
398
  if self.safe_mode:
394
399
  try:
395
400
  self.db.set_read_only(True)
396
401
  except Exception as e:
397
- self.safe_mode = False
398
- self.notify(f"Failed to enable Read-Only mode on database: {e}", severity="error")
402
+ self.notify(f"Security Alert: Failed to enforce Read-Only mode on database: {e}", severity="error")
403
+ self.exit(return_code=1)
404
+ return
399
405
 
400
406
  tables = self.db.get_tables()
401
407
  # Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
@@ -780,7 +786,7 @@ class GhostBaseApp(App):
780
786
  def action_explain_query(self):
781
787
  """Execute 1-Key EXPLAIN Query Plan (Ctrl+P)"""
782
788
  raw = self.query_one("#query-box", TextArea).text.strip()
783
- lines = [l for l in raw.splitlines() if not l.strip().startswith("--")]
789
+ lines = [l for l in raw.splitlines() if not l.strip().startswith(("--", "#"))]
784
790
  query = "\n".join(lines).strip()
785
791
  if not query:
786
792
  self.notify("No query to explain", severity="warning")
@@ -790,6 +796,19 @@ class GhostBaseApp(App):
790
796
  status = self.query_one("#status-label", Label)
791
797
  res_cnt = self.query_one("#results-count", Label)
792
798
 
799
+ # Central safety verification before EXPLAIN
800
+ statements = self.db.split_statements(query)
801
+ if len(statements) > 1:
802
+ status.update("[bold red]EXPLAIN ERROR[/] Multiple statements not allowed in EXPLAIN")
803
+ self.notify("EXPLAIN only supports a single SQL statement.", severity="error", timeout=4)
804
+ return
805
+
806
+ is_destructive, reason = self.db.check_safety(query, is_safe_mode=self.safe_mode)
807
+ if self.safe_mode and is_destructive:
808
+ status.update(f"[bold red]BLOCKED[/] {reason}")
809
+ self.notify(f"Cannot explain query in Safe Mode: {reason}", severity="error", timeout=5)
810
+ return
811
+
793
812
  res = self.db.explain_query(query)
794
813
  dt.clear(columns=True)
795
814
 
@@ -815,6 +834,10 @@ class GhostBaseApp(App):
815
834
 
816
835
  def action_toggle_safe_mode(self):
817
836
  """Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
837
+ if getattr(self, "_cli_read_only", False):
838
+ self.notify("CLI --read-only flag is active. Safe Mode is locked and cannot be disabled.", severity="warning", timeout=4)
839
+ return
840
+
818
841
  new_mode = not self.safe_mode
819
842
  self._pending_destructive_query = None
820
843
  self._pending_destructive_time = 0.0
@@ -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):
@@ -353,14 +353,38 @@ class DatabaseEngine:
353
353
 
354
354
  # --- Query safety and formatting ---
355
355
 
356
+ @staticmethod
357
+ def _mask_strings(sql: str) -> Tuple[str, List[str]]:
358
+ """
359
+ Extract and mask string literals (data values):
360
+ - Plain untagged dollar-quotes: $$...$$
361
+ - Tagged dollar-quotes: $tag$...$tag$
362
+ - PostgreSQL E-strings: E'...'
363
+ - Standard single-quoted strings: '...'
364
+ """
365
+ literals = []
366
+ def _repl(m):
367
+ idx = len(literals)
368
+ literals.append(m.group(0))
369
+ return f"___GB_STR_{idx}___"
370
+
371
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*')"
372
+ masked = re.sub(pattern, _repl, sql)
373
+ return masked, literals
374
+
375
+ @staticmethod
376
+ def _strip_comments(sql: str) -> str:
377
+ """Strip block comments /* ... */, line comments -- ..., and MySQL # ..."""
378
+ c = re.sub(r"/\*[\s\S]*?\*/", " ", sql)
379
+ c = re.sub(r"--[^\r\n]*", " ", c)
380
+ c = re.sub(r"#[^\r\n]*", " ", c)
381
+ return c
382
+
356
383
  @staticmethod
357
384
  def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
358
385
  """
359
386
  Extract and mask all string literals and quoted identifiers:
360
- - PostgreSQL dollar-quoted strings with exact tag matching: $tag$ ... $tag$ (using backreference \2)
361
- - Standard single-quoted strings: '...' (with '' escaping)
362
- - Standard double-quoted strings / identifiers: "..."
363
- - MySQL backtick identifiers: `...`
387
+ Used by SQL formatter to preserve both literal values and identifier cases.
364
388
  """
365
389
  literals = []
366
390
  def _repl(m):
@@ -368,36 +392,78 @@ class DatabaseEngine:
368
392
  literals.append(m.group(0))
369
393
  return f" ___GB_LIT_{idx}___ "
370
394
 
371
- # Matches:
372
- # 1. Plain untagged dollar-quotes: $$...$$
373
- # 2. Tagged dollar-quotes with strict matching backreference: $tag$...$tag$
374
- # 3. Standard single-quoted strings: '...'
375
- # 4. Standard double-quoted strings/identifiers: "..."
376
- # 5. MySQL backticks: `...`
377
- pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
395
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
378
396
  masked = re.sub(pattern, _repl, sql)
379
397
  return masked, literals
380
398
 
381
- def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
399
+ @classmethod
400
+ def split_statements(cls, sql: str) -> List[str]:
382
401
  """
383
- Check query safety.
384
- - Strings and dollar-quotes are masked FIRST so inner text ('WHERE ...') does not bypass checks.
385
- - Comments (-- and /* */) are stripped second.
386
- - In Safe Mode: Enforces strict Read-Only operations, blocking all DML, DDL, DO blocks, CALL, and SET configuration changes.
387
- - In Normal Mode: Intercepts unconstrained destructive queries.
402
+ Split a multi-statement SQL script into individual statements by semicolon.
403
+ Guarantees that semicolons inside string literals, dollar quotes, comments,
404
+ and quoted identifiers do not cause incorrect statement boundaries.
388
405
  """
389
- # 1. Mask all string literals and dollar-quoted strings FIRST
390
- masked, _ = self._mask_strings_and_literals(query)
406
+ # Mask both strings and quoted identifiers so semicolons inside them are preserved
407
+ tokens = []
408
+ def _repl_tok(m):
409
+ idx = len(tokens)
410
+ tokens.append(m.group(0))
411
+ return f"___GB_TOK_{idx}___"
412
+
413
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
414
+ masked = re.sub(pattern, _repl_tok, sql)
415
+ clean = cls._strip_comments(masked)
416
+ parts = clean.split(";")
417
+ stmts = []
418
+ for p in parts:
419
+ p_str = p.strip()
420
+ if p_str:
421
+ for idx, tok in enumerate(tokens):
422
+ p_str = p_str.replace(f"___GB_TOK_{idx}___", tok)
423
+ stmts.append(p_str)
424
+ return stmts
391
425
 
392
- # 2. Strip block comments /* ... */ and line comments -- ...
393
- q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
394
- q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
395
-
396
- q_clean = q_no_comments.strip()
397
- if not q_clean:
426
+ @staticmethod
427
+ def _normalize_identifiers_for_safety(sql: str) -> str:
428
+ """
429
+ Normalize quoted identifiers ("set_config", `setval`, [col]) for safety checks:
430
+ - Quoted function/object names (e.g. "set_config", "setval", "delete") are unquoted
431
+ so that identifier quotes cannot conceal restricted keywords or functions.
432
+ - If an identifier is literally "WHERE", it represents a column/alias name and CANNOT
433
+ act as a SQL clause keyword; it is masked to ___GB_ID_WHERE___ so it does not
434
+ falsely satisfy the WHERE requirement of UPDATE/DELETE.
435
+ """
436
+ def _repl_ident(m):
437
+ raw = m.group(0)
438
+ inner = raw[1:-1]
439
+ if raw.startswith('"'):
440
+ inner = inner.replace('""', '"')
441
+ elif raw.startswith('`'):
442
+ inner = inner.replace('``', '`')
443
+ elif raw.startswith('['):
444
+ inner = inner.replace(']]', ']')
445
+
446
+ if inner.strip().upper() == "WHERE":
447
+ return " ___GB_ID_WHERE___ "
448
+ return f" {inner} "
449
+
450
+ ident_pattern = r"(\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
451
+ return re.sub(ident_pattern, _repl_ident, sql)
452
+
453
+ def _check_single_statement_safety(self, stmt: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
454
+ """Evaluate safety on an individual, isolated SQL statement."""
455
+ # 1. Mask data literals (single quotes, dollar-quotes, E-strings)
456
+ masked, _ = self._mask_strings(stmt)
457
+
458
+ # 2. Strip comments
459
+ q_no_comments = self._strip_comments(masked)
460
+
461
+ # 3. Normalize identifiers (unquote "set_config", protect "where" columns)
462
+ q_norm = self._normalize_identifiers_for_safety(q_no_comments).strip()
463
+ if not q_norm:
398
464
  return False, ""
399
465
 
400
- q_upper = q_clean.upper()
466
+ q_upper = q_norm.upper()
401
467
 
402
468
  # SAFE MODE: STRICT READ-ONLY ENFORCEMENT
403
469
  if is_safe_mode:
@@ -413,14 +479,23 @@ class DatabaseEngine:
413
479
  (r"\bGRANT\b", "GRANT statement"),
414
480
  (r"\bREVOKE\b", "REVOKE statement"),
415
481
  (r"\bVACUUM\b", "VACUUM statement"),
416
- (r"\bNEXTVAL\s*\(", "NEXTVAL sequence advance"),
417
- (r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
418
- (r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
419
482
  (r"\bSET\b", "SET configuration change"),
420
483
  (r"\bRESET\b", "RESET configuration change"),
421
484
  (r"\bDO\b", "DO procedural block"),
422
485
  (r"\bCALL\b", "CALL stored procedure"),
423
486
  (r"\bMERGE\b", "MERGE statement"),
487
+ (r"\bSET_CONFIG\b", "SET_CONFIG parameter modification"),
488
+ (r"\bSETVAL\b", "SETVAL sequence mutation"),
489
+ (r"\bNEXTVAL\b", "NEXTVAL sequence advance"),
490
+ (r"\bCURRVAL\b", "CURRVAL sequence check"),
491
+ (r"\bLASTVAL\b", "LASTVAL sequence check"),
492
+ (r"\bPG_TERMINATE_BACKEND\b", "PG_TERMINATE_BACKEND admin call"),
493
+ (r"\bPG_CANCEL_BACKEND\b", "PG_CANCEL_BACKEND admin call"),
494
+ (r"\bLO_IMPORT\b", "LO_IMPORT filesystem read"),
495
+ (r"\bLO_EXPORT\b", "LO_EXPORT filesystem write"),
496
+ (r"\bDBLINK\b", "DBLINK external query execution"),
497
+ (r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
498
+ (r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
424
499
  ]
425
500
  for pattern, desc in write_keywords:
426
501
  if re.search(pattern, q_upper):
@@ -431,7 +506,7 @@ class DatabaseEngine:
431
506
 
432
507
  return False, ""
433
508
 
434
- # NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops)
509
+ # NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops per statement)
435
510
  if re.search(r"\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW)\b", q_upper):
436
511
  return True, "DROP operation detected"
437
512
  if re.search(r"\bTRUNCATE\b", q_upper):
@@ -445,20 +520,56 @@ class DatabaseEngine:
445
520
 
446
521
  return False, ""
447
522
 
448
- def explain_query(self, query: str) -> Dict[str, Any]:
449
- masked, literals = self._mask_strings_and_literals(query)
450
- clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
451
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().rstrip(";")
452
- for idx, orig in enumerate(literals):
453
- clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
523
+ def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
524
+ """
525
+ Check script safety across all statements.
526
+ Multi-statement queries are evaluated individually: if ANY statement is destructive
527
+ or violates Safe Mode, the entire batch is flagged.
528
+ """
529
+ statements = self.split_statements(query)
530
+ if not statements:
531
+ return False, ""
454
532
 
455
- if not clean_q:
533
+ for stmt in statements:
534
+ is_dest, reason = self._check_single_statement_safety(stmt, is_safe_mode=is_safe_mode)
535
+ if is_dest:
536
+ return True, reason
537
+
538
+ return False, ""
539
+
540
+ def explain_query(self, query: str) -> Dict[str, Any]:
541
+ """
542
+ Explain query execution plan.
543
+ Strictly allows only a single read statement. Multi-statement batches or destructive
544
+ operations are strictly rejected to prevent execution bypasses.
545
+ """
546
+ statements = self.split_statements(query)
547
+ if not statements:
456
548
  return {"success": False, "error": "Query is empty"}
549
+ if len(statements) > 1:
550
+ return {
551
+ "success": False,
552
+ "error": "EXPLAIN only supports a single SQL statement. Multiple statements are not permitted.",
553
+ }
554
+
555
+ single_q = statements[0].strip().rstrip(";")
556
+
557
+ # Validate safety on the single statement to be explained
558
+ is_dest, reason = self.check_safety(single_q, is_safe_mode=False)
559
+
560
+ # Detect EXPLAIN ANALYZE on destructive queries (PostgreSQL executes the query under ANALYZE)
561
+ masked, _ = self._mask_strings(single_q)
562
+ clean = self._strip_comments(masked).strip().upper()
563
+ if re.search(r"\b(ANALYZE|EXECUTE)\b", clean) and is_dest:
564
+ return {
565
+ "success": False,
566
+ "error": f"EXPLAIN with execution side-effects on destructive statements is blocked: {reason}",
567
+ }
457
568
 
458
569
  if self.db_type == "sqlite":
459
- explain_sql = f"EXPLAIN QUERY PLAN {clean_q};"
570
+ explain_sql = f"EXPLAIN QUERY PLAN {single_q};"
460
571
  else:
461
- explain_sql = f"EXPLAIN {clean_q};"
572
+ explain_sql = f"EXPLAIN {single_q};"
462
573
  return self.execute_query(explain_sql)
463
574
 
464
575
  @staticmethod
@@ -474,7 +585,7 @@ class DatabaseEngine:
474
585
  sql_lines = []
475
586
  for line in lines:
476
587
  stripped = line.strip()
477
- if stripped.startswith("--"):
588
+ if stripped.startswith(("--", "#")):
478
589
  comment_lines.append(stripped)
479
590
  else:
480
591
  sql_lines.append(line)
@@ -521,6 +632,33 @@ class DatabaseEngine:
521
632
  result_parts.append(sql_text.strip())
522
633
  return "\n".join(result_parts)
523
634
 
635
+ @classmethod
636
+ def is_mysql_select_needing_limit(cls, query: str) -> bool:
637
+ """
638
+ Check if a query is a top-level SELECT/WITH in MySQL that lacks a top-level LIMIT clause.
639
+ Guarantees that SHOW, DESCRIBE, EXPLAIN, and subqueries with LIMIT are handled correctly.
640
+ """
641
+ masked, _ = cls._mask_strings(query)
642
+ clean = cls._strip_comments(masked).strip()
643
+
644
+ # Must start with SELECT or WITH
645
+ if not re.match(r"^(SELECT|WITH)\b", clean, flags=re.IGNORECASE):
646
+ return False
647
+
648
+ # Check if there is a top-level LIMIT (at parenthesis depth 0)
649
+ depth = 0
650
+ tokens = re.split(r"(\(|\)|\bLIMIT\b)", clean, flags=re.IGNORECASE)
651
+ for token in tokens:
652
+ if token == "(":
653
+ depth += 1
654
+ elif token == ")":
655
+ if depth > 0:
656
+ depth -= 1
657
+ elif token.upper() == "LIMIT":
658
+ if depth == 0:
659
+ return False # Already has a top-level LIMIT
660
+ return True
661
+
524
662
  # --- Query execution ---
525
663
 
526
664
  def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
@@ -530,23 +668,58 @@ class DatabaseEngine:
530
668
  # Check if query is DML/DDL write operation (even if it has RETURNING)
531
669
  masked, _ = self._mask_strings_and_literals(query)
532
670
  clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
533
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().upper()
671
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
672
+ clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().upper()
534
673
  is_write_dml = bool(
535
674
  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)
536
675
  )
537
676
 
538
- # Use server-side streaming cursor for PostgreSQL read queries to prevent client RAM overflow
677
+ # PostgreSQL streaming: Use server-side named cursor to prevent pulling entire table into client RAM
539
678
  if self.db_type == "postgresql" and not is_write_dml:
540
679
  stream_name = f"gb_stream_{int(time.perf_counter() * 1000000)}"
680
+ # Disable autocommit temporarily for named cursor creation in psycopg2
681
+ self.conn.autocommit = False
541
682
  try:
542
683
  cur = self.conn.cursor(name=stream_name)
543
684
  cur.itersize = max_rows + 1
544
- except Exception:
545
- cur = self.conn.cursor()
546
- else:
547
- cur = self.conn.cursor()
685
+ cur.execute(query)
686
+ if cur.description:
687
+ columns = [d[0] for d in cur.description]
688
+ raw = cur.fetchmany(max_rows + 1)
689
+ truncated = len(raw) > max_rows
690
+ display_rows = raw[:max_rows]
691
+ rows = [list(r.values()) if isinstance(r, dict) else list(r) for r in display_rows]
692
+ cur.close()
693
+ self.conn.commit() # Cleanly close read transaction without leaving idle connection
694
+ elapsed_ms = (time.perf_counter() - start) * 1000
695
+ return {
696
+ "success": True,
697
+ "columns": columns,
698
+ "rows": rows,
699
+ "row_count": len(rows),
700
+ "affected_rows": 0,
701
+ "truncated": truncated,
702
+ "elapsed_ms": round(elapsed_ms, 2),
703
+ "is_select": True,
704
+ "is_returning": False,
705
+ }
706
+ except Exception as e:
707
+ try:
708
+ self.conn.rollback()
709
+ except Exception:
710
+ pass
711
+ raise e
712
+ finally:
713
+ self.conn.autocommit = True
714
+
715
+ # MySQL optimization: Auto-apply LIMIT on SELECTs without LIMIT to prevent server unbuffered socket drain
716
+ exec_query = query
717
+ if self.db_type == "mysql" and not is_write_dml:
718
+ if self.is_mysql_select_needing_limit(query):
719
+ exec_query = query.rstrip().rstrip(";") + f" LIMIT {max_rows + 1};"
548
720
 
549
- cur.execute(query)
721
+ cur = self.conn.cursor()
722
+ cur.execute(exec_query)
550
723
  elapsed_ms = (time.perf_counter() - start) * 1000
551
724
 
552
725
  if cur.description:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -112,6 +112,9 @@ def main():
112
112
  try:
113
113
  app = GhostBaseApp(db_path=conn, read_only=read_only)
114
114
  app.run()
115
+ ret = getattr(app, "return_code", 0)
116
+ if isinstance(ret, int) and ret != 0:
117
+ sys.exit(ret)
115
118
  except Exception as e:
116
119
  print(f"\n[GhostBase Error] Failed to connect to database: {e}\n")
117
120
  sys.exit(1)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghostbase-cli"
7
- version = "0.1.3"
7
+ version = "0.1.5"
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" }]
@@ -46,6 +46,16 @@ def test_safe_mode_and_comment_bypass():
46
46
  is_dest, r = db.check_safety(mismatched_tag_query, is_safe_mode=False)
47
47
  assert is_dest, f"VULNERABILITY: Dollar-quoted tag mismatch bypassed WHERE check: {r}"
48
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
+
49
59
  # Safe Mode STRICT read-only checks
50
60
  safe_queries = [
51
61
  "SELECT * FROM users WHERE id = 5;",
@@ -57,7 +67,7 @@ def test_safe_mode_and_comment_bypass():
57
67
  is_dest, r = db.check_safety(q, is_safe_mode=True)
58
68
  assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
59
69
 
60
- # Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, and SET changes
70
+ # Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions
61
71
  unsafe_queries = [
62
72
  ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
63
73
  ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
@@ -67,6 +77,9 @@ def test_safe_mode_and_comment_bypass():
67
77
  ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
68
78
  ("CREATE TABLE test (id INT);", "CREATE"),
69
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"),
70
83
  ("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
71
84
  ("SET default_transaction_read_only = OFF;", "SET"),
72
85
  ("RESET ALL;", "RESET"),
@@ -156,22 +169,27 @@ def test_sqlite_foreign_keys_enforced():
156
169
 
157
170
 
158
171
  def test_empty_sqlite_not_polluted():
159
- 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
160
174
  from app import GhostBaseApp
161
175
  with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
162
176
  empty_db_path = f.name
163
177
 
164
- try:
165
- # Launch in read-only / safe mode
178
+ async def _run():
166
179
  app = GhostBaseApp(db_path=empty_db_path, read_only=True, populate_sample=True)
167
- tables = app.db.get_tables()
168
- assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
169
- 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())
170
188
  finally:
171
189
  if os.path.exists(empty_db_path):
172
190
  os.remove(empty_db_path)
173
191
 
174
- print(" [+] Empty Database Integrity in Read-Only: PASS")
192
+ print(" [+] Empty Database Integrity in Read-Only (on_mount verified): PASS")
175
193
 
176
194
 
177
195
  def test_returning_dml_classification():
@@ -284,9 +302,145 @@ def test_result_set_memory_limit():
284
302
  print(" [+] Result Set Memory Limit: PASS")
285
303
 
286
304
 
305
+ def test_quoted_identifier_safety():
306
+ print("[*] 10. Testing Quoted Identifier Safety (Safe Mode & WHERE Column Guards)...")
307
+ db = DatabaseEngine(":memory:")
308
+
309
+ # Safe Mode: Quoted set_config, setval, nextval must NOT bypass detection
310
+ quoted_unsafe_queries = [
311
+ 'SELECT pg_catalog."set_config"(\'default_transaction_read_only\', \'off\', false);',
312
+ 'SELECT "setval"(\'users_id_seq\', 1, false);',
313
+ 'SELECT "nextval"(\'users_id_seq\');',
314
+ 'SELECT `set_config`(\'default_transaction_read_only\', \'off\', false);',
315
+ 'SELECT [setval](\'users_id_seq\', 1);',
316
+ 'SELECT "pg_terminate_backend"(1234);',
317
+ ]
318
+ for q in quoted_unsafe_queries:
319
+ is_dest, r = db.check_safety(q, is_safe_mode=True)
320
+ assert is_dest, f"VULNERABILITY: Quoted function bypassed Safe Mode: {q}"
321
+ assert "Safe Mode is active" in r
322
+
323
+ # Normal Mode: Column named "where" or `where` must NOT masquerade as WHERE clause
324
+ col_where_queries = [
325
+ 'UPDATE users SET "where" = 1;',
326
+ 'UPDATE users SET `where` = 1;',
327
+ 'UPDATE users SET [where] = 1;',
328
+ 'DELETE FROM users WHERE false = false;', # Real WHERE -> safe
329
+ ]
330
+ # First 3 must be detected as UPDATE without WHERE
331
+ for q in col_where_queries[:3]:
332
+ is_dest, r = db.check_safety(q, is_safe_mode=False)
333
+ assert is_dest, f"VULNERABILITY: Quoted 'where' column bypassed WHERE check: {q}"
334
+ assert "UPDATE without WHERE" in r
335
+
336
+ # Real WHERE must pass
337
+ is_dest, r = db.check_safety(col_where_queries[3], is_safe_mode=False)
338
+ assert not is_dest, f"Valid WHERE clause falsely flagged: {col_where_queries[3]}"
339
+
340
+ print(" [+] Quoted Identifier Safety: PASS")
341
+
342
+
343
+ def test_multi_statement_safety():
344
+ print("[*] 11. Testing Multi-Statement SQL Safety Evaluation...")
345
+ db = DatabaseEngine(":memory:")
346
+
347
+ # Multi-statement injection: UPDATE without WHERE followed by SELECT with WHERE
348
+ multi_bypass = "UPDATE users SET role = 'Admin'; SELECT 1 WHERE true;"
349
+ is_dest, r = db.check_safety(multi_bypass, is_safe_mode=False)
350
+ assert is_dest, "VULNERABILITY: Multi-statement UPDATE without WHERE bypassed safety!"
351
+ assert "UPDATE without WHERE" in r
352
+
353
+ # Multi-statement: SELECT followed by unconditional DELETE
354
+ multi_delete = "SELECT 1; DELETE FROM users;"
355
+ is_dest, r = db.check_safety(multi_delete, is_safe_mode=False)
356
+ assert is_dest, "VULNERABILITY: Multi-statement DELETE without WHERE bypassed safety!"
357
+ assert "DELETE without WHERE" in r
358
+
359
+ # Safe multi-statement: Both statements have valid WHERE clauses
360
+ multi_safe = "UPDATE users SET role = 'Admin' WHERE id = 1; SELECT 1 WHERE true;"
361
+ is_dest, r = db.check_safety(multi_safe, is_safe_mode=False)
362
+ assert not is_dest, f"Safe multi-statement wrongly flagged: {multi_safe} ({r})"
363
+
364
+ # Safe Mode multi-statement: Any write in any statement must trigger Safe Mode block
365
+ safe_mode_multi = "SELECT 1; INSERT INTO audit (msg) VALUES ('ok');"
366
+ is_dest, r = db.check_safety(safe_mode_multi, is_safe_mode=True)
367
+ assert is_dest, "VULNERABILITY: INSERT in multi-statement bypassed Safe Mode!"
368
+ assert "Safe Mode is active" in r
369
+
370
+ # Statement splitter preserves semicolons inside strings and comments
371
+ complex_script = "SELECT 'one;two;three' AS s; -- comment; with; semicolons\nSELECT 2;"
372
+ stmts = db.split_statements(complex_script)
373
+ assert len(stmts) == 2, f"Expected 2 statements, got {len(stmts)}: {stmts}"
374
+ assert stmts[0] == "SELECT 'one;two;three' AS s"
375
+ assert stmts[1] == "SELECT 2"
376
+
377
+ print(" [+] Multi-Statement Safety: PASS")
378
+
379
+
380
+ def test_explain_query_safety():
381
+ print("[*] 12. Testing EXPLAIN Query Plan Safety (No Multi-Statement Injection)...")
382
+ db = DatabaseEngine(":memory:")
383
+ db.conn.execute("CREATE TABLE users (id INT, role TEXT);")
384
+ db.conn.execute("INSERT INTO users VALUES (1, 'User');")
385
+ db.conn.commit()
386
+
387
+ # Attempt multi-statement injection via EXPLAIN
388
+ injection = "SELECT 1; DELETE FROM users;"
389
+ res = db.explain_query(injection)
390
+ assert not res["success"], "VULNERABILITY: EXPLAIN allowed multi-statement execution!"
391
+ assert "single SQL statement" in res["error"]
392
+
393
+ # Verify that users table was NOT modified
394
+ cur = db.conn.cursor()
395
+ cur.execute("SELECT COUNT(*) FROM users;")
396
+ assert cur.fetchone()[0] == 1, "VULNERABILITY: Table data was modified during EXPLAIN injection!"
397
+
398
+ # Normal valid single EXPLAIN
399
+ valid_res = db.explain_query("SELECT * FROM users WHERE id = 1;")
400
+ assert valid_res["success"], f"Valid EXPLAIN failed: {valid_res.get('error')}"
401
+
402
+ print(" [+] EXPLAIN Query Plan Safety: PASS")
403
+
404
+
405
+ def test_mysql_auto_limit_logic():
406
+ print("[*] 13. Testing MySQL Auto-LIMIT Logic (Top-Level SELECT vs Non-SELECT/Subqueries)...")
407
+ test_cases = [
408
+ ("SHOW TABLES;", False),
409
+ ("SHOW DATABASES;", False),
410
+ ("DESCRIBE users;", False),
411
+ ("DESC users;", False),
412
+ ("EXPLAIN SELECT * FROM users;", False),
413
+ ("SELECT * FROM users;", True),
414
+ ("SELECT * FROM users LIMIT 10;", False),
415
+ ("SELECT * FROM huge WHERE id IN (SELECT id FROM t LIMIT 10);", True),
416
+ ("WITH cte AS (SELECT 1 AS n LIMIT 5) SELECT * FROM cte;", True),
417
+ ("WITH cte AS (SELECT 1 AS n) SELECT * FROM cte LIMIT 20;", False),
418
+ ]
419
+
420
+ for q, expected in test_cases:
421
+ res = DatabaseEngine.is_mysql_select_needing_limit(q)
422
+ assert res == expected, f"MySQL LIMIT detection failed for: {q} (expected {expected}, got {res})"
423
+
424
+ print(" [+] MySQL Auto-LIMIT Logic: PASS")
425
+
426
+
427
+ def test_cli_readonly_lock():
428
+ print("[*] 14. Testing CLI --read-only Immutable Lock...")
429
+ from app import GhostBaseApp
430
+ app = GhostBaseApp(db_path=":memory:", read_only=True)
431
+ assert app.safe_mode is True, "Safe mode should be initialized to True"
432
+ assert app._cli_read_only is True, "_cli_read_only flag should be True"
433
+
434
+ # Attempt to toggle safe mode off
435
+ app.action_toggle_safe_mode()
436
+ assert app.safe_mode is True, "VULNERABILITY: CLI --read-only was bypassed via action_toggle_safe_mode!"
437
+
438
+ print(" [+] CLI --read-only Immutable Lock: PASS")
439
+
440
+
287
441
  def main():
288
442
  print("\n" + "=" * 60)
289
- print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.3)")
443
+ print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.5)")
290
444
  print("=" * 60)
291
445
 
292
446
  test_safe_mode_and_comment_bypass()
@@ -298,6 +452,11 @@ def main():
298
452
  test_exporter_json_types_and_dialect_sql()
299
453
  test_table_quoting_and_cte_export_detection()
300
454
  test_result_set_memory_limit()
455
+ test_quoted_identifier_safety()
456
+ test_multi_statement_safety()
457
+ test_explain_query_safety()
458
+ test_mysql_auto_limit_logic()
459
+ test_cli_readonly_lock()
301
460
 
302
461
  print("\n" + "=" * 60)
303
462
  print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
File without changes
File without changes
File without changes
File without changes