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.
@@ -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
@@ -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: []