#!/usr/bin/env python

"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission

Output formatting of the result dumper (lib/core/dump.py) and the SQLite
replication backend (lib/core/replication.py).

dump.Dump turns extracted DB structures (schemas, table/column listings, row
counts, single facts, user lists) into the human-readable ASCII tables printed
to the console, and serializes per-table row data to CSV / HTML / SQLite files.
None of that needs a live target, network or DBMS: the console renderers route
every line through Dump._write (overridden here to capture instead of print),
and the file renderers just write to a path we point at a temp dir. These tests
pin the rendered layout/escaping contracts so a formatting regression is caught
without an end-to-end scan.
"""

import io
import os
import shutil
import sys
import tempfile
import unittest

from collections import OrderedDict as _PlainOrderedDict

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap, reset_dbms
bootstrap()

from lib.core.common import Backend
from lib.core.data import conf, kb
from lib.core.dump import Dump
from lib.core.enums import DUMP_FORMAT
from lib.core.replication import Replication


# --- console-rendering tests (no files): capture every Dump._write line --------------------------

class _CaptureCase(unittest.TestCase):
    """Base for the console renderers: pins a neutral case-preserving DBMS, disables api/report
    side channels, and replaces Dump._write with an in-memory capture so nothing hits stdout."""

    _CONF_KEYS = ("api", "reportCollector", "dumpFormat", "col", "csvDel", "dumpPath", "dumpFile",
                  "limitStart", "limitStop", "forceDbms", "dbms")
    _KB_KEYS = ("forcedDbms", "dbms")

    def setUp(self):
        self._saved = dict((k, conf.get(k)) for k in self._CONF_KEYS)
        self._savedKb = dict((k, kb.get(k)) for k in self._KB_KEYS)
        conf.forceDbms = conf.dbms = None
        kb.dbms = None
        Backend.forceDbms("MySQL")
        conf.api = False
        conf.reportCollector = None
        conf.col = None
        conf.csvDel = ","
        self.lines = []
        self.d = Dump()
        self.d._write = self._capture

    def tearDown(self):
        for k, v in self._saved.items():
            conf[k] = v
        for k, v in self._savedKb.items():
            kb[k] = v

    def _capture(self, data, newline=True, console=True, content_type=None):
        # mirror Dump._write's own line-vs-space join so multi-call lines reassemble faithfully
        self.lines.append("%s%s" % (data, "\n" if newline else " "))

    def text(self):
        return "".join(self.lines)


class TestStringAndLister(_CaptureCase):
    def test_string_scalar_quoted(self):
        # a plain string fact is rendered as "header: 'value'"
        self.d.string("current user", "root@localhost")
        self.assertIn("current user: 'root@localhost'", self.text())

    def test_string_multiline_block(self):
        # a value containing a newline switches to the fenced ---\n...\n--- block form
        self.d.string("banner", "line1\nline2")
        out = self.text()
        self.assertIn("banner:\n---\nline1\nline2\n---", out)

    def test_string_singleton_list_unwrapped(self):
        # a one-element list is unwrapped to the scalar form (not the lister "[N]:" form)
        self.d.string("current database", ["testdb"])
        out = self.text()
        self.assertIn("current database: 'testdb'", out)
        self.assertNotIn("[1]", out)

    def test_lister_sorts_and_counts(self):
        # lister prints a "[count]:" header, one "[*] item" per element, sorted case-insensitively
        self.d.lister("available databases", ["mysql", "Alpha", "zebra"])
        out = self.text()
        self.assertIn("available databases [3]:", out)
        body = out[out.index("[3]:"):]
        # case-insensitive ascending: Alpha, mysql, zebra
        self.assertLess(body.index("[*] Alpha"), body.index("[*] mysql"))
        self.assertLess(body.index("[*] mysql"), body.index("[*] zebra"))

    def test_lister_dedupes(self):
        # the sort path also de-duplicates (set()) before listing
        self.d.lister("database management system users", ["root", "root", "guest"])
        out = self.text()
        self.assertIn("database management system users [2]:", out)
        self.assertEqual(out.count("[*] root"), 1)

    def test_lister_unsorted_preserves_order(self):
        # sort=False (e.g. rFile) keeps insertion order
        self.d.lister("files saved to", ["/z", "/a", "/m"], sort=False)
        out = self.text()
        self.assertLess(out.index("[*] /z"), out.index("[*] /a"))
        self.assertLess(out.index("[*] /a"), out.index("[*] /m"))


class TestCurrentDb(_CaptureCase):
    def test_label_default_dbms(self):
        # MySQL is not in the schema/owner special-cased lists -> plain "current database"
        self.d.currentDb("testdb")
        self.assertIn("current database: 'testdb'", self.text())

    def test_label_schema_dbms(self):
        # Oracle is in the schema-equivalent list -> the label is annotated accordingly
        Backend.forceDbms("Oracle")
        self.d.currentDb("SYSTEM")
        out = self.text()
        self.assertIn("equivalent to schema on Oracle", out)
        self.assertIn("SYSTEM", out)


class TestDbTables(_CaptureCase):
    def test_table_listing_box(self):
        self.d.dbTables({"testdb": ["users", "logs"]})
        out = self.text()
        self.assertIn("Database: testdb", out)
        self.assertIn("[2 tables]", out)
        self.assertIn("| users", out)
        self.assertIn("| logs", out)
        # box borders present
        self.assertIn("+", out)

    def test_single_table_singular(self):
        self.d.dbTables({"testdb": ["only"]})
        self.assertIn("[1 table]", self.text())

    def test_no_tables(self):
        self.d.dbTables({})
        self.assertIn("No tables found", self.text())

    def test_box_width_matches_longest_table(self):
        # the border length tracks the longest table name (+2 padding)
        self.d.dbTables({"testdb": ["a", "elephant"]})
        out = self.text()
        # "elephant" is 8 chars -> a border line of 8+2 = 10 dashes exists
        self.assertIn("+%s+" % ("-" * 10), out)


class TestDbTableColumns(_CaptureCase):
    def test_typed_columns_two_column_box(self):
        self.d.dbTableColumns({"testdb": {"users": {"id": "int", "name": "varchar(50)"}}})
        out = self.text()
        self.assertIn("Database: testdb", out)
        self.assertIn("Table: users", out)
        self.assertIn("[2 columns]", out)
        self.assertIn("| Column", out)
        self.assertIn("| Type", out)
        self.assertIn("int", out)
        self.assertIn("varchar(50)", out)

    def test_typeless_columns_single_box(self):
        # when no column carries a type, only the Column box is rendered (no Type header)
        self.d.dbTableColumns({"testdb": {"users": {"id": None, "name": None}}})
        out = self.text()
        self.assertIn("| Column", out)
        self.assertNotIn("| Type", out)

    def test_mixed_types_still_show_type_header(self):
        # even if the alphabetically-last column is type-less, a Type column must appear
        self.d.dbTableColumns({"testdb": {"t": {"aaa": "int", "zzz": None}}})
        self.assertIn("| Type", self.text())


class TestDbTablesCount(_CaptureCase):
    def test_count_box_sorted_desc(self):
        self.d.dbTablesCount({"testdb": {5: ["small"], 100: ["big"]}})
        out = self.text()
        self.assertIn("Database: testdb", out)
        self.assertIn("| Table", out)
        self.assertIn("| Entries", out)
        # higher count first (reverse sort)
        self.assertLess(out.index("big"), out.index("small"))
        self.assertIn("100", out)


class TestUserSettings(_CaptureCase):
    def test_privileges_listed_with_admin_flag(self):
        # userSettings accepts (settingsDict, adminsSet); admins get an "(administrator)" tag
        settings = ({"root": ["ALL"], "guest": ["SELECT"]}, set(["root"]))
        self.d.userSettings("database management system users privileges", settings, "privilege")
        out = self.text()
        self.assertIn("[*] root (administrator)", out)
        self.assertIn("[*] guest", out)
        self.assertNotIn("guest (administrator)", out)
        self.assertIn("privilege: ALL", out)
        self.assertIn("privilege: SELECT", out)


# --- file-rendering tests (CSV / HTML / SQLite): point output at a temp dir ----------------------

class _FileDumpCase(unittest.TestCase):
    _CONF_KEYS = ("dumpFormat", "dumpPath", "dumpFile", "col", "api", "reportCollector",
                  "limitStart", "limitStop", "csvDel", "forceDbms", "dbms")
    _KB_KEYS = ("forcedDbms", "dbms")

    def setUp(self):
        self._saved = dict((k, conf.get(k)) for k in self._CONF_KEYS)
        self._savedKb = dict((k, kb.get(k)) for k in self._KB_KEYS)
        conf.forceDbms = conf.dbms = None
        kb.dbms = None
        Backend.forceDbms("MySQL")
        self.tmp = tempfile.mkdtemp(prefix="sqlmap-dumpfmt-test")
        conf.dumpPath = self.tmp
        conf.dumpFile = None
        conf.col = None
        conf.api = False
        conf.reportCollector = None
        conf.limitStart = conf.limitStop = None
        conf.csvDel = ","
        self.d = Dump()
        self.d._write = lambda *a, **k: None   # silence the console table

    def tearDown(self):
        for k, v in self._saved.items():
            conf[k] = v
        for k, v in self._savedKb.items():
            kb[k] = v
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _path(self, table_values, ext):
        db = table_values["__infos__"]["db"] or "All"
        return os.path.join(self.tmp, db, "%s.%s" % (table_values["__infos__"]["table"], ext))

    def _dump(self, table_values, fmt, ext):
        conf.dumpFormat = fmt
        self.d.dbTableValues(table_values)
        with io.open(self._path(table_values, ext), encoding="utf-8") as f:
            return f.read()


class TestCsvDump(_FileDumpCase):
    def _sample(self):
        return _PlainOrderedDict([
            ("__infos__", {"count": 2, "db": "testdb", "table": "users"}),
            ("id", {"length": 2, "values": ["1", "2"]}),
            ("name", {"length": 6, "values": ["luther", "fluffy"]}),
        ])

    def test_header_and_rows(self):
        content = self._dump(self._sample(), DUMP_FORMAT.CSV, "csv")
        lines = [l for l in content.splitlines() if l.strip()]
        self.assertEqual(lines[0].split(","), ["id", "name"])
        self.assertEqual(lines[1].split(","), ["1", "luther"])
        self.assertEqual(lines[2].split(","), ["2", "fluffy"])

    def test_delimiter_in_value_is_quoted(self):
        # RFC-4180: a value containing the delimiter must be wrapped in quotes
        tv = _PlainOrderedDict([
            ("__infos__", {"count": 1, "db": "testdb", "table": "t"}),
            ("a", {"length": 8, "values": ["x,y"]}),
            ("b", {"length": 1, "values": ["z"]}),
        ])
        content = self._dump(tv, DUMP_FORMAT.CSV, "csv")
        self.assertIn('"x,y"', content)

    def test_null_and_blank_markers(self):
        # the display replacements apply to CSV too: DB NULL (" ") -> NULL, empty ("") -> <blank>
        tv = _PlainOrderedDict([
            ("__infos__", {"count": 1, "db": "testdb", "table": "t"}),
            ("a", {"length": 4, "values": [" "]}),
            ("b", {"length": 7, "values": [""]}),
            ("c", {"length": 1, "values": ["x"]}),
        ])
        content = self._dump(tv, DUMP_FORMAT.CSV, "csv")
        row = [l for l in content.splitlines() if l.strip()][1]
        self.assertEqual(row.split(","), ["NULL", "<blank>", "x"])

    def test_custom_delimiter(self):
        conf.csvDel = ";"
        content = self._dump(self._sample(), DUMP_FORMAT.CSV, "csv")
        self.assertEqual(content.splitlines()[0].split(";"), ["id", "name"])


class TestHtmlDump(_FileDumpCase):
    def _sample(self):
        return _PlainOrderedDict([
            ("__infos__", {"count": 1, "db": "testdb", "table": "users"}),
            ("id", {"length": 2, "values": ["1"]}),
            ("name", {"length": 6, "values": ["luther"]}),
        ])

    def test_html_scaffold_and_cells(self):
        content = self._dump(self._sample(), DUMP_FORMAT.HTML, "html")
        self.assertIn("<!DOCTYPE html>", content)
        self.assertIn("<title>testdb.users</title>", content)
        self.assertIn("<th", content)
        self.assertIn(">id</th>", content)
        self.assertIn(">name</th>", content)
        self.assertIn("<td>1</td>", content)
        self.assertIn("<td>luther</td>", content)
        self.assertIn("</table>", content)
        self.assertIn("</html>", content)

    def test_html_escapes_markup(self):
        # a value with HTML metacharacters must be escaped, not emitted raw
        tv = _PlainOrderedDict([
            ("__infos__", {"count": 1, "db": "testdb", "table": "t"}),
            ("payload", {"length": 16, "values": ["<script>x</script>"]}),
        ])
        content = self._dump(tv, DUMP_FORMAT.HTML, "html")
        self.assertNotIn("<td><script>x</script></td>", content)
        self.assertIn("&lt;", content)


class TestSqliteDump(_FileDumpCase):
    def test_rows_and_inferred_types(self):
        tv = _PlainOrderedDict([
            ("__infos__", {"count": 2, "db": "testdb", "table": "people"}),
            ("id", {"length": 2, "values": ["1", "2"]}),       # all ints -> INTEGER
            ("ratio", {"length": 4, "values": ["1.5", "2.0"]}),  # floats -> REAL
            ("name", {"length": 6, "values": ["alice", " "]}),   # text with a NULL marker
        ])
        conf.dumpFormat = DUMP_FORMAT.SQLITE
        self.d.dbTableValues(tv)

        import sqlite3
        dbfile = os.path.join(self.tmp, "testdb.sqlite3")
        self.assertTrue(os.path.exists(dbfile))
        conn = sqlite3.connect(dbfile)
        try:
            cur = conn.cursor()
            cur.execute("SELECT id, ratio, name FROM people ORDER BY id")
            rows = cur.fetchall()
            self.assertEqual(rows[0], (1, 1.5, "alice"))
            # the DB NULL marker (" ") was stored as a real NULL, not the "NULL" text
            self.assertEqual(rows[1], (2, 2.0, None))
            # column affinities inferred from the values
            cur.execute("PRAGMA table_info(people)")
            types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()}
            self.assertEqual(types["id"], "INTEGER")
            self.assertEqual(types["ratio"], "REAL")
            self.assertEqual(types["name"], "TEXT")
        finally:
            conn.close()

    def test_non_roundtrip_numbers_stay_text(self):
        # Values that look numeric but would be silently rewritten by SQLite's INTEGER/REAL
        # affinity (leading zeros, sign prefix, 64-bit overflow, trailing-zero/exponent floats)
        # must be stored verbatim as TEXT, otherwise the export corrupts the dumped data
        tv = _PlainOrderedDict([
            ("__infos__", {"count": 1, "db": "testdb", "table": "t"}),
            ("zip", {"length": 5, "values": ["007"]}),
            ("phone", {"length": 10, "values": ["0917123456"]}),
            ("signed", {"length": 2, "values": ["+1"]}),
            ("huge", {"length": 30, "values": ["123456789012345678901234567890"]}),
            ("money", {"length": 4, "values": ["2.00"]}),
            ("real_int", {"length": 1, "values": ["5"]}),   # genuine ints still typed INTEGER
        ])
        conf.dumpFormat = DUMP_FORMAT.SQLITE
        self.d.dbTableValues(tv)

        import sqlite3
        conn = sqlite3.connect(os.path.join(self.tmp, "testdb.sqlite3"))
        try:
            cur = conn.cursor()
            cur.execute("SELECT zip, phone, signed, huge, money, real_int FROM t")
            self.assertEqual(cur.fetchone(), ("007", "0917123456", "+1", "123456789012345678901234567890", "2.00", 5))
            cur.execute("PRAGMA table_info(t)")
            types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()}
            self.assertEqual(types["zip"], "TEXT")
            self.assertEqual(types["huge"], "TEXT")
            self.assertEqual(types["money"], "TEXT")
            self.assertEqual(types["real_int"], "INTEGER")
        finally:
            conn.close()

    def test_type_breaking_value_past_sampling_prefix_stays_text(self):
        # Regression: type inference once sampled only the first 100 values, so a leading-zero /
        # signed / overflow value at index >= 100 was missed and the column got typed INTEGER,
        # silently corrupting that value via SQLite affinity on insert. The whole column must be scanned.
        values = [str(i) for i in range(1, 101)] + ["007"]   # 100 clean ints, then a leading-zero at index 100
        tv = _PlainOrderedDict([
            ("__infos__", {"count": len(values), "db": "testdb", "table": "big"}),
            ("code", {"length": 3, "values": values}),
        ])
        conf.dumpFormat = DUMP_FORMAT.SQLITE
        self.d.dbTableValues(tv)

        import sqlite3
        conn = sqlite3.connect(os.path.join(self.tmp, "testdb.sqlite3"))
        try:
            cur = conn.cursor()
            cur.execute("PRAGMA table_info(big)")
            types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()}
            self.assertEqual(types["code"], "TEXT")   # a single non-round-trip value anywhere forces TEXT
            cur.execute("SELECT code FROM big WHERE code = '007'")
            self.assertEqual(cur.fetchone(), ("007",))   # stored verbatim, not rewritten to integer 7
        finally:
            conn.close()


# --- replication backend tests (pure sqlite3, no network/DBMS) -----------------------------------

class TestReplication(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp(prefix="sqlmap-repl-test")
        self.path = os.path.join(self.tmp, "out.sqlite3")
        self.repl = Replication(self.path)

    def tearDown(self):
        try:
            self.repl.connection.close()
        except Exception:
            pass
        shutil.rmtree(self.tmp, ignore_errors=True)

    def test_create_insert_select_roundtrip(self):
        t = self.repl.createTable("t", [("id", Replication.INTEGER), ("name", Replication.TEXT)])
        t.beginTransaction()
        t.insert(["1", "alice"])
        t.insert(["2", "bob"])
        t.endTransaction()
        rows = sorted(t.select())
        self.assertEqual(rows, [(1, "alice"), (2, "bob")])

    def test_select_with_condition(self):
        t = self.repl.createTable("t", [("id", Replication.INTEGER), ("name", Replication.TEXT)])
        t.insert(["1", "alice"])
        t.insert(["2", "bob"])
        self.assertEqual(t.select("name = 'bob'"), [(2, "bob")])

    def test_insert_wrong_arity_raises(self):
        from lib.core.exception import SqlmapValueException
        t = self.repl.createTable("t", [("id", Replication.INTEGER), ("name", Replication.TEXT)])
        with self.assertRaises(SqlmapValueException):
            t.insert(["only-one-value"])

    def test_typeless_table(self):
        t = self.repl.createTable("t", ["a", "b"], typeless=True)
        t.insert(["x", "y"])
        self.assertEqual(t.select(), [("x", "y")])

    def test_datatype_str(self):
        self.assertEqual(str(Replication.TEXT), "TEXT")
        self.assertEqual(str(Replication.INTEGER), "INTEGER")
        self.assertIn("DataType", repr(Replication.REAL))


if __name__ == "__main__":
    unittest.main(verbosity=2)


def tearDownModule():
    reset_dbms()   # clear any DBMS forced via set_dbms() so it can't leak into later test modules
