nusadb 0.1.0
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.
- checksums.yaml +7 -0
- data/README.md +144 -0
- data/lib/active_record/connection_adapters/nusadb_adapter.rb +355 -0
- data/lib/nusadb/connection.rb +537 -0
- data/lib/nusadb/protocol.rb +210 -0
- data/lib/nusadb/scram.rb +82 -0
- data/lib/nusadb.rb +23 -0
- metadata +52 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NusaDB
|
|
4
|
+
# Low-level Nusa Wire Protocol codec (docs/wire-protocol.md, PROTOCOL_VERSION 1.1).
|
|
5
|
+
#
|
|
6
|
+
# A frame is [type:u8][len:u32][payload]; len is the total including the 5-byte header.
|
|
7
|
+
# Big-endian throughout; strings are [len:u32][utf8 bytes], not null-terminated.
|
|
8
|
+
module Protocol
|
|
9
|
+
MAGIC = 0x4E55_5341 # "NUSA"
|
|
10
|
+
MAJOR = 1
|
|
11
|
+
# Request minor 1 to receive the typed RowDescription (per-column type tags). A 1.0 server
|
|
12
|
+
# ignores it and answers with the classic untyped form, which the driver still handles.
|
|
13
|
+
MINOR = 1
|
|
14
|
+
HEADER_LEN = 5
|
|
15
|
+
MAX_FRAME_LEN = 256 * 1024 * 1024
|
|
16
|
+
SCRAM_MECHANISM = 'SCRAM-SHA-256'
|
|
17
|
+
|
|
18
|
+
# Frontend type bytes.
|
|
19
|
+
T_STARTUP = 'S'.ord
|
|
20
|
+
T_QUERY = 'Q'.ord
|
|
21
|
+
T_PARSE = 'P'.ord
|
|
22
|
+
T_BIND = 'B'.ord
|
|
23
|
+
T_DESCRIBE = 'D'.ord
|
|
24
|
+
T_EXECUTE = 'E'.ord
|
|
25
|
+
T_SYNC = 'Y'.ord
|
|
26
|
+
T_SASL_INITIAL = 'p'.ord
|
|
27
|
+
T_SASL_RESPONSE = 'r'.ord
|
|
28
|
+
T_TERMINATE = 'X'.ord
|
|
29
|
+
T_COPY_DATA = 'd'.ord # COPY ... FROM STDIN data chunk (§12.1)
|
|
30
|
+
T_COPY_DONE = 'c'.ord # end of the client's COPY data stream
|
|
31
|
+
T_COPY_FAIL = 'f'.ord # abort the in-progress COPY: [message:Str]
|
|
32
|
+
|
|
33
|
+
# Backend type bytes.
|
|
34
|
+
B_AUTH = 'R'.ord
|
|
35
|
+
B_BACKEND_KEY = 'K'.ord
|
|
36
|
+
B_READY = 'Z'.ord
|
|
37
|
+
B_COMMAND_COMPLETE = 'C'.ord
|
|
38
|
+
B_ERROR = 'E'.ord
|
|
39
|
+
B_ROW_DESCRIPTION = 'T'.ord
|
|
40
|
+
B_ROW_DESCRIPTION_TYPED = 'y'.ord # protocol 1.1 (typed columns)
|
|
41
|
+
B_DATA_ROW = 'D'.ord
|
|
42
|
+
B_COPY_IN = 'G'.ord # CopyInResponse (§12.1)
|
|
43
|
+
B_COPY_OUT = 'H'.ord # CopyOutResponse (§12.2)
|
|
44
|
+
B_COPY_DATA = 'd'.ord # a COPY data chunk
|
|
45
|
+
B_COPY_DONE = 'c'.ord # end of the COPY data stream
|
|
46
|
+
B_NOTIFICATION = 'A'.ord # async LISTEN/NOTIFY: [pid:u32][channel:str][payload:str]
|
|
47
|
+
|
|
48
|
+
# Column type tags carried by RowDescriptionTyped (protocol 1.1, wire-protocol.md §9.2). Maps the
|
|
49
|
+
# 1-byte tag to a canonical type name; an unknown/0x00 tag is UNKNOWN (treated as text).
|
|
50
|
+
TYPE_TAGS = {
|
|
51
|
+
0x00 => 'UNKNOWN', 0x01 => 'BOOL', 0x02 => 'INT', 0x03 => 'FLOAT', 0x04 => 'NUMERIC',
|
|
52
|
+
0x05 => 'TEXT', 0x06 => 'BYTES', 0x07 => 'DATE', 0x08 => 'TIME', 0x09 => 'TIMETZ',
|
|
53
|
+
0x0A => 'TIMESTAMP', 0x0B => 'TIMESTAMPTZ', 0x0C => 'INTERVAL', 0x0D => 'UUID',
|
|
54
|
+
0x0E => 'JSON', 0x0F => 'ARRAY', 0x10 => 'VECTOR'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# Canonical type name for a RowDescriptionTyped type tag (UNKNOWN if unrecognised).
|
|
58
|
+
def self.type_name(tag)
|
|
59
|
+
TYPE_TAGS.fetch(tag, 'UNKNOWN')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Auth sub-codes.
|
|
63
|
+
AUTH_OK = 0
|
|
64
|
+
AUTH_SASL = 10
|
|
65
|
+
AUTH_SASL_CONTINUE = 11
|
|
66
|
+
AUTH_SASL_FINAL = 12
|
|
67
|
+
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
def u16(value)
|
|
71
|
+
[value].pack('n')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def u32(value)
|
|
75
|
+
[value].pack('N')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def str(value)
|
|
79
|
+
bytes = value.to_s.dup.force_encoding(Encoding::BINARY)
|
|
80
|
+
u32(bytes.bytesize) + bytes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Encode a Fields list: [count:u16] then per field present byte + optional [len][bytes].
|
|
84
|
+
def fields(values)
|
|
85
|
+
out = u16(values.length)
|
|
86
|
+
values.each do |v|
|
|
87
|
+
if v.nil?
|
|
88
|
+
out += "\x00".b
|
|
89
|
+
else
|
|
90
|
+
v = v.dup.force_encoding(Encoding::BINARY)
|
|
91
|
+
out += "\x01".b + u32(v.bytesize) + v
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
out
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def frame(type, payload)
|
|
98
|
+
total = payload.bytesize + HEADER_LEN
|
|
99
|
+
(type.chr + u32(total) + payload).force_encoding(Encoding::BINARY)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def startup(user, database)
|
|
103
|
+
payload = u32(MAGIC) + u16(MAJOR) + u16(MINOR) + str(user) + str(database)
|
|
104
|
+
frame(T_STARTUP, payload)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def query(sql)
|
|
108
|
+
frame(T_QUERY, str(sql))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse(name, sql)
|
|
112
|
+
frame(T_PARSE, str(name) + str(sql) + u16(0))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def bind(portal, statement, values)
|
|
116
|
+
frame(T_BIND, str(portal) + str(statement) + fields(values) + u16(0))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def describe_portal(name)
|
|
120
|
+
frame(T_DESCRIBE, 'P'.b + str(name))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def execute(portal, max_rows = 0)
|
|
124
|
+
frame(T_EXECUTE, str(portal) + u32(max_rows))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def sync
|
|
128
|
+
frame(T_SYNC, ''.b)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def terminate
|
|
132
|
+
frame(T_TERMINATE, ''.b)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def sasl_initial(mechanism, data)
|
|
136
|
+
frame(T_SASL_INITIAL, str(mechanism) + u32(data.bytesize) + data)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def sasl_response(data)
|
|
140
|
+
frame(T_SASL_RESPONSE, data)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def copy_data(data)
|
|
144
|
+
frame(T_COPY_DATA, data)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def copy_done
|
|
148
|
+
frame(T_COPY_DONE, ''.b)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def copy_fail(message)
|
|
152
|
+
frame(T_COPY_FAIL, str(message))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Reads the protocol's primitive encodings from a payload buffer.
|
|
157
|
+
class Reader
|
|
158
|
+
def initialize(buf)
|
|
159
|
+
@buf = buf
|
|
160
|
+
@pos = 0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def u8
|
|
164
|
+
v = @buf.getbyte(@pos)
|
|
165
|
+
@pos += 1
|
|
166
|
+
v
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def u16
|
|
170
|
+
v = @buf.byteslice(@pos, 2).unpack1('n')
|
|
171
|
+
@pos += 2
|
|
172
|
+
v
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def u32
|
|
176
|
+
v = @buf.byteslice(@pos, 4).unpack1('N')
|
|
177
|
+
@pos += 4
|
|
178
|
+
v
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def str
|
|
182
|
+
n = u32
|
|
183
|
+
s = @buf.byteslice(@pos, n)
|
|
184
|
+
@pos += n
|
|
185
|
+
s.force_encoding(Encoding::UTF_8)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def rest
|
|
189
|
+
s = @buf.byteslice(@pos, @buf.bytesize - @pos)
|
|
190
|
+
@pos = @buf.bytesize
|
|
191
|
+
s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Decode a Fields list; a nil entry is SQL NULL.
|
|
195
|
+
def fields
|
|
196
|
+
n = u16
|
|
197
|
+
out = []
|
|
198
|
+
n.times do
|
|
199
|
+
if u8.zero?
|
|
200
|
+
out << nil
|
|
201
|
+
else
|
|
202
|
+
len = u32
|
|
203
|
+
out << @buf.byteslice(@pos, len)
|
|
204
|
+
@pos += len
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
out
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/nusadb/scram.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module NusaDB
|
|
7
|
+
# Client-side SCRAM-SHA-256 (RFC 5802 / RFC 7677), docs/wire-protocol.md §7.2.
|
|
8
|
+
# Uses Ruby's bundled OpenSSL / SecureRandom — no gems.
|
|
9
|
+
#
|
|
10
|
+
# Base64 goes through Array#pack / String#unpack1 rather than the `base64` library, which
|
|
11
|
+
# `strict_encode64` / `strict_decode64` are themselves thin wrappers over ("m0" is strict:
|
|
12
|
+
# no line breaks, and decoding rejects malformed input with ArgumentError). Ruby 3.4 demoted
|
|
13
|
+
# `base64` from a default gem to a bundled one, so `require 'base64'` raises LoadError under
|
|
14
|
+
# Bundler unless the app declares it — which every Rails app using the ActiveRecord adapter
|
|
15
|
+
# would have to. The core methods keep the gem genuinely dependency-free.
|
|
16
|
+
class Scram
|
|
17
|
+
GS2_HEADER = 'n,,'
|
|
18
|
+
CHANNEL_BINDING = [GS2_HEADER].pack('m0')
|
|
19
|
+
|
|
20
|
+
attr_reader :client_first_bare, :full_client_first
|
|
21
|
+
|
|
22
|
+
def initialize(user)
|
|
23
|
+
nonce = [SecureRandom.random_bytes(18)].pack('m0')
|
|
24
|
+
@client_first_bare = "n=#{user},r=#{nonce}"
|
|
25
|
+
@full_client_first = GS2_HEADER + @client_first_bare
|
|
26
|
+
@expected_signature = ''
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client_final(password, server_first)
|
|
30
|
+
combined_nonce = nil
|
|
31
|
+
salt = nil
|
|
32
|
+
iterations = 0
|
|
33
|
+
server_first.split(',').each do |field|
|
|
34
|
+
key, _, value = field.partition('=')
|
|
35
|
+
next if value.nil? || key.empty?
|
|
36
|
+
|
|
37
|
+
case key
|
|
38
|
+
when 'r' then combined_nonce = value
|
|
39
|
+
when 's' then salt = value.unpack1('m0')
|
|
40
|
+
when 'i' then iterations = value.to_i
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
if combined_nonce.nil? || salt.nil? || iterations <= 0
|
|
44
|
+
raise NusaError.new('nusadb: malformed server-first message', '08P01')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
salted = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, 32, OpenSSL::Digest::SHA256.new)
|
|
48
|
+
client_key = hmac(salted, 'Client Key')
|
|
49
|
+
stored_key = OpenSSL::Digest::SHA256.digest(client_key)
|
|
50
|
+
|
|
51
|
+
without_proof = "c=#{CHANNEL_BINDING},r=#{combined_nonce}"
|
|
52
|
+
auth_message = "#{@client_first_bare},#{server_first},#{without_proof}"
|
|
53
|
+
client_sig = hmac(stored_key, auth_message)
|
|
54
|
+
proof = xor(client_key, client_sig)
|
|
55
|
+
final = "#{without_proof},p=#{[proof].pack('m0')}"
|
|
56
|
+
|
|
57
|
+
server_key = hmac(salted, 'Server Key')
|
|
58
|
+
@expected_signature = [hmac(server_key, auth_message)].pack('m0')
|
|
59
|
+
final
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def verify_server_final(server_final)
|
|
63
|
+
got = ''
|
|
64
|
+
server_final.split(',').each do |field|
|
|
65
|
+
got = field[2..] if field.start_with?('v=')
|
|
66
|
+
end
|
|
67
|
+
OpenSSL.fixed_length_secure_compare(got, @expected_signature)
|
|
68
|
+
rescue ArgumentError
|
|
69
|
+
false # lengths differ
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def hmac(key, msg)
|
|
75
|
+
OpenSSL::HMAC.digest('SHA256', key, msg)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def xor(a, b)
|
|
79
|
+
a.bytes.zip(b.bytes).map { |x, y| x ^ y }.pack('C*')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/nusadb.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'nusadb/protocol'
|
|
4
|
+
require_relative 'nusadb/scram'
|
|
5
|
+
require_relative 'nusadb/connection'
|
|
6
|
+
|
|
7
|
+
# NusaDB — pure-Ruby client for the Nusa Wire Protocol (PROTOCOL_VERSION 1.1).
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
#
|
|
11
|
+
# conn = NusaDB.connect(host: '127.0.0.1', port: 5678, user: 'nusa-root', database: 'nusadb')
|
|
12
|
+
# conn.query('CREATE TABLE t (id INT NOT NULL, name TEXT)')
|
|
13
|
+
# conn.execute('INSERT INTO t VALUES ($1, $2)', [1, 'alice'])
|
|
14
|
+
# conn.query('SELECT id, name FROM t WHERE id = $1', [1]).each { |row| p row }
|
|
15
|
+
# conn.close
|
|
16
|
+
module NusaDB
|
|
17
|
+
VERSION = '0.1.0'
|
|
18
|
+
|
|
19
|
+
# Open a connection (see Connection#initialize for options).
|
|
20
|
+
def self.connect(**options)
|
|
21
|
+
Connection.new(**options)
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nusadb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- NusaDB Authors
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A pure-Ruby driver that speaks the Nusa Wire Protocol directly over a
|
|
14
|
+
socket.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/active_record/connection_adapters/nusadb_adapter.rb
|
|
22
|
+
- lib/nusadb.rb
|
|
23
|
+
- lib/nusadb/connection.rb
|
|
24
|
+
- lib/nusadb/protocol.rb
|
|
25
|
+
- lib/nusadb/scram.rb
|
|
26
|
+
homepage: https://github.com/nusadb/ruby
|
|
27
|
+
licenses:
|
|
28
|
+
- Apache-2.0
|
|
29
|
+
metadata:
|
|
30
|
+
homepage_uri: https://github.com/nusadb/ruby
|
|
31
|
+
source_code_uri: https://github.com/nusadb/ruby
|
|
32
|
+
bug_tracker_uri: https://github.com/nusadb/ruby/issues
|
|
33
|
+
post_install_message:
|
|
34
|
+
rdoc_options: []
|
|
35
|
+
require_paths:
|
|
36
|
+
- lib
|
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '2.7'
|
|
42
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
requirements: []
|
|
48
|
+
rubygems_version: 3.3.27
|
|
49
|
+
signing_key:
|
|
50
|
+
specification_version: 4
|
|
51
|
+
summary: Pure-Ruby client for NusaDB (Nusa Wire Protocol)
|
|
52
|
+
test_files: []
|