ghostbase-cli 0.1.1__tar.gz → 0.1.3__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.1
3
+ Version: 0.1.3
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.1", id="version-label")
345
+ yield Label("v0.1.3", id="version-label")
346
346
 
347
347
  with Horizontal(id="main-container"):
348
348
  with Vertical(id="sidebar"):
@@ -389,14 +389,22 @@ 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 (fail-closed)
393
+ if self.safe_mode:
394
+ try:
395
+ self.db.set_read_only(True)
396
+ except Exception as e:
397
+ self.safe_mode = False
398
+ self.notify(f"Failed to enable Read-Only mode on database: {e}", severity="error")
399
+
392
400
  tables = self.db.get_tables()
393
- # Only populate demo tables when explicitly requested, in-memory, or default sample.db
401
+ # Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
394
402
  is_default_sample = (
395
403
  self.populate_sample
396
404
  or self.db_path == ":memory:"
397
405
  or os.path.basename(os.path.abspath(self.db_path)) == "sample.db"
398
406
  )
399
- if not tables and self.db.db_type == "sqlite" and is_default_sample:
407
+ if not tables and self.db.db_type == "sqlite" and is_default_sample and not self.safe_mode:
400
408
  self._create_sample_data()
401
409
  tables = self.db.get_tables()
402
410
 
@@ -408,8 +416,9 @@ class GhostBaseApp(App):
408
416
  if self.all_tables:
409
417
  first = self.all_tables[0]
410
418
  self.selected_table = first
419
+ quoted = self.db.quote_table_name(first)
411
420
  self.query_one("#query-box", TextArea).text = (
412
- f"SELECT * FROM {first} LIMIT 50;"
421
+ f"SELECT * FROM {quoted} LIMIT 50;"
413
422
  )
414
423
  self.action_run_query()
415
424
  self._show_schema(first)
@@ -586,8 +595,9 @@ class GhostBaseApp(App):
586
595
  self._pending_destructive_query = None
587
596
  self._pending_destructive_time = 0.0
588
597
 
598
+ quoted = self.db.quote_table_name(t)
589
599
  self.query_one("#query-box", TextArea).text = (
590
- f"SELECT * FROM {t} LIMIT 50;"
600
+ f"SELECT * FROM {quoted} LIMIT 50;"
591
601
  )
592
602
  self.action_run_query()
593
603
  self._show_schema(t, is_view=is_view)
@@ -673,9 +683,10 @@ class GhostBaseApp(App):
673
683
  self.last_columns = res["columns"]
674
684
  self.last_rows = res["rows"]
675
685
 
676
- # Detect target table for export if simple SELECT FROM table
677
- m = re.search(r"\bFROM\s+[`\"\[]?([a-zA-Z0-9_\.]+)[`\"\]]?", query, flags=re.IGNORECASE)
678
- self.last_query_table = m.group(1).split(".")[-1] if m else (self.selected_table or "query_result")
686
+ # Detect target table for export from final SELECT statement (ignoring CTE definitions)
687
+ main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", query, flags=re.IGNORECASE)
688
+ m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
689
+ self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
679
690
 
680
691
  dt.add_columns(*self.last_columns)
681
692
  for row in self.last_rows:
@@ -702,9 +713,46 @@ class GhostBaseApp(App):
702
713
  else:
703
714
  res_cnt.update(f"({rc} rows)")
704
715
 
716
+ self._update_status_bar(row_count=rc, ms=ms)
717
+ elif res.get("is_returning"):
718
+ # DML with RETURNING: Render returned rows in DataTable, but treat as write operation
719
+ self.last_columns = res["columns"]
720
+ self.last_rows = res["rows"]
721
+
722
+ # Extract target table from INSERT/UPDATE
723
+ m = re.search(r"\b(?:INTO|UPDATE)\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", query, flags=re.IGNORECASE)
724
+ self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
725
+
726
+ dt.add_columns(*self.last_columns)
727
+ for row in self.last_rows:
728
+ styled = []
729
+ for val in row:
730
+ if val is None:
731
+ styled.append(Text("NULL", style="dim #6e7681"))
732
+ elif isinstance(val, (int, float)):
733
+ styled.append(Text(str(val), style="#79c0ff"))
734
+ else:
735
+ s = str(val).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
736
+ if len(s) > 200:
737
+ s = s[:197] + "..."
738
+ styled.append(Text(s, style="#c9d1d9"))
739
+ dt.add_row(*styled)
740
+
741
+ aff = res.get("affected_rows", len(self.last_rows))
742
+ rc = res.get("row_count", len(self.last_rows))
743
+ ms = res["elapsed_ms"]
744
+ status.update(f"[bold green]OK[/] ({ms}ms)")
745
+ res_cnt.update(f"({rc} rows returned, {aff} affected)")
746
+ self.notify(f"Done — {aff} row(s) affected (RETURNING {rc} rows)")
747
+
748
+ # Refresh schema & row counts
749
+ self.all_tables = self.db.get_tables()
750
+ self.all_views = self.db.get_views()
751
+ self._refresh_row_counts()
752
+ self._refresh_sidebar(self.all_tables, self.all_views)
705
753
  self._update_status_bar(row_count=rc, ms=ms)
706
754
  else:
707
- # Clear export cache so previous SELECT results are not mistakenly exported
755
+ # Pure DML / DDL statement -> clear export cache so previous SELECT results are not mistakenly exported
708
756
  self.last_columns = []
709
757
  self.last_rows = []
710
758
  self.last_query_table = None
@@ -766,11 +814,19 @@ class GhostBaseApp(App):
766
814
  self.notify("Execution Plan analyzed successfully")
767
815
 
768
816
  def action_toggle_safe_mode(self):
769
- """Toggle Production Safe Mode (Ctrl+S)"""
770
- self.safe_mode = not self.safe_mode
817
+ """Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
818
+ new_mode = not self.safe_mode
771
819
  self._pending_destructive_query = None
772
820
  self._pending_destructive_time = 0.0
773
821
 
822
+ # Enforce/revoke database session level Read-Only (Fail-closed)
823
+ try:
824
+ self.db.set_read_only(new_mode)
825
+ self.safe_mode = new_mode
826
+ except Exception as e:
827
+ self.notify(f"Cannot enable Safe Mode: {e}", severity="error", timeout=5)
828
+ return
829
+
774
830
  badge = self.query_one("#safe-badge", Label)
775
831
  if self.safe_mode:
776
832
  badge.update("[SAFE: ON]")
@@ -858,7 +914,7 @@ class GhostBaseApp(App):
858
914
  else os.getcwd()
859
915
  )
860
916
  out = os.path.join(db_dir, "ghostbase_export.sql")
861
- Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out)
917
+ Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out, dialect=self.db.db_type)
862
918
  self.notify(f"Saved: {out}", timeout=5)
863
919
 
864
920
  # Status bar
@@ -65,16 +65,58 @@ 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
 
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
+ """
84
+ if not self.conn:
85
+ return False
86
+ try:
87
+ cur = self.conn.cursor()
88
+ if self.db_type == "sqlite":
89
+ val = "ON" if enabled else "OFF"
90
+ cur.execute(f"PRAGMA query_only = {val};")
91
+ elif self.db_type == "postgresql":
92
+ mode = "ON" if enabled else "OFF"
93
+ cur.execute(f"SET default_transaction_read_only = {mode};")
94
+ elif self.db_type == "mysql":
95
+ mode = "READ ONLY" if enabled else "READ WRITE"
96
+ cur.execute(f"SET SESSION TRANSACTION {mode};")
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
102
+
103
+ def quote_table_name(self, table_name: str) -> str:
104
+ """Properly quote table identifier for queries based on database engine."""
105
+ if not table_name:
106
+ return ""
107
+ if self.db_type == "postgresql":
108
+ if "." in table_name:
109
+ s, t = table_name.split(".", 1)
110
+ return f'"{s}"."{t}"'
111
+ return f'"{table_name}"'
112
+ elif self.db_type == "mysql":
113
+ if "." in table_name:
114
+ s, t = table_name.split(".", 1)
115
+ return f"`{s}`.`{t}`"
116
+ return f"`{table_name}`"
117
+ else: # sqlite
118
+ return f"[{table_name}]"
119
+
78
120
  # --- Schema discovery ---
79
121
 
80
122
  def get_tables(self) -> List[str]:
@@ -266,16 +308,28 @@ class DatabaseEngine:
266
308
  if self.db_type == "sqlite":
267
309
  cur.execute(f"SELECT COUNT(*) FROM [{table_name}];")
268
310
  elif self.db_type == "postgresql":
269
- clean_name = table_name.split(".", 1)[-1]
311
+ schema = "public"
312
+ tbl = table_name
313
+ if "." in table_name:
314
+ schema, tbl = table_name.split(".", 1)
315
+
270
316
  if fast_estimate:
271
317
  try:
272
- cur.execute("SELECT reltuples::bigint FROM pg_class WHERE relname = %s;", (clean_name,))
318
+ cur.execute(
319
+ """
320
+ SELECT c.reltuples::bigint
321
+ FROM pg_class c
322
+ JOIN pg_namespace n ON n.oid = c.relnamespace
323
+ WHERE n.nspname = %s AND c.relname = %s;
324
+ """,
325
+ (schema, tbl),
326
+ )
273
327
  r = cur.fetchone()
274
328
  if r and r[0] is not None and r[0] >= 0:
275
329
  return int(r[0])
276
330
  except Exception:
277
331
  pass
278
- cur.execute(f'SELECT COUNT(*) FROM "{table_name}";')
332
+ cur.execute(f'SELECT COUNT(*) FROM "{schema}"."{tbl}";')
279
333
  else:
280
334
  if fast_estimate:
281
335
  try:
@@ -299,20 +353,47 @@ class DatabaseEngine:
299
353
 
300
354
  # --- Query safety and formatting ---
301
355
 
356
+ @staticmethod
357
+ def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
358
+ """
359
+ 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: `...`
364
+ """
365
+ literals = []
366
+ def _repl(m):
367
+ idx = len(literals)
368
+ literals.append(m.group(0))
369
+ return f" ___GB_LIT_{idx}___ "
370
+
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\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
378
+ masked = re.sub(pattern, _repl, sql)
379
+ return masked, literals
380
+
302
381
  def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
303
382
  """
304
383
  Check query safety.
305
- In Safe Mode: Enforces strict Read-Only operations.
306
- In Normal Mode: Intercepts destructive queries lacking constraints (e.g. DELETE/UPDATE without WHERE).
307
- Comments (-- and /* */) and string literals are stripped to prevent syntax bypasses.
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.
308
388
  """
309
- # 1. Strip block comments /* ... */ and line comments -- ...
310
- q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", query)
389
+ # 1. Mask all string literals and dollar-quoted strings FIRST
390
+ masked, _ = self._mask_strings_and_literals(query)
391
+
392
+ # 2. Strip block comments /* ... */ and line comments -- ...
393
+ q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
311
394
  q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
312
395
 
313
- # 2. Strip string literals & identifiers so keywords inside literals don't cause false detections
314
- q_clean = re.sub(r"('(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)", " ", q_no_comments)
315
- q_clean = q_clean.strip()
396
+ q_clean = q_no_comments.strip()
316
397
  if not q_clean:
317
398
  return False, ""
318
399
 
@@ -332,10 +413,18 @@ class DatabaseEngine:
332
413
  (r"\bGRANT\b", "GRANT statement"),
333
414
  (r"\bREVOKE\b", "REVOKE statement"),
334
415
  (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
+ (r"\bSET\b", "SET configuration change"),
420
+ (r"\bRESET\b", "RESET configuration change"),
421
+ (r"\bDO\b", "DO procedural block"),
422
+ (r"\bCALL\b", "CALL stored procedure"),
423
+ (r"\bMERGE\b", "MERGE statement"),
335
424
  ]
336
425
  for pattern, desc in write_keywords:
337
426
  if re.search(pattern, q_upper):
338
- return True, f"Safe Mode is active: write operations ({desc}) are strictly blocked"
427
+ return True, f"Safe Mode is active: write or state-altering operations ({desc}) are strictly blocked"
339
428
 
340
429
  if re.search(r"\bPRAGMA\b\s+\w+\s*=", q_upper):
341
430
  return True, "Safe Mode is active: PRAGMA configuration writes are blocked"
@@ -357,8 +446,12 @@ class DatabaseEngine:
357
446
  return False, ""
358
447
 
359
448
  def explain_query(self, query: str) -> Dict[str, Any]:
360
- clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", query)
449
+ masked, literals = self._mask_strings_and_literals(query)
450
+ clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
361
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)
454
+
362
455
  if not clean_q:
363
456
  return {"success": False, "error": "Query is empty"}
364
457
 
@@ -374,15 +467,7 @@ class DatabaseEngine:
374
467
  if not query or not query.strip():
375
468
  return query
376
469
 
377
- # Mask string literals ('...'), double-quoted strings ("..."), and backticks (`...`)
378
- literals = []
379
- def _mask_literal(m):
380
- idx = len(literals)
381
- literals.append(m.group(0))
382
- return f"___GB_LIT_{idx}___"
383
-
384
- lit_pattern = r"('(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
385
- masked = re.sub(lit_pattern, _mask_literal, query)
470
+ masked, literals = DatabaseEngine._mask_strings_and_literals(query)
386
471
 
387
472
  lines = masked.splitlines()
388
473
  comment_lines = []
@@ -440,8 +525,27 @@ class DatabaseEngine:
440
525
 
441
526
  def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
442
527
  start = time.perf_counter()
528
+ cur = None
443
529
  try:
444
- cur = self.conn.cursor()
530
+ # Check if query is DML/DDL write operation (even if it has RETURNING)
531
+ masked, _ = self._mask_strings_and_literals(query)
532
+ clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
533
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().upper()
534
+ is_write_dml = bool(
535
+ 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
+ )
537
+
538
+ # Use server-side streaming cursor for PostgreSQL read queries to prevent client RAM overflow
539
+ if self.db_type == "postgresql" and not is_write_dml:
540
+ stream_name = f"gb_stream_{int(time.perf_counter() * 1000000)}"
541
+ try:
542
+ cur = self.conn.cursor(name=stream_name)
543
+ cur.itersize = max_rows + 1
544
+ except Exception:
545
+ cur = self.conn.cursor()
546
+ else:
547
+ cur = self.conn.cursor()
548
+
445
549
  cur.execute(query)
446
550
  elapsed_ms = (time.perf_counter() - start) * 1000
447
551
 
@@ -469,9 +573,11 @@ class DatabaseEngine:
469
573
  "columns": columns,
470
574
  "rows": rows,
471
575
  "row_count": len(rows),
576
+ "affected_rows": max(cur.rowcount, len(rows)) if is_write_dml else 0,
472
577
  "truncated": truncated,
473
578
  "elapsed_ms": round(elapsed_ms, 2),
474
- "is_select": True,
579
+ "is_select": not is_write_dml,
580
+ "is_returning": is_write_dml,
475
581
  }
476
582
  else:
477
583
  if self.db_type in ("sqlite", "postgresql"):
@@ -481,6 +587,7 @@ class DatabaseEngine:
481
587
  "affected_rows": max(cur.rowcount, 0),
482
588
  "elapsed_ms": round(elapsed_ms, 2),
483
589
  "is_select": False,
590
+ "is_returning": False,
484
591
  }
485
592
  except Exception as e:
486
593
  elapsed_ms = (time.perf_counter() - start) * 1000
@@ -494,6 +601,12 @@ class DatabaseEngine:
494
601
  "error": str(e),
495
602
  "elapsed_ms": round(elapsed_ms, 2),
496
603
  }
604
+ finally:
605
+ if cur:
606
+ try:
607
+ cur.close()
608
+ except Exception:
609
+ pass
497
610
 
498
611
  # --- Helpers ---
499
612
 
@@ -1,9 +1,30 @@
1
1
  import csv
2
2
  import json
3
+ import decimal
4
+ import datetime
5
+ import uuid
3
6
  from typing import List, Any
4
7
 
5
8
 
6
9
  class Exporter:
10
+ @staticmethod
11
+ def _json_serial(obj: Any) -> Any:
12
+ """Serialize non-standard types (Decimal, datetime, UUID, bytes) for JSON export."""
13
+ if isinstance(obj, (datetime.date, datetime.datetime, datetime.time)):
14
+ return obj.isoformat()
15
+ if isinstance(obj, decimal.Decimal):
16
+ return int(obj) if (obj % 1 == 0) else float(obj)
17
+ if isinstance(obj, uuid.UUID):
18
+ return str(obj)
19
+ if isinstance(obj, (bytes, bytearray)):
20
+ try:
21
+ return obj.decode("utf-8")
22
+ except Exception:
23
+ return obj.hex()
24
+ if hasattr(obj, "__str__"):
25
+ return str(obj)
26
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
27
+
7
28
  @staticmethod
8
29
  def _dedup_columns(columns: List[str]) -> List[str]:
9
30
  seen = {}
@@ -19,8 +40,15 @@ class Exporter:
19
40
  return deduped
20
41
 
21
42
  @staticmethod
22
- def _escape_ident(ident: str) -> str:
43
+ def _escape_ident(ident: str, dialect: str = "sqlite") -> str:
23
44
  s = str(ident).strip()
45
+ q = "`" if str(dialect).lower() == "mysql" else '"'
46
+ escaped_q = q * 2
47
+
48
+ if "." in s:
49
+ parts = s.split(".")
50
+ return ".".join(f"{q}{p.replace(q, escaped_q)}{q}" for p in parts)
51
+
24
52
  reserved = {
25
53
  "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE",
26
54
  "CHECK", "COLUMN", "CONSTRAINT", "CREATE", "CROSS", "CURRENT_DATE",
@@ -34,8 +62,8 @@ class Exporter:
34
62
  "USER", "VALUES", "VIEW", "WHEN", "WHERE", "WITH"
35
63
  }
36
64
  if not s.isidentifier() or s.upper() in reserved:
37
- escaped = s.replace('"', '""')
38
- return f'"{escaped}"'
65
+ escaped = s.replace(q, escaped_q)
66
+ return f"{q}{escaped}{q}"
39
67
  return s
40
68
 
41
69
  @staticmethod
@@ -52,7 +80,7 @@ class Exporter:
52
80
  for r in rows:
53
81
  data.append(dict(zip(clean_cols, r)))
54
82
  with open(output_path, "w", encoding="utf-8") as f:
55
- json.dump(data, f, indent=2, ensure_ascii=False)
83
+ json.dump(data, f, indent=2, ensure_ascii=False, default=Exporter._json_serial)
56
84
 
57
85
  @staticmethod
58
86
  def to_markdown(columns: List[str], rows: List[List[Any]], output_path: str):
@@ -70,13 +98,13 @@ class Exporter:
70
98
  f.write("| " + " | ".join(clean_cells) + " |\n")
71
99
 
72
100
  @staticmethod
73
- 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"):
74
102
  table = (table_name or "exported_table").strip()
75
- escaped_table = Exporter._escape_ident(table)
76
- 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]
77
105
  cols_str = ", ".join(escaped_cols)
78
106
  with open(output_path, "w", encoding="utf-8") as f:
79
- f.write(f"-- GhostBase SQL Export: {table}\n")
107
+ f.write(f"-- GhostBase SQL Export: {table} ({dialect})\n")
80
108
  f.write(f"-- Total: {len(rows)} row(s)\n\n")
81
109
  for r in rows:
82
110
  vals = []
@@ -85,7 +113,7 @@ class Exporter:
85
113
  vals.append("NULL")
86
114
  elif isinstance(v, bool):
87
115
  vals.append("TRUE" if v else "FALSE")
88
- elif isinstance(v, (int, float)):
116
+ elif isinstance(v, (int, float, decimal.Decimal)):
89
117
  vals.append(str(v))
90
118
  else:
91
119
  escaped = str(v).replace("'", "''")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
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.1"
7
+ version = "0.1.3"
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" }]
@@ -0,0 +1,308 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ import sqlite3
5
+ import json
6
+ import decimal
7
+ import datetime
8
+ import uuid
9
+ import re
10
+
11
+ # Ensure parent directory is in path
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from db_engine import DatabaseEngine
15
+ from exporter import Exporter
16
+ from main import detect_connection
17
+
18
+
19
+ def test_safe_mode_and_comment_bypass():
20
+ print("[*] 1. Testing Safe Mode & Comment Bypass Prevention...")
21
+ db = DatabaseEngine(":memory:")
22
+
23
+ # Comment bypass attempt: WHERE is inside /* ... */ block comment
24
+ bypass_query = "UPDATE users SET role = 'Member' /* WHERE id = 1 */;"
25
+ is_destructive, reason = db.check_safety(bypass_query, is_safe_mode=False)
26
+ assert is_destructive, "VULNERABILITY: Block comment containing WHERE fooled check_safety!"
27
+ assert "UPDATE without WHERE" in reason
28
+
29
+ # Line comment bypass attempt: WHERE is after --
30
+ bypass_line = "UPDATE users SET role = 'Member' -- WHERE id = 1\n;"
31
+ is_destructive, reason = db.check_safety(bypass_line, is_safe_mode=False)
32
+ assert is_destructive, "VULNERABILITY: Line comment containing WHERE fooled check_safety!"
33
+
34
+ # String with WHERE inside must not count as WHERE clause
35
+ string_bypass = "UPDATE users SET role = 'WHERE -- this is a string';"
36
+ is_dest, r = db.check_safety(string_bypass, is_safe_mode=False)
37
+ assert is_dest, f"VULNERABILITY: String literal containing WHERE fooled check_safety: {r}"
38
+
39
+ # Postgres dollar-quoted string with WHERE
40
+ dollar_bypass = "UPDATE users SET role = $$WHERE$$;"
41
+ is_dest, r = db.check_safety(dollar_bypass, is_safe_mode=False)
42
+ assert is_dest, f"VULNERABILITY: Dollar-quoted string containing WHERE fooled check_safety: {r}"
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
+ # Safe Mode STRICT read-only checks
50
+ safe_queries = [
51
+ "SELECT * FROM users WHERE id = 5;",
52
+ "SELECT 'DROP TABLE users' AS msg;",
53
+ "WITH cte AS (SELECT 1 AS n) SELECT * FROM cte;",
54
+ "EXPLAIN SELECT * FROM users;",
55
+ ]
56
+ for q in safe_queries:
57
+ is_dest, r = db.check_safety(q, is_safe_mode=True)
58
+ assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
59
+
60
+ # Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, and SET changes
61
+ unsafe_queries = [
62
+ ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
63
+ ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
64
+ ("DELETE FROM users WHERE id = 5;", "DELETE"),
65
+ ("DROP TABLE users;", "DROP"),
66
+ ("TRUNCATE TABLE users;", "TRUNCATE"),
67
+ ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
68
+ ("CREATE TABLE test (id INT);", "CREATE"),
69
+ ("SELECT nextval('users_id_seq');", "NEXTVAL"),
70
+ ("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
71
+ ("SET default_transaction_read_only = OFF;", "SET"),
72
+ ("RESET ALL;", "RESET"),
73
+ ("DO $body$ BEGIN UPDATE users SET role = 'Admin'; END $body$;", "DO"),
74
+ ("CALL custom_proc();", "CALL"),
75
+ ("MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN DELETE;", "MERGE"),
76
+ ]
77
+ for q, op in unsafe_queries:
78
+ is_dest, r = db.check_safety(q, is_safe_mode=True)
79
+ assert is_dest, f"VULNERABILITY: Safe Mode failed to block {op}: {q}"
80
+ assert "Safe Mode is active" in r
81
+
82
+ print(" [+] Safe Mode & Comment Bypass: PASS")
83
+
84
+
85
+ def test_database_engine_level_readonly():
86
+ print("[*] 2. Testing Database Engine-Level Read-Only & Fail-Closed Enforcement...")
87
+ db = DatabaseEngine(":memory:")
88
+ db.conn.execute("CREATE TABLE read_only_test (id INT, val TEXT);")
89
+ db.conn.commit()
90
+
91
+ # Enable engine-level read-only
92
+ db.set_read_only(True)
93
+
94
+ # SQLite PRAGMA query_only=ON must reject writes at the C/engine level
95
+ failed = False
96
+ try:
97
+ db.conn.execute("INSERT INTO read_only_test VALUES (1, 'fail');")
98
+ db.conn.commit()
99
+ except sqlite3.OperationalError as e:
100
+ if "readonly" in str(e).lower() or "query_only" in str(e).lower():
101
+ failed = True
102
+
103
+ assert failed, "VULNERABILITY: SQLite allowed write with PRAGMA query_only = ON!"
104
+
105
+ # Disable read-only
106
+ db.set_read_only(False)
107
+ db.conn.execute("INSERT INTO read_only_test VALUES (1, 'success');")
108
+ db.conn.commit()
109
+
110
+ print(" [+] Database Engine-Level Read-Only: PASS")
111
+
112
+
113
+ def test_sql_beautifier_preserves_literals():
114
+ print("[*] 3. Testing SQL Beautifier Literal Integrity (Single, Double & Dollar Quotes)...")
115
+ raw_query = "SELECT 'order by and where' AS message, $$order by AND where$$ AS dollar_msg FROM users WHERE id = 1;"
116
+ formatted = DatabaseEngine.format_sql(raw_query)
117
+
118
+ assert "'order by and where'" in formatted, f"VULNERABILITY: Beautifier corrupted single-quoted literal: {formatted}"
119
+ assert "$$order by AND where$$" in formatted, f"VULNERABILITY: Beautifier corrupted dollar-quoted literal: {formatted}"
120
+ assert "WHERE" in formatted
121
+ assert "SELECT" in formatted
122
+
123
+ print(" [+] SQL Beautifier Preserves Literals: PASS")
124
+
125
+
126
+ def test_sqlite_foreign_keys_enforced():
127
+ print("[*] 4. Testing SQLite Foreign Keys Enforcement...")
128
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
129
+ db_path = f.name
130
+
131
+ try:
132
+ db = DatabaseEngine(db_path)
133
+ cur = db.conn.cursor()
134
+ cur.execute("PRAGMA foreign_keys;")
135
+ fk_status = cur.fetchone()[0]
136
+ assert fk_status == 1, f"VULNERABILITY: PRAGMA foreign_keys is OFF ({fk_status})"
137
+
138
+ db.conn.execute("CREATE TABLE dept (id INTEGER PRIMARY KEY, name TEXT);")
139
+ db.conn.execute("CREATE TABLE emp (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES dept(id));")
140
+ db.conn.commit()
141
+
142
+ failed = False
143
+ try:
144
+ db.conn.execute("INSERT INTO emp (id, dept_id) VALUES (1, 999);")
145
+ db.conn.commit()
146
+ except sqlite3.IntegrityError:
147
+ failed = True
148
+
149
+ assert failed, "VULNERABILITY: SQLite allowed foreign key violation without error!"
150
+ finally:
151
+ db.close()
152
+ if os.path.exists(db_path):
153
+ os.remove(db_path)
154
+
155
+ print(" [+] SQLite Foreign Key Enforcement: PASS")
156
+
157
+
158
+ def test_empty_sqlite_not_polluted():
159
+ print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode...")
160
+ from app import GhostBaseApp
161
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
162
+ empty_db_path = f.name
163
+
164
+ try:
165
+ # Launch in read-only / safe mode
166
+ 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()
170
+ finally:
171
+ if os.path.exists(empty_db_path):
172
+ os.remove(empty_db_path)
173
+
174
+ print(" [+] Empty Database Integrity in Read-Only: PASS")
175
+
176
+
177
+ def test_returning_dml_classification():
178
+ print("[*] 6. Testing DML with RETURNING Classification...")
179
+ db = DatabaseEngine(":memory:")
180
+ db.conn.execute("CREATE TABLE ret_test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);")
181
+ db.conn.commit()
182
+
183
+ res = db.execute_query("INSERT INTO ret_test (name) VALUES ('Item1') RETURNING id, name;")
184
+ assert res["success"]
185
+ assert res["is_returning"] is True, "Expected is_returning to be True"
186
+ assert res["is_select"] is False, "Expected is_select to be False for INSERT RETURNING"
187
+ assert res["affected_rows"] == 1
188
+ assert len(res["rows"]) == 1
189
+ assert res["rows"][0][1] == "Item1"
190
+
191
+ print(" [+] DML with RETURNING Classification: PASS")
192
+
193
+
194
+ def test_exporter_json_types_and_dialect_sql():
195
+ print("[*] 7. Testing Exporter JSON Types & Dialect-Aware SQL Escaping (MySQL vs Postgres/SQLite)...")
196
+ tmp = tempfile.gettempdir()
197
+ json_path = os.path.join(tmp, "test_types.json")
198
+ sql_pg_path = os.path.join(tmp, "test_pg.sql")
199
+ sql_my_path = os.path.join(tmp, "test_mysql.sql")
200
+
201
+ # JSON export with Decimal, datetime, UUID, bytes, and duplicate column names
202
+ cols = ["id", "id", "price", "created", "uid", "raw"]
203
+ rows = [[
204
+ 1, 1,
205
+ decimal.Decimal("49.95"),
206
+ datetime.datetime(2026, 11, 2, 8, 0, 0),
207
+ uuid.UUID("12345678-1234-5678-1234-567812345678"),
208
+ b"binary_ghost"
209
+ ]]
210
+ Exporter.to_json(cols, rows, json_path)
211
+ with open(json_path, "r", encoding="utf-8") as f:
212
+ data = json.load(f)
213
+ assert len(data) == 1
214
+ assert data[0]["id"] == 1
215
+ assert data[0]["id_2"] == 1
216
+ assert data[0]["price"] == 49.95
217
+ assert data[0]["created"] == "2026-11-02T08:00:00"
218
+ assert data[0]["uid"] == "12345678-1234-5678-1234-567812345678"
219
+ assert data[0]["raw"] == "binary_ghost"
220
+
221
+ # PostgreSQL / SQLite dialect SQL export (Double quotes)
222
+ table_name = "analytics.order"
223
+ cols_special = ["first name", "group", "price"]
224
+ rows_special = [["Bob", "Admin", decimal.Decimal("10.50")]]
225
+ Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_pg_path, dialect="postgresql")
226
+ with open(sql_pg_path, "r", encoding="utf-8") as f:
227
+ pg_content = f.read()
228
+
229
+ assert 'INSERT INTO "analytics"."order" ("first name", "group", price) VALUES (\'Bob\', \'Admin\', 10.50);' in pg_content
230
+
231
+ # MySQL dialect SQL export (Backticks)
232
+ Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_my_path, dialect="mysql")
233
+ with open(sql_my_path, "r", encoding="utf-8") as f:
234
+ my_content = f.read()
235
+
236
+ assert 'INSERT INTO `analytics`.`order` (`first name`, `group`, price) VALUES (\'Bob\', \'Admin\', 10.50);' in my_content
237
+
238
+ print(" [+] Exporter JSON Types & Dialect-Aware SQL Escaping: PASS")
239
+
240
+
241
+ def test_table_quoting_and_cte_export_detection():
242
+ print("[*] 8. Testing Table Quoting & CTE Export Target Detection...")
243
+ db_sqlite = DatabaseEngine(":memory:")
244
+ assert db_sqlite.quote_table_name("order") == "[order]"
245
+
246
+ db_pg = DatabaseEngine.__new__(DatabaseEngine)
247
+ db_pg.db_type = "postgresql"
248
+ assert db_pg.quote_table_name("order") == '"order"'
249
+ assert db_pg.quote_table_name("analytics.users") == '"analytics"."users"'
250
+
251
+ db_my = DatabaseEngine.__new__(DatabaseEngine)
252
+ db_my.db_type = "mysql"
253
+ assert db_my.quote_table_name("order") == "`order`"
254
+ assert db_my.quote_table_name("analytics.users") == "`analytics`.`users`"
255
+
256
+ # CTE target detection test
257
+ cte_query = """
258
+ WITH recent AS (
259
+ SELECT * FROM archived_users WHERE active = 0
260
+ )
261
+ SELECT * FROM recent;
262
+ """
263
+ main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", cte_query.strip(), flags=re.IGNORECASE)
264
+ m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
265
+ assert m is not None
266
+ detected = m.group(1).strip('`"[]')
267
+ assert detected == "recent", f"Expected target 'recent', got '{detected}'"
268
+
269
+ print(" [+] Table Quoting & CTE Export Target Detection: PASS")
270
+
271
+
272
+ def test_result_set_memory_limit():
273
+ print("[*] 9. Testing Result Set Memory Limit (Capped at 1,000 rows)...")
274
+ db = DatabaseEngine(":memory:")
275
+ db.conn.execute("CREATE TABLE big (id INTEGER PRIMARY KEY);")
276
+ db.conn.executemany("INSERT INTO big (id) VALUES (?);", [(i,) for i in range(1500)])
277
+ db.conn.commit()
278
+
279
+ res = db.execute_query("SELECT * FROM big;", max_rows=1000)
280
+ assert res["success"]
281
+ assert res["row_count"] == 1000, f"Expected 1000 rows capped, got {res['row_count']}"
282
+ assert res["truncated"] is True, "Expected truncated flag to be True"
283
+
284
+ print(" [+] Result Set Memory Limit: PASS")
285
+
286
+
287
+ def main():
288
+ print("\n" + "=" * 60)
289
+ print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.3)")
290
+ print("=" * 60)
291
+
292
+ test_safe_mode_and_comment_bypass()
293
+ test_database_engine_level_readonly()
294
+ test_sql_beautifier_preserves_literals()
295
+ test_sqlite_foreign_keys_enforced()
296
+ test_empty_sqlite_not_polluted()
297
+ test_returning_dml_classification()
298
+ test_exporter_json_types_and_dialect_sql()
299
+ test_table_quoting_and_cte_export_detection()
300
+ test_result_set_memory_limit()
301
+
302
+ print("\n" + "=" * 60)
303
+ print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
304
+ print("=" * 60 + "\n")
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
@@ -1,216 +0,0 @@
1
- import os
2
- import sys
3
- import tempfile
4
- import sqlite3
5
- import json
6
-
7
- # Ensure parent directory is in path
8
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
-
10
- from db_engine import DatabaseEngine
11
- from exporter import Exporter
12
- from main import detect_connection
13
-
14
-
15
- def test_safe_mode_and_comment_bypass():
16
- print("[*] 1. Testing Safe Mode & Comment Bypass Prevention...")
17
- db = DatabaseEngine(":memory:")
18
-
19
- # Comment bypass attempt: WHERE is inside /* ... */ block comment
20
- bypass_query = "UPDATE users SET role = 'Member' /* WHERE id = 1 */;"
21
- is_destructive, reason = db.check_safety(bypass_query, is_safe_mode=False)
22
- assert is_destructive, "VULNERABILITY: Block comment containing WHERE fooled check_safety!"
23
- assert "UPDATE without WHERE" in reason
24
-
25
- # Line comment bypass attempt: WHERE is after --
26
- bypass_line = "UPDATE users SET role = 'Member' -- WHERE id = 1\n;"
27
- is_destructive, reason = db.check_safety(bypass_line, is_safe_mode=False)
28
- assert is_destructive, "VULNERABILITY: Line comment containing WHERE fooled check_safety!"
29
-
30
- # Safe Mode STRICT read-only checks
31
- safe_queries = [
32
- "SELECT * FROM users WHERE id = 5;",
33
- "SELECT 'DROP TABLE users' AS msg;",
34
- "WITH cte AS (SELECT 1 AS n) SELECT * FROM cte;",
35
- "EXPLAIN SELECT * FROM users;",
36
- ]
37
- for q in safe_queries:
38
- is_dest, r = db.check_safety(q, is_safe_mode=True)
39
- assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
40
-
41
- unsafe_queries = [
42
- ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
43
- ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
44
- ("DELETE FROM users WHERE id = 5;", "DELETE"),
45
- ("DROP TABLE users;", "DROP"),
46
- ("TRUNCATE TABLE users;", "TRUNCATE"),
47
- ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
48
- ("CREATE TABLE test (id INT);", "CREATE"),
49
- ]
50
- for q, op in unsafe_queries:
51
- is_dest, r = db.check_safety(q, is_safe_mode=True)
52
- assert is_dest, f"VULNERABILITY: Safe Mode failed to block {op}: {q}"
53
- assert "Safe Mode is active" in r
54
-
55
- print(" [+] Safe Mode & Comment Bypass: PASS")
56
-
57
-
58
- def test_sql_beautifier_preserves_literals():
59
- print("[*] 2. Testing SQL Beautifier Literal Integrity...")
60
- raw_query = "SELECT 'order by and where' AS message, 'SELECT * FROM users' AS sample FROM users WHERE id = 1;"
61
- formatted = DatabaseEngine.format_sql(raw_query)
62
-
63
- # The string literals must not have their contents altered to uppercase or split by newlines
64
- assert "'order by and where'" in formatted, f"VULNERABILITY: Beautifier corrupted string literal! Got: {formatted}"
65
- assert "'SELECT * FROM users'" in formatted, "VULNERABILITY: Beautifier corrupted inner SQL string!"
66
- # The actual SQL keyword outside strings should be formatted
67
- assert "WHERE" in formatted
68
- assert "SELECT" in formatted
69
-
70
- print(" [+] SQL Beautifier Preserves Literals: PASS")
71
-
72
-
73
- def test_sqlite_foreign_keys_enforced():
74
- print("[*] 3. Testing SQLite Foreign Keys Enforcement...")
75
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
76
- db_path = f.name
77
-
78
- try:
79
- db = DatabaseEngine(db_path)
80
- # Verify PRAGMA foreign_keys is ON
81
- cur = db.conn.cursor()
82
- cur.execute("PRAGMA foreign_keys;")
83
- fk_status = cur.fetchone()[0]
84
- assert fk_status == 1, f"VULNERABILITY: PRAGMA foreign_keys is OFF ({fk_status})"
85
-
86
- # Verify real constraint enforcement
87
- db.conn.execute("CREATE TABLE dept (id INTEGER PRIMARY KEY, name TEXT);")
88
- db.conn.execute("CREATE TABLE emp (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES dept(id));")
89
- db.conn.commit()
90
-
91
- # Inserting child with invalid foreign key must raise IntegrityError
92
- failed = False
93
- try:
94
- db.conn.execute("INSERT INTO emp (id, dept_id) VALUES (1, 999);")
95
- db.conn.commit()
96
- except sqlite3.IntegrityError:
97
- failed = True
98
-
99
- assert failed, "VULNERABILITY: SQLite allowed foreign key violation without error!"
100
- finally:
101
- db.close()
102
- if os.path.exists(db_path):
103
- os.remove(db_path)
104
-
105
- print(" [+] SQLite Foreign Key Enforcement: PASS")
106
-
107
-
108
- def test_empty_sqlite_not_polluted():
109
- print("[*] 4. Testing Empty SQLite Database Guard...")
110
- from app import GhostBaseApp
111
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
112
- empty_db_path = f.name
113
-
114
- try:
115
- # User opens an empty database without populate_sample flag
116
- app = GhostBaseApp(db_path=empty_db_path, populate_sample=False)
117
- tables = app.db.get_tables()
118
- assert len(tables) == 0, f"VULNERABILITY: Sample tables were automatically added to user's database: {tables}"
119
- app.db.close()
120
- finally:
121
- if os.path.exists(empty_db_path):
122
- os.remove(empty_db_path)
123
-
124
- print(" [+] Empty Database Integrity: PASS")
125
-
126
-
127
- def test_exporter_dedup_and_ident_escaping():
128
- print("[*] 5. Testing Exporter Duplicate Column Dedup & Quoting...")
129
- tmp = tempfile.gettempdir()
130
- json_path = os.path.join(tmp, "test_dedup.json")
131
- sql_path = os.path.join(tmp, "test_escape.sql")
132
-
133
- # JSON duplicate columns
134
- cols = ["id", "id", "name"]
135
- rows = [[1, 2, "Alice"]]
136
- Exporter.to_json(cols, rows, json_path)
137
- with open(json_path, "r", encoding="utf-8") as f:
138
- data = json.load(f)
139
- assert len(data) == 1
140
- assert data[0]["id"] == 1
141
- assert data[0]["id_2"] == 2
142
- assert data[0]["name"] == "Alice"
143
-
144
- # SQL export reserved keywords & special characters
145
- table_name = "order" # Reserved word
146
- cols_with_special = ["first name", "group", "price"] # "first name" has space, "group" is reserved
147
- rows_special = [[ "Bob", "Admin", 99.5 ]]
148
- Exporter.to_sql_inserts(table_name, cols_with_special, rows_special, sql_path)
149
- with open(sql_path, "r", encoding="utf-8") as f:
150
- sql_content = f.read()
151
-
152
- assert 'INSERT INTO "order" ("first name", "group", price)' in sql_content
153
-
154
- print(" [+] Exporter Dedup & Escaping: PASS")
155
-
156
-
157
- def test_result_set_memory_limit():
158
- print("[*] 6. Testing Result Set Memory Limit (Capped at 1,000 rows)...")
159
- db = DatabaseEngine(":memory:")
160
- db.conn.execute("CREATE TABLE big (id INTEGER PRIMARY KEY);")
161
- db.conn.executemany("INSERT INTO big (id) VALUES (?);", [(i,) for i in range(1500)])
162
- db.conn.commit()
163
-
164
- res = db.execute_query("SELECT * FROM big;", max_rows=1000)
165
- assert res["success"]
166
- assert res["row_count"] == 1000, f"Expected 1000 rows capped, got {res['row_count']}"
167
- assert res["truncated"] is True, "Expected truncated flag to be True"
168
-
169
- print(" [+] Result Set Memory Limit: PASS")
170
-
171
-
172
- def test_env_credentials_url_encoding():
173
- print("[*] 7. Testing .env Credentials URL Encoding...")
174
- tmp_env = os.path.join(tempfile.gettempdir(), ".env")
175
- with open(tmp_env, "w", encoding="utf-8") as f:
176
- f.write("DB_HOST=127.0.0.1\n")
177
- f.write("DB_PORT=5432\n")
178
- f.write("DB_NAME=my_db\n")
179
- f.write("DB_USER=user@work#1\n")
180
- f.write("DB_PASSWORD=p@ss:w/ord%!\n")
181
-
182
- # Backup current dir and test detect_connection
183
- orig_dir = os.getcwd()
184
- os.chdir(tempfile.gettempdir())
185
- try:
186
- url = detect_connection()
187
- assert "user%40work%231" in url
188
- assert "p%40ss%3Aw%2Ford%25%21" in url
189
- finally:
190
- os.chdir(orig_dir)
191
- if os.path.exists(tmp_env):
192
- os.remove(tmp_env)
193
-
194
- print(" [+] .env Credentials URL Encoding: PASS")
195
-
196
-
197
- def main():
198
- print("\n" + "=" * 60)
199
- print("RUNNING SECURITY & BUG FIX VERIFICATION TESTS")
200
- print("=" * 60)
201
-
202
- test_safe_mode_and_comment_bypass()
203
- test_sql_beautifier_preserves_literals()
204
- test_sqlite_foreign_keys_enforced()
205
- test_empty_sqlite_not_polluted()
206
- test_exporter_dedup_and_ident_escaping()
207
- test_result_set_memory_limit()
208
- test_env_credentials_url_encoding()
209
-
210
- print("\n" + "=" * 60)
211
- print("ALL 10 ANALYST FINDINGS & VULNERABILITIES ARE 100% FIXED!")
212
- print("=" * 60 + "\n")
213
-
214
-
215
- if __name__ == "__main__":
216
- main()
File without changes
File without changes
File without changes
File without changes