#!/usr/bin/env python

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

"""
Minimal pure-python CUBRID client speaking the CAS (Common Application Server) broker protocol (stdlib
only, no CUBRID-Python/CCI). Does the 10-byte broker handshake (+ optional CAS-worker redirect), a
cleartext OPEN_DATABASE login, then prepare / execute / fetch with column-metadata decoding. Read-oriented
for sqlmap: execute() takes a fully-formed query string, binary (BIT/VARBIT/BLOB) values come back as bytes
(sqlmap hex-encodes them). Auto-commit is enabled so each statement is independent.
"""

import datetime
import socket
import struct

from extra.dbwire import DatabaseError
from extra.dbwire import DataError
from extra.dbwire import InterfaceError
from extra.dbwire import IntegrityError
from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
from extra.dbwire import ProgrammingError

_MAGIC = b"CUBRK"
_CLIENT_JDBC = 3
_CAS_VERSION = 0x48  # PROTO_INDICATOR(0x40) | VERSION(8)

# function codes (first raw byte of each request)
_FC_END_TRAN = 1
_FC_PREPARE = 2
_FC_EXECUTE = 3
_FC_SET_DB_PARAMETER = 5
_FC_CLOSE_REQ_HANDLE = 6
_FC_FETCH = 8
_FC_CON_CLOSE = 31

_TRAN_COMMIT = 1
_TRAN_ROLLBACK = 2
_PARAM_AUTO_COMMIT = 4

_OID_SIZE = 8
_STMT_SELECT = 21

# CUBRID CCI_U_TYPE column type codes
_T_CHAR = 1
_T_STRING = 2
_T_NCHAR = 3
_T_VARNCHAR = 4
_T_BIT = 5
_T_VARBIT = 6
_T_NUMERIC = 7
_T_INT = 8
_T_SHORT = 9
_T_MONETARY = 10
_T_FLOAT = 11
_T_DOUBLE = 12
_T_DATE = 13
_T_TIME = 14
_T_TIMESTAMP = 15
_T_OBJECT = 19
_T_BIGINT = 21
_T_DATETIME = 22
_T_BLOB = 23
_T_CLOB = 24
_T_ENUM = 25
_T_JSON = 34
_STRING_TYPES = frozenset((_T_CHAR, _T_STRING, _T_NCHAR, _T_VARNCHAR, _T_ENUM, _T_JSON))
_BINARY_TYPES = frozenset((_T_BIT, _T_VARBIT, _T_BLOB, _T_CLOB))

_MAX_MESSAGE_LENGTH = 0x40000000  # guard against a hostile/corrupt length

class _Writer(object):
    # builds a request payload (after the 8-byte header): raw function code + length-prefixed args
    def __init__(self, fc):
        self._buf = bytearray(struct.pack(">B", fc))

    def raw_int(self, v):
        self._buf += struct.pack(">i", v); return self

    def raw_byte(self, v):
        self._buf += struct.pack(">B", v); return self

    def arg_int(self, v):
        self._buf += struct.pack(">ii", 4, v); return self

    def arg_byte(self, v):
        self._buf += struct.pack(">iB", 1, v); return self

    def arg_null(self):
        self._buf += struct.pack(">i", 0); return self

    def arg_cache_time(self):
        self._buf += struct.pack(">iii", 8, 0, 0); return self

    def arg_nts(self, s):  # null-terminated string arg: [len(utf8)+1][utf8][00]
        b = s.encode("utf-8")
        self._buf += struct.pack(">i", len(b) + 1) + b + b"\x00"; return self

    def payload(self):
        return bytes(self._buf)

class _Reader(object):
    def __init__(self, buf):
        self._buf = buf
        self._off = 0

    def remaining(self):
        return len(self._buf) - self._off

    def byte(self):
        v = struct.unpack_from(">B", self._buf, self._off)[0]; self._off += 1; return v

    def short(self):
        v = struct.unpack_from(">h", self._buf, self._off)[0]; self._off += 2; return v

    def int(self):
        v = struct.unpack_from(">i", self._buf, self._off)[0]; self._off += 4; return v

    def long(self):
        v = struct.unpack_from(">q", self._buf, self._off)[0]; self._off += 8; return v

    def float(self):
        v = struct.unpack_from(">f", self._buf, self._off)[0]; self._off += 4; return v

    def double(self):
        v = struct.unpack_from(">d", self._buf, self._off)[0]; self._off += 8; return v

    def raw(self, n):
        # a slice past the end returns short data *silently*, which would hand a truncated value to the
        # caller (the fixed-width readers above raise struct.error instead)
        if n < 0 or n > self.remaining():
            raise InterfaceError("CAS response too short (wanted %d of %d bytes)" % (n, self.remaining()))
        v = self._buf[self._off:self._off + n]; self._off += n; return bytes(v)

    def skip(self, n):
        self._off += n

    def nts(self, n):  # n bytes, drop one trailing NUL if present
        b = self.raw(n)
        if b and bytearray(b)[-1:] == bytearray(b"\x00"):
            b = b[:-1]
        return b

def _decode_text(raw):
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError:
        return raw  # non-UTF-8 -> keep bytes (sqlmap hex-encodes), not lossy

def _decode_value(reader, col_type, size):
    if col_type in _STRING_TYPES:
        return _decode_text(reader.nts(size))
    if col_type in (_T_BIT, _T_VARBIT):
        return reader.raw(size)
    if col_type == _T_NUMERIC:
        return _decode_text(reader.nts(size))  # DECIMAL arrives as ASCII text
    if col_type == _T_INT:
        return str(reader.int())
    if col_type == _T_SHORT:
        return str(reader.short())
    if col_type == _T_BIGINT:
        return str(reader.long())
    if col_type == _T_FLOAT:
        return repr(reader.float())
    if col_type in (_T_DOUBLE, _T_MONETARY):
        return repr(reader.double())
    if col_type == _T_DATE:
        y, mo, d = reader.short(), reader.short(), reader.short()
        return "%s" % datetime.date(y, mo, d)
    if col_type == _T_TIME:
        h, mi, s = reader.short(), reader.short(), reader.short()
        return "%s" % datetime.time(h, mi, s)
    if col_type == _T_TIMESTAMP:
        vals = [reader.short() for _ in range(6)]
        return "%s" % datetime.datetime(*vals)
    if col_type == _T_DATETIME:
        y, mo, d, h, mi, s, ms = (reader.short() for _ in range(7))
        return "%s" % datetime.datetime(y, mo, d, h, mi, s, ms * 1000)
    if col_type == _T_OBJECT:
        page, slot, vol = reader.int(), reader.short(), reader.short()
        return "OID:@%d|%d|%d" % (page, slot, vol)
    return reader.raw(size)  # BLOB/CLOB locator or unknown type -> raw bytes

class _Column(object):
    __slots__ = ("name", "type", "scale", "precision")

class Cursor(object):
    def __init__(self, connection):
        self.connection = connection
        self.description = None
        self.rowcount = -1
        self._rows = []
        self._pos = 0

    def execute(self, query, params=None):
        if params is not None:
            raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string")
        self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0
        self.description, self._rows, self.rowcount = self.connection._query(query)
        return self

    def fetchall(self):
        retVal = self._rows[self._pos:]
        self._pos = len(self._rows)
        return retVal

    def fetchone(self):
        if self._pos >= len(self._rows):
            return None
        retVal = self._rows[self._pos]
        self._pos += 1
        return retVal

    def close(self):
        self._rows = []

class Connection(object):
    def __init__(self, host, port, user, password, database, timeout):
        self._host = host
        self._port = port
        self._user = user
        self._password = password
        self._database = database
        self._timeout = timeout
        self._sock = None
        self._cas_info = b"\x00\x00\x00\x00"
        self._protocol_version = 8
        self._open()

    def cursor(self):
        return Cursor(self)

    def commit(self):
        self._call(_Writer(_FC_END_TRAN).arg_byte(_TRAN_COMMIT))

    def rollback(self):
        self._call(_Writer(_FC_END_TRAN).arg_byte(_TRAN_ROLLBACK))

    def close(self):
        try:
            if self._sock is not None:
                self._send(_Writer(_FC_CON_CLOSE).payload())
        except Exception:
            pass
        self._safe_close()

    # ---- connection / framing ----

    def _safe_close(self):
        try:
            if self._sock is not None:
                self._sock.close()
        except Exception:
            pass
        self._sock = None

    def _recvn(self, n):
        return recvn(self._sock, n)

    def _open(self):
        # broker handshake (may redirect to a dedicated CAS worker port), then cleartext OPEN_DATABASE login
        try:
            sock = socket.create_connection((self._host, self._port), timeout=self._timeout)
            keepalive(sock)
            sock.sendall(_MAGIC + struct.pack(">BB", _CLIENT_JDBC, _CAS_VERSION) + b"\x00\x00\x00")
            self._sock = sock
            (port,) = struct.unpack(">i", self._recvn(4))
            if port < 0:
                raise OperationalError("CUBRID broker rejected the connection (status %d)" % port)
            if port > 0:  # redirected to a CAS worker: reconnect there, no second handshake
                self._safe_close()
                sock = socket.create_connection((self._host, port), timeout=self._timeout)
                keepalive(sock)
                self._sock = sock

            login = self._fixed(self._database, 32) + self._fixed(self._user, 32) + self._fixed(self._password, 32)
            login += b"\x00" * 532  # 512 extended-info + 20 reserved
            self._sock.sendall(login)
            reader = self._read_response()
            reader.int()             # response_code (>=0; errors already raised in _read_response)
            broker = reader.raw(8)
            self._protocol_version = bytearray(broker)[4] & 0x3f
            # enable auto-commit so each statement is independent (avoids the CAS keep-connection handshake dance)
            self._call(_Writer(_FC_SET_DB_PARAMETER).arg_int(_PARAM_AUTO_COMMIT).arg_int(1))
        except (socket.error, socket.timeout) as ex:
            self._safe_close()
            raise OperationalError("could not connect to '%s:%s' (%s)" % (self._host, self._port, ex))
        except Exception:   # a rejected login (or connection_lost() out of _recvn) is still ours to close
            self._safe_close()
            raise

        handshake_done(self._sock)

    @staticmethod
    def _fixed(value, length):
        b = (value or "").encode("utf-8")[:length]
        return b + b"\x00" * (length - len(b))

    def _send(self, payload):
        # frame: [payload_len(4)][cas_info(4)][payload]
        try:
            self._sock.sendall(struct.pack(">i", len(payload)) + self._cas_info + payload)
        except (socket.error, OSError) as ex:
            raise connection_lost(ex)

    def _read_response(self):
        (data_length,) = struct.unpack(">i", self._recvn(4))
        if data_length < 0 or data_length > _MAX_MESSAGE_LENGTH:
            raise InterfaceError("invalid CAS response length (%d)" % data_length)
        body = self._recvn(data_length + 4)  # cas_info(4) + payload(data_length)
        self._cas_info = body[:4]
        reader = _Reader(body[4:])
        peek = struct.unpack_from(">i", body, 4)[0]
        if peek < 0:  # error response: response_code(<0), errno, message
            reader.int()
            errno = reader.int()
            message = _decode_text(reader.nts(reader.remaining()))
            if not isinstance(message, str):
                message = "errno %d" % errno
            self._raise(errno, "(remote) %s" % message.strip())
        return reader

    def _call(self, writer):
        # reconnect transparently if the CAS worker was released after a previous auto-committed statement
        if self._sock is None or bytearray(self._cas_info)[0] == 0:
            self._open()
        try:
            self._send(writer.payload() if isinstance(writer, _Writer) else writer)
            return self._read_response()
        except (struct.error, IndexError, ValueError) as ex:
            raise InterfaceError("malformed server response: %s" % ex)

    @staticmethod
    def _raise(errno, message):
        text = message.lower()
        if any(k in text for k in ("unique", "duplicate", "foreign key", "constraint violat")):
            raise IntegrityError(message)
        if any(k in text for k in ("syntax", "unknown class", "does not exist", "not found", "before '")):
            raise ProgrammingError(message)  # CUBRID points at the offending token as: before ' ,'y')'
        if any(k in text for k in ("cast", "coerce", "conversion", "overflow", "truncat")):
            raise DataError(message)
        raise ProgrammingError(message)

    # ---- query ----

    def _query(self, query):
        reader = self._call(_Writer(_FC_PREPARE).arg_nts(query).arg_byte(0).arg_byte(0))
        handle = reader.int()
        try:
            return self._execute(handle, reader)
        finally:
            # release the broker-side request handle even when execute/fetch raised: a connection that
            # survives a few failed statements would otherwise hold every one of them until it closes
            try:
                self._call(_Writer(_FC_CLOSE_REQ_HANDLE).arg_int(handle))
            except Exception:
                pass

    def _execute(self, handle, reader):
        reader.int()                 # result cache lifetime
        stmt_type = reader.byte()
        reader.int()                 # bind count
        reader.byte()                # is_updatable
        columns = self._parse_columns(reader, reader.int())

        # args: handle, flag, max_col_size, max_row, binds, fetch_flag, auto_commit, forward_only_cursor,
        # cache_time, query_timeout. auto_commit makes the CAS worker commit the statement and end the
        # transaction - without it DML is rolled back when the connection drops, whatever _open() asked
        # SET_DB_PARAMETER for. It must stay off for a SELECT: ending the transaction there invalidates the
        # request handle the paged _fetch_remaining() still reads from.
        select = stmt_type == _STMT_SELECT
        exec_writer = (_Writer(_FC_EXECUTE).arg_int(handle).arg_byte(0).arg_int(0).arg_int(0)
                       .arg_null().arg_byte(1 if select else 0)
                       .arg_byte(0 if select else 1).arg_byte(1).arg_cache_time().arg_int(0))
        reader = self._call(exec_writer)

        total = reader.int()
        reader.byte()                # cache reusable
        result_count = reader.int()
        result_infos = [self._parse_result_info(reader) for _ in range(result_count)]
        if self._protocol_version > 1:
            reader.byte()            # includes_column_info
        if self._protocol_version > 4:
            reader.int()             # shard_id

        description, rows, rowcount = None, [], -1
        if stmt_type == _STMT_SELECT and columns:
            description = [(c.name, c.type, None, None, c.precision, c.scale, None) for c in columns]
            if reader.remaining() >= 8:
                reader.int()         # fetch code
                tuple_count = reader.int()
                rows = self._parse_rows(reader, tuple_count, columns)
            rows += self._fetch_remaining(handle, columns, len(rows), total)
        elif result_infos:
            rowcount = result_infos[0]
        return description, rows, rowcount

    def _fetch_remaining(self, handle, columns, fetched, total):
        rows = []
        while fetched + len(rows) < total:
            reader = self._call(_Writer(_FC_FETCH).arg_int(handle)
                                .arg_int(fetched + len(rows) + 1).arg_int(100).arg_byte(0).arg_int(0))
            reader.int()             # response code (>=0)
            tuple_count = reader.int()
            if tuple_count <= 0:
                break
            rows += self._parse_rows(reader, tuple_count, columns)
        return rows

    def _parse_result_info(self, reader):
        reader.byte()                # stmt type
        count = reader.int()         # affected rows
        reader.raw(_OID_SIZE)
        reader.int(); reader.int()   # cache time sec/usec
        return count

    def _parse_columns(self, reader, count):
        columns = []
        for _ in range(count):
            col = _Column()
            legacy = reader.byte()
            col.type = reader.byte() if legacy & 0x80 else legacy
            col.scale = reader.short()
            col.precision = reader.int()
            col.name = _to_str(reader.nts(reader.int()))
            reader.nts(reader.int())          # real name
            reader.nts(reader.int())          # table name
            reader.byte()                     # is_nullable
            reader.nts(reader.int())          # default value
            reader.skip(7)                    # auto_inc/unique/primary/rev_index/rev_unique/foreign/shared
            columns.append(col)
        return columns

    def _parse_rows(self, reader, tuple_count, columns):
        rows = []
        for _ in range(tuple_count):
            reader.int()             # row index
            reader.skip(_OID_SIZE)
            row = []
            for col in columns:
                size = reader.int()
                if size <= 0:
                    row.append(None)
                else:
                    row.append(_decode_value(reader, col.type, size))
            rows.append(tuple(row))
        return rows

def _to_str(b):
    v = _decode_text(b)
    return v if isinstance(v, str) else v.decode("latin-1")

def connect(host=None, port=33000, user=None, password=None, database=None, connect_timeout=None, **kwargs):
    try:
        return Connection(host or "localhost", int(port or 33000), user or "public",
                          password or "", database or "", connect_timeout)
    except (DatabaseError, InterfaceError):
        raise
    except Exception as ex:
        raise OperationalError("CUBRID connection failed (%s)" % ex)
