Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions data/xml/queries.xml
Original file line number Diff line number Diff line change
Expand Up @@ -598,11 +598,11 @@
<blind/>
</dbs>
<tables>
<inband query="SELECT name FROM %s..sysobjects WHERE type IN ('U','V')"/>
<inband query="SELECT %s..sysusers.name+'.'+%s..sysobjects.name AS name FROM %s..sysobjects,%s..sysusers WHERE %s..sysobjects.uid=%s..sysusers.uid AND %s..sysobjects.type IN ('U','V')"/>
<blind/>
</tables>
<columns>
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns,%s..sysobjects WHERE %s..syscolumns.id=%s..sysobjects.id AND %s..sysobjects.name='%s'" condition="[DB]..syscolumns.name"/>
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns WHERE %s..syscolumns.id=object_id('%s.%s')" condition="[DB]..syscolumns.name"/>
<blind/>
</columns>
<dump_table>
Expand Down
10 changes: 8 additions & 2 deletions extra/dbwire/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ A wire protocol is shared across a whole family of products, so one client serve
|-----------------|---------------------|---------|
| `postgres.py` | PostgreSQL v3 | PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica |
| `mysql.py` | MySQL client/server | MySQL, MariaDB, TiDB, Aurora (MySQL), Percona |
| `tds.py` | TDS | Microsoft SQL Server, Sybase |
| `tds.py` | TDS 7.x | Microsoft SQL Server |
| `sybase.py` | TDS 5.0 | Sybase / SAP ASE |
| `firebird.py` | Firebird wire | Firebird 3 / 4 / 5 |
| `cubrid.py` | CUBRID CAS | CUBRID |
| `clickhouse.py` | HTTP (TabSeparated) | ClickHouse and HTTP-compatible forks |
Expand Down Expand Up @@ -57,7 +58,12 @@ parameter binding, prepared statements, bulk load/`COPY`, or TLS. Notable per-pr
`caching_sha2_password` authentication over a plaintext connection requires RSA/TLS and is not
supported; use a `mysql_native_password` account for the dependency-free path.
- **TDS** - cleartext login only. Servers that force encryption (for example Azure SQL Database)
require TLS and are not supported here; the native driver or SQLAlchemy tier covers those.
require TLS and are not supported here; the native driver or SQLAlchemy tier covers those. LOGIN7 is the
Microsoft dialect (TDS 7.x) and does not reach Sybase - see the next bullet.
- **TDS 5.0 (Sybase)** - cleartext LOGINREC login. The client declares no optional capabilities, so the
server converts anything exotic (`date`/`time`/`bigdatetime`, wide formats) down to the baseline types
before sending it. Packet size is whatever the login negotiates. Sends and expects UTF-8, so a server
running any character set is decoded correctly.
- **Firebird** - SRP-256 (and SRP) authentication with ChaCha20 or RC4 wire encryption, as required by
default on Firebird 3 and later. Legacy (pre-SRP) authentication is not implemented.
- **CUBRID** - cleartext login over the CAS broker protocol. Large objects (BLOB/CLOB) are returned as
Expand Down
49 changes: 42 additions & 7 deletions extra/dbwire/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@

Design note: connectors speak a *wire protocol*, not a product, so a single client covers the whole
compatible family - e.g. the PostgreSQL client also serves CockroachDB, CrateDB, Redshift and Greenplum;
a MySQL client serves MariaDB/TiDB/Aurora; a TDS client serves MSSQL/Sybase. Each module exposes a small
PEP 249 (DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
a MySQL client serves MariaDB/TiDB/Aurora. Where a family split the protocol, so does the client: tds.py
speaks Microsoft's TDS 7.x and sybase.py the TDS 5.0 that ASE kept. Each module exposes a small PEP 249
(DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
"""

import socket

__version__ = "0.1"

apilevel = "2.0"
Expand Down Expand Up @@ -71,6 +74,21 @@ def connection_lost(ex):

return OperationalError("connection lost (%s)" % ex)

def handshake_done(sock):
"""
Drop the connect deadline, once the login exchange is over.

connect_timeout has to stay armed THROUGH the handshake, not just the TCP connect: a peer that
accepts the connection and then says nothing (a wrong port, a silent proxy, a dropping firewall) is
perfectly alive as far as keepalive() below is concerned, so an unbounded login read waits forever.
A query is the opposite case - see keepalive().
"""

try:
sock.settimeout(None)
except Exception:
pass

def keepalive(sock):
"""
Ask the kernel to probe an idle connection, so a peer that dies without a FIN is eventually detected.
Expand All @@ -80,12 +98,29 @@ def keepalive(sock):
failure being guarded against. Best-effort - the options are not portable everywhere.
"""

import socket as _socket

try:
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
for name, value in (("TCP_KEEPIDLE", 60), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 5)):
if hasattr(_socket, name):
sock.setsockopt(_socket.IPPROTO_TCP, getattr(_socket, name), value)
if hasattr(socket, name):
sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, name), value)
except Exception:
pass

def recvn(sock, n):
"""
Read exactly n bytes off `sock`, or raise - every wire protocol here is framed, so a short read is a
desynchronized stream, not a smaller message.

Shared because it was five identical copies: a fix to the recv loop has to land once, not per module.
"""

buf = b""
while len(buf) < n:
try:
chunk = sock.recv(n - len(buf))
except (socket.error, OSError) as ex:
raise connection_lost(ex)
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
58 changes: 32 additions & 26 deletions extra/dbwire/cubrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
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"
Expand Down Expand Up @@ -132,6 +134,10 @@ 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):
Expand Down Expand Up @@ -257,23 +263,13 @@ def _safe_close(self):
self._sock = None

def _recvn(self, n):
buf = b""
while len(buf) < n:
try:
chunk = self._sock.recv(n - len(buf))
except (socket.error, OSError) as ex:
raise connection_lost(ex)
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
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.settimeout(None)
sock.sendall(_MAGIC + struct.pack(">BB", _CLIENT_JDBC, _CAS_VERSION) + b"\x00\x00\x00")
self._sock = sock
(port,) = struct.unpack(">i", self._recvn(4))
Expand All @@ -283,21 +279,25 @@ def _open(self):
self._safe_close()
sock = socket.create_connection((self._host, port), timeout=self._timeout)
keepalive(sock)
sock.settimeout(None)
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

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))
handshake_done(self._sock)

@staticmethod
def _fixed(value, length):
Expand Down Expand Up @@ -343,9 +343,9 @@ 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)
if any(k in text for k in ("cast", "conversion", "overflow", "truncat")):
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)

Expand All @@ -371,9 +371,15 @@ def _execute(self, handle, reader):
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 stmt_type == _STMT_SELECT else 0)
.arg_byte(0).arg_byte(1).arg_cache_time().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()
Expand Down
93 changes: 46 additions & 47 deletions extra/dbwire/firebird.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
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

# operation codes
_op_connect = 1
Expand Down Expand Up @@ -142,6 +144,8 @@
_GDS_DATA = frozenset((335544321,))
_GDS_WARNING = 335544434

_MAX_MESSAGE_LENGTH = 0x40000000 # cap on a wire-supplied length, to bound a hostile/corrupt stream

# SRP-6a group used by Firebird (fixed 1024-bit prime, generator 2)
_SRP_N = int("E67D2E994B2F900C3F41F08F5BB2627ED0D49EE1FE767A52EFCD565CD6E768812C3E1E9CE8F0A8BEA6CB13CD29DDE"
"BF7A96D4A93B55D488DF099A15C89DCB0640738EB2CBDD9A8F7BAB561AB1B0DC1C6CDABF303264A08D1BCA932D1F"
Expand Down Expand Up @@ -294,18 +298,13 @@ def send(self, data):
raise connection_lost(ex)

def _recv_raw(self, n):
buf = b""
while len(buf) < n:
try:
chunk = self._sock.recv(n - len(buf))
except (socket.error, OSError) as ex:
raise connection_lost(ex)
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
return recvn(self._sock, n)

def recv(self, n, align=False):
# every length here comes off the wire (response buffers, status strings, per-value lengths): a
# negative one would silently return short data, a huge one would read until memory ran out
if n < 0 or n > _MAX_MESSAGE_LENGTH:
raise InterfaceError("invalid Firebird length (%d)" % n)
total = n + ((4 - n % 4) % 4) if align else n
data = self._recv_raw(total)
if self._rc:
Expand All @@ -324,6 +323,38 @@ def close(self):
except Exception:
pass

def _parse_response(wire):
head = wire.recv(16)
handle = struct.unpack("!i", head[:4])[0]
object_id = head[4:12]
buf = wire.recv(struct.unpack("!i", head[12:16])[0], align=True)
_check_status(wire)
return handle, object_id, buf

def _check_status(wire):
gds, message = set(), ""
n = wire.recv_int()
while n != _isc_arg_end:
if n == _isc_arg_gds:
gds_code = wire.recv_int()
if gds_code:
gds.add(gds_code)
elif n == _isc_arg_number:
message += " %d" % wire.recv_int()
elif n in (_isc_arg_string, _isc_arg_interpreted, _isc_arg_sql_state):
s = wire.recv(wire.recv_int(), align=True)
if n != _isc_arg_sql_state:
message += " " + s.decode("utf-8", "replace")
n = wire.recv_int()
if gds:
message = ("(remote) firebird error %s%s" % (sorted(gds), message)).strip()
if gds & _GDS_INTEGRITY:
raise IntegrityError(message)
if gds & _GDS_DATA:
raise DataError(message)
if _GDS_WARNING not in gds:
raise OperationalError(message)

def _pack_int(v):
return struct.pack("!i", v)

Expand Down Expand Up @@ -455,39 +486,7 @@ def _response(self):
op = self._wire.recv_int()
if op != _op_response:
raise OperationalError("unexpected Firebird operation %d" % op)
return self._parse_response()

def _parse_response(self):
head = self._wire.recv(16)
handle = struct.unpack("!i", head[:4])[0]
object_id = head[4:12]
buf = self._wire.recv(struct.unpack("!i", head[12:16])[0], align=True)
self._check_status()
return handle, object_id, buf

def _check_status(self):
gds, message = set(), ""
n = self._wire.recv_int()
while n != _isc_arg_end:
if n == _isc_arg_gds:
gds_code = self._wire.recv_int()
if gds_code:
gds.add(gds_code)
elif n == _isc_arg_number:
message += " %d" % self._wire.recv_int()
elif n in (_isc_arg_string, _isc_arg_interpreted, _isc_arg_sql_state):
s = self._wire.recv(self._wire.recv_int(), align=True)
if n != _isc_arg_sql_state:
message += " " + s.decode("utf-8", "replace")
n = self._wire.recv_int()
if gds:
message = ("(remote) firebird error %s%s" % (sorted(gds), message)).strip()
if gds & _GDS_INTEGRITY:
raise IntegrityError(message)
if gds & _GDS_DATA:
raise DataError(message)
if _GDS_WARNING not in gds:
raise OperationalError(message)
return _parse_response(self._wire)

# ---- query ----

Expand Down Expand Up @@ -626,7 +625,7 @@ def _fetch(self, stmt, columns):
op = self._wire.recv_int()
if op != _op_fetch_response:
if op == _op_response:
self._parse_response()
_parse_response(self._wire)
raise OperationalError("unexpected Firebird operation %d during fetch" % op)
status = self._wire.recv_int()
count = self._wire.recv_int()
Expand Down Expand Up @@ -759,7 +758,6 @@ def connect(host=None, port=3050, user=None, password=None, database=None, conne
try:
sock = socket.create_connection((host or "localhost", int(port or 3050)), timeout=connect_timeout)
keepalive(sock)
sock.settimeout(None)
except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))

Expand All @@ -776,6 +774,7 @@ def connect(host=None, port=3050, user=None, password=None, database=None, conne
_authenticate(wire, user, password, public_key, private_key)
connection = Connection(wire, filename, user, password)
_attach(connection, wire, user)
handshake_done(sock)
except (DatabaseError, InterfaceError):
wire.close()
raise
Expand All @@ -799,7 +798,7 @@ def _authenticate(wire, user, password, public_key, private_key):
if op == _op_reject:
raise OperationalError("Firebird connection rejected")
if op == _op_response:
Connection(wire, b"", user, password)._parse_response() # will raise the server error
_parse_response(wire) # will raise the server error
raise OperationalError("Firebird connection rejected")

wire.recv(12) # accept block: protocol version / architecture / type (not needed once lazy-send is off)
Expand Down Expand Up @@ -852,7 +851,7 @@ def _read_response(wire, user, password):
raise OperationalError("Firebird authentication failed")
if op != _op_response:
raise OperationalError("unexpected Firebird operation %d during login" % op)
return Connection(wire, b"", user, password)._parse_response()[2]
return _parse_response(wire)[2]

def _attach(connection, wire, user):
dpb = bytearray([_isc_dpb_version1])
Expand Down
Loading