nusadb 0.1.0__py3-none-any.whl

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.
nusadb/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """nusadb — the Python driver for NusaDB (PEP-249 / DB-API 2.0).
2
+
3
+ Speaks the Nusa Wire Protocol (``PROTOCOL_VERSION 1.1``, see
4
+ ``docs/wire-protocol.md``) directly over a socket — no C extension, stdlib only.
5
+
6
+ Example::
7
+
8
+ import nusadb
9
+
10
+ conn = nusadb.connect(host="127.0.0.1", port=5678, user="nusa-root", database="nusadb")
11
+ cur = conn.cursor()
12
+ cur.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
13
+ cur.execute("INSERT INTO t VALUES ($1, $2)", [1, "alice"])
14
+ cur.execute("SELECT id, name FROM t WHERE id = $1", [1])
15
+ print(cur.fetchall())
16
+ conn.close()
17
+
18
+ Parameter markers are positional ``$1``, ``$2`` (the server's native form);
19
+ ``paramstyle`` is reported as ``"numeric"``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .connection import Connection, Cursor, Notification, connect
25
+ from .exceptions import (
26
+ DatabaseError,
27
+ DataError,
28
+ Error,
29
+ IntegrityError,
30
+ InterfaceError,
31
+ InternalError,
32
+ NotSupportedError,
33
+ OperationalError,
34
+ ProgrammingError,
35
+ Warning,
36
+ )
37
+ from .pool import Pool
38
+
39
+ # PEP-249 module globals.
40
+ apilevel = "2.0"
41
+ threadsafety = 1 # threads may share the module but not connections
42
+ paramstyle = "numeric" # positional markers, written ``$1`` (server-native)
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "connect",
48
+ "Connection",
49
+ "Cursor",
50
+ "Notification",
51
+ "Pool",
52
+ "apilevel",
53
+ "threadsafety",
54
+ "paramstyle",
55
+ "Warning",
56
+ "Error",
57
+ "InterfaceError",
58
+ "DatabaseError",
59
+ "DataError",
60
+ "OperationalError",
61
+ "IntegrityError",
62
+ "InternalError",
63
+ "ProgrammingError",
64
+ "NotSupportedError",
65
+ ]
nusadb/_protocol.py ADDED
@@ -0,0 +1,271 @@
1
+ """Low-level Nusa Wire Protocol codec.
2
+
3
+ Implements the framing and message layouts of ``docs/wire-protocol.md``
4
+ (``PROTOCOL_VERSION 1.1``): a frame is ``[type:u8][len:u32][payload]`` with
5
+ ``len`` the *total* size including the 5-byte header, big-endian throughout.
6
+ Strings are length-prefixed UTF-8 (``[len:u32][bytes]``, **not** null-terminated).
7
+
8
+ This module is intentionally dependency-free (stdlib only).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ from typing import List, Optional, Tuple
15
+
16
+ # Protocol magic: ASCII "NUSA".
17
+ PROTOCOL_MAGIC = 0x4E555341
18
+ # Request minor 2 to receive the typed RowDescription (per-column type tags) AND the element type of
19
+ # an ARRAY column (protocol 1.2). An older server ignores the minor and answers with a form the driver
20
+ # still handles (1.1 = typed scalars only; 1.0 = untyped names).
21
+ PROTOCOL_VERSION = (1, 2)
22
+ HEADER_LEN = 5
23
+ MAX_FRAME_LEN = 256 * 1024 * 1024
24
+
25
+ # Column type tags carried by RowDescriptionTyped (protocol 1.1, wire-protocol.md §9.2). Maps the
26
+ # 1-byte tag to a canonical type name; an unknown/0x00 tag is UNKNOWN (treated as text).
27
+ TYPE_TAGS = {
28
+ 0x00: "UNKNOWN", 0x01: "BOOL", 0x02: "INT", 0x03: "FLOAT", 0x04: "NUMERIC",
29
+ 0x05: "TEXT", 0x06: "BYTES", 0x07: "DATE", 0x08: "TIME", 0x09: "TIMETZ",
30
+ 0x0A: "TIMESTAMP", 0x0B: "TIMESTAMPTZ", 0x0C: "INTERVAL", 0x0D: "UUID",
31
+ 0x0E: "JSON", 0x0F: "ARRAY", 0x10: "VECTOR",
32
+ }
33
+
34
+ # Protocol 1.2: an ARRAY column's tag is ``ARRAY_ELEMENT_FLAG | <element tag>`` (e.g. INT[] = 0x82),
35
+ # so the element type travels with the column and the row decoder casts the elements at their type.
36
+ ARRAY_ELEMENT_FLAG = 0x80
37
+
38
+
39
+ def type_name(tag: int) -> str:
40
+ """Canonical type name for a RowDescriptionTyped type tag (UNKNOWN if unrecognised). An
41
+ element-typed array tag (protocol 1.2, ``0x80 | element``) is named ``ARRAY:<element>`` —
42
+ e.g. ``ARRAY:INT`` — so the row decoder can cast the elements at their real type."""
43
+ if tag & ARRAY_ELEMENT_FLAG:
44
+ return "ARRAY:" + TYPE_TAGS.get(tag & ~ARRAY_ELEMENT_FLAG, "UNKNOWN")
45
+ return TYPE_TAGS.get(tag, "UNKNOWN")
46
+
47
+ # Frontend (client -> server) type bytes.
48
+ T_STARTUP = ord("S")
49
+ T_QUERY = ord("Q")
50
+ T_PARSE = ord("P")
51
+ T_BIND = ord("B")
52
+ T_DESCRIBE = ord("D")
53
+ T_EXECUTE = ord("E")
54
+ T_SYNC = ord("Y")
55
+ T_CLOSE = ord("C")
56
+ T_SASL_INITIAL = ord("p")
57
+ T_SASL_RESPONSE = ord("r")
58
+ T_COPY_DATA = ord("d")
59
+ T_COPY_DONE = ord("c")
60
+ T_COPY_FAIL = ord("f")
61
+ T_CANCEL_REQUEST = ord("K")
62
+ T_TERMINATE = ord("X")
63
+
64
+ # Backend (server -> client) type bytes.
65
+ B_AUTH = ord("R")
66
+ B_BACKEND_KEY = ord("K")
67
+ B_READY = ord("Z")
68
+ B_COMMAND_COMPLETE = ord("C")
69
+ B_ERROR = ord("E")
70
+ B_ROW_DESCRIPTION = ord("T")
71
+ B_ROW_DESCRIPTION_TYPED = ord("y") # protocol 1.1 (typed columns)
72
+ B_DATA_ROW = ord("D")
73
+ B_PARSE_COMPLETE = ord("1")
74
+ B_BIND_COMPLETE = ord("2")
75
+ B_CLOSE_COMPLETE = ord("3")
76
+ B_PARAMETER_DESCRIPTION = ord("t")
77
+ B_NO_DATA = ord("n")
78
+ B_PORTAL_SUSPENDED = ord("z")
79
+ B_COPY_IN = ord("G")
80
+ B_COPY_OUT = ord("H")
81
+ B_COPY_DATA = ord("d")
82
+ B_COPY_DONE = ord("c")
83
+ B_NOTIFICATION_RESPONSE = ord("A") # async LISTEN/NOTIFY: [pid:u32][channel:str][payload:str]
84
+
85
+ # Authentication sub-codes (the leading u32 of an `R` message).
86
+ AUTH_OK = 0
87
+ AUTH_SASL = 10
88
+ AUTH_SASL_CONTINUE = 11
89
+ AUTH_SASL_FINAL = 12
90
+
91
+
92
+ class Writer:
93
+ """Accumulates a message payload with the protocol's primitive encodings."""
94
+
95
+ __slots__ = ("_buf",)
96
+
97
+ def __init__(self) -> None:
98
+ self._buf = bytearray()
99
+
100
+ def u8(self, value: int) -> "Writer":
101
+ self._buf += struct.pack(">B", value)
102
+ return self
103
+
104
+ def u16(self, value: int) -> "Writer":
105
+ self._buf += struct.pack(">H", value)
106
+ return self
107
+
108
+ def u32(self, value: int) -> "Writer":
109
+ self._buf += struct.pack(">I", value)
110
+ return self
111
+
112
+ def raw(self, data: bytes) -> "Writer":
113
+ self._buf += data
114
+ return self
115
+
116
+ def string(self, text: str) -> "Writer":
117
+ encoded = text.encode("utf-8")
118
+ self.u32(len(encoded))
119
+ self._buf += encoded
120
+ return self
121
+
122
+ def fields(self, values: List[Optional[bytes]]) -> "Writer":
123
+ """Encode a `Fields` list (DataRow / Bind params)."""
124
+ self.u16(len(values))
125
+ for value in values:
126
+ if value is None:
127
+ self.u8(0)
128
+ else:
129
+ self.u8(1)
130
+ self.u32(len(value))
131
+ self._buf += value
132
+ return self
133
+
134
+ def frame(self, message_type: int) -> bytes:
135
+ """Wrap the payload in a frame header: ``[type][len][payload]``."""
136
+ total = len(self._buf) + HEADER_LEN
137
+ return struct.pack(">BI", message_type, total) + bytes(self._buf)
138
+
139
+
140
+ class Reader:
141
+ """Reads the protocol's primitive encodings from a payload buffer."""
142
+
143
+ __slots__ = ("_buf", "_pos")
144
+
145
+ def __init__(self, payload: bytes) -> None:
146
+ self._buf = payload
147
+ self._pos = 0
148
+
149
+ def remaining(self) -> int:
150
+ return len(self._buf) - self._pos
151
+
152
+ def _take(self, n: int) -> bytes:
153
+ if self._pos + n > len(self._buf):
154
+ raise ValueError("truncated payload")
155
+ chunk = self._buf[self._pos : self._pos + n]
156
+ self._pos += n
157
+ return chunk
158
+
159
+ def u8(self) -> int:
160
+ return struct.unpack(">B", self._take(1))[0]
161
+
162
+ def u16(self) -> int:
163
+ return struct.unpack(">H", self._take(2))[0]
164
+
165
+ def u32(self) -> int:
166
+ return struct.unpack(">I", self._take(4))[0]
167
+
168
+ def string(self) -> str:
169
+ n = self.u32()
170
+ return self._take(n).decode("utf-8")
171
+
172
+ def rest(self) -> bytes:
173
+ chunk = self._buf[self._pos :]
174
+ self._pos = len(self._buf)
175
+ return chunk
176
+
177
+ def fields(self) -> List[Optional[bytes]]:
178
+ count = self.u16()
179
+ out: List[Optional[bytes]] = []
180
+ for _ in range(count):
181
+ present = self.u8()
182
+ if present == 0:
183
+ out.append(None)
184
+ else:
185
+ n = self.u32()
186
+ out.append(self._take(n))
187
+ return out
188
+
189
+
190
+ # --- frontend message builders (return wire-ready frame bytes) ---
191
+
192
+
193
+ def startup(user: str, database: str) -> bytes:
194
+ return (
195
+ Writer()
196
+ .u32(PROTOCOL_MAGIC)
197
+ .u16(PROTOCOL_VERSION[0])
198
+ .u16(PROTOCOL_VERSION[1])
199
+ .string(user)
200
+ .string(database)
201
+ .frame(T_STARTUP)
202
+ )
203
+
204
+
205
+ def query(sql: str) -> bytes:
206
+ return Writer().string(sql).frame(T_QUERY)
207
+
208
+
209
+ def parse(name: str, sql: str) -> bytes:
210
+ return Writer().string(name).string(sql).u16(0).frame(T_PARSE)
211
+
212
+
213
+ def bind(portal: str, statement: str, params: List[Optional[bytes]]) -> bytes:
214
+ return (
215
+ Writer()
216
+ .string(portal)
217
+ .string(statement)
218
+ .fields(params)
219
+ .u16(0) # result format count: empty => all text
220
+ .frame(T_BIND)
221
+ )
222
+
223
+
224
+ def describe_portal(name: str) -> bytes:
225
+ return Writer().u8(ord("P")).string(name).frame(T_DESCRIBE)
226
+
227
+
228
+ def execute(portal: str, max_rows: int = 0) -> bytes:
229
+ return Writer().string(portal).u32(max_rows).frame(T_EXECUTE)
230
+
231
+
232
+ def sync() -> bytes:
233
+ return Writer().frame(T_SYNC)
234
+
235
+
236
+ def terminate() -> bytes:
237
+ return Writer().frame(T_TERMINATE)
238
+
239
+
240
+ def sasl_initial(mechanism: str, data: bytes) -> bytes:
241
+ return Writer().string(mechanism).u32(len(data)).raw(data).frame(T_SASL_INITIAL)
242
+
243
+
244
+ def sasl_response(data: bytes) -> bytes:
245
+ return Writer().raw(data).frame(T_SASL_RESPONSE)
246
+
247
+
248
+ def cancel_request(pid: int, secret: int) -> bytes:
249
+ return Writer().u32(pid).u32(secret).frame(T_CANCEL_REQUEST)
250
+
251
+
252
+ def copy_data(data: bytes) -> bytes:
253
+ return Writer().raw(data).frame(T_COPY_DATA)
254
+
255
+
256
+ def copy_done() -> bytes:
257
+ return Writer().frame(T_COPY_DONE)
258
+
259
+
260
+ def copy_fail(message: str) -> bytes:
261
+ return Writer().string(message).frame(T_COPY_FAIL)
262
+
263
+
264
+ def decode_frame_header(header: bytes) -> Tuple[int, int]:
265
+ """Return ``(message_type, payload_len)`` from a 5-byte frame header."""
266
+ message_type, total = struct.unpack(">BI", header)
267
+ if total < HEADER_LEN:
268
+ raise ValueError(f"malformed frame length {total}")
269
+ if total > MAX_FRAME_LEN:
270
+ raise ValueError(f"frame too large: {total}")
271
+ return message_type, total - HEADER_LEN
nusadb/_scram.py ADDED
@@ -0,0 +1,76 @@
1
+ """Client-side SCRAM-SHA-256 (RFC 5802 / RFC 7677) for the Nusa Wire Protocol.
2
+
3
+ Stdlib only (``hashlib``/``hmac``/``os``/``base64``). Implements the four-step
4
+ exchange of ``docs/wire-protocol.md`` §7.2 and verifies the server's signature
5
+ (mutual authentication).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import os
14
+ from typing import Tuple
15
+
16
+ _GS2_HEADER = "n,,"
17
+ # base64("n,,") — the channel-binding value `c=` in client-final.
18
+ _CHANNEL_BINDING = base64.b64encode(_GS2_HEADER.encode("ascii")).decode("ascii")
19
+
20
+
21
+ def _hmac(key: bytes, msg: bytes) -> bytes:
22
+ return hmac.new(key, msg, hashlib.sha256).digest()
23
+
24
+
25
+ def _xor(a: bytes, b: bytes) -> bytes:
26
+ return bytes(x ^ y for x, y in zip(a, b))
27
+
28
+
29
+ def client_first(user: str) -> Tuple[str, str]:
30
+ """Return ``(client_first_bare, full_client_first)`` with a fresh nonce."""
31
+ nonce = base64.b64encode(os.urandom(18)).decode("ascii")
32
+ bare = f"n={user},r={nonce}"
33
+ return bare, _GS2_HEADER + bare
34
+
35
+
36
+ def _parse_server_first(message: str) -> Tuple[str, bytes, int]:
37
+ """Parse ``r=<nonce>,s=<base64 salt>,i=<iterations>``."""
38
+ parts = dict(field.split("=", 1) for field in message.split(","))
39
+ combined_nonce = parts["r"]
40
+ salt = base64.b64decode(parts["s"])
41
+ iterations = int(parts["i"])
42
+ return combined_nonce, salt, iterations
43
+
44
+
45
+ def client_final(
46
+ password: str,
47
+ client_first_bare: str,
48
+ server_first: str,
49
+ ) -> Tuple[bytes, str]:
50
+ """Build the client-final message and the cached state to verify the server.
51
+
52
+ Returns ``(client_final_bytes, server_signature_b64_expected)``.
53
+ """
54
+ combined_nonce, salt, iterations = _parse_server_first(server_first)
55
+
56
+ salted = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
57
+ client_key = _hmac(salted, b"Client Key")
58
+ stored_key = hashlib.sha256(client_key).digest()
59
+
60
+ without_proof = f"c={_CHANNEL_BINDING},r={combined_nonce}"
61
+ auth_message = f"{client_first_bare},{server_first},{without_proof}".encode("utf-8")
62
+ client_signature = _hmac(stored_key, auth_message)
63
+ proof = _xor(client_key, client_signature)
64
+ final = f"{without_proof},p={base64.b64encode(proof).decode('ascii')}"
65
+
66
+ server_key = _hmac(salted, b"Server Key")
67
+ server_signature = _hmac(server_key, auth_message)
68
+ expected = base64.b64encode(server_signature).decode("ascii")
69
+ return final.encode("utf-8"), expected
70
+
71
+
72
+ def verify_server_final(server_final: str, expected_signature_b64: str) -> bool:
73
+ """Check the server-final ``v=<signature>`` against the expected value."""
74
+ parts = dict(field.split("=", 1) for field in server_final.split(","))
75
+ got = parts.get("v", "")
76
+ return hmac.compare_digest(got, expected_signature_b64)