feat(sdk): add Java and Ruby SDK wrappers over C FFI

Java SDK: JNI bindings to libquicproquo_ffi with QpqClient class,
Gradle build, and exception hierarchy matching Kotlin SDK.

Ruby SDK: FFI gem wrapping libquicproquo_ffi with Client class,
block-form auto-disconnect, gemspec for RubyGems publishing,
and example script.
This commit is contained in:
2026-03-04 21:00:20 +01:00
parent 3f5a3a5ac8
commit 12717979ba
15 changed files with 619 additions and 0 deletions

82
sdks/ruby/README.md Normal file
View File

@@ -0,0 +1,82 @@
# QuicProQuo Ruby SDK
Ruby FFI gem wrapping `libquicproquo_ffi` for the quicproquo E2E encrypted messenger.
## Prerequisites
- Ruby 3.1+
- `libquicproquo_ffi` built for the target platform
## Installation
```sh
gem install quicproquo
```
Or add to your Gemfile:
```ruby
gem "quicproquo"
```
## Building the Native Library
```sh
cargo build --release -p quicproquo-ffi
```
Set `QPQ_LIB_PATH` if the library is not in the default search path.
## Usage
```ruby
require "quicproquo"
# Block form (auto-disconnect)
QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |client|
client.login("alice", "secret")
client.send("bob", "hello from Ruby!")
messages = client.receive(timeout_ms: 5000)
messages.each { |msg| puts msg }
end
# Manual lifecycle
client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem")
client.login("alice", "secret")
client.send("bob", "hello")
client.disconnect
```
## API
| Method | Description |
|---|---|
| `Client.new(server, ca_cert:, server_name:)` | Connect to server |
| `Client.open(server, **opts) { \|c\| ... }` | Connect with auto-disconnect |
| `client.login(username, password)` | OPAQUE authentication |
| `client.send(recipient, message)` | Send message by username |
| `client.receive(timeout_ms: 5000)` | Receive pending messages |
| `client.disconnect` | Disconnect |
| `client.connected?` | Connection status |
## Error Handling
```ruby
begin
client.login("alice", "wrong")
rescue QuicProQuo::AuthError => e
puts "Auth failed: #{e.message}"
rescue QuicProQuo::TimeoutError => e
puts "Timeout: #{e.message}"
rescue QuicProQuo::Error => e
puts "Error: #{e.message}"
end
```
## Structure
- `lib/quicproquo/client.rb` -- High-level client
- `lib/quicproquo/ffi_bindings.rb` -- FFI function declarations
- `lib/quicproquo/errors.rb` -- Exception classes
- `examples/demo.rb` -- Usage example

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Example: quicproquo Ruby SDK demo.
#
# Usage:
# ruby demo.rb --server 127.0.0.1:5001 --ca-cert ca.pem \
# --user alice --pass secret --recipient bob --message "hello"
require "optparse"
require_relative "../lib/quicproquo"
options = {
server: "127.0.0.1:5001",
ca_cert: "ca.pem",
message: "hello from Ruby SDK!",
}
OptionParser.new do |opts|
opts.banner = "Usage: demo.rb [options]"
opts.on("--server ADDR", "Server address") { |v| options[:server] = v }
opts.on("--ca-cert PATH", "CA certificate") { |v| options[:ca_cert] = v }
opts.on("--user NAME", "Username") { |v| options[:user] = v }
opts.on("--pass PASSWORD", "Password") { |v| options[:pass] = v }
opts.on("--recipient NAME", "Recipient") { |v| options[:recipient] = v }
opts.on("--message TEXT", "Message") { |v| options[:message] = v }
end.parse!
QuicProQuo::Client.open(options[:server], ca_cert: options[:ca_cert]) do |client|
puts "Connected to #{options[:server]}"
client.login(options[:user], options[:pass])
puts "Logged in as #{options[:user]}"
client.send(options[:recipient], options[:message])
puts "Sent message to #{options[:recipient]}"
puts "Waiting for messages (5s)..."
messages = client.receive(timeout_ms: 5000)
messages.each { |msg| puts " received: #{msg}" }
puts "Done."
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
require_relative "quicproquo/ffi_bindings"
require_relative "quicproquo/client"
require_relative "quicproquo/errors"
require_relative "quicproquo/version"
# Ruby SDK for the quicproquo E2E encrypted messenger.
#
# Two usage patterns:
#
# # Block form (auto-disconnect)
# QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |client|
# client.login("alice", "secret")
# client.send("bob", "hello")
# end
#
# # Manual lifecycle
# client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem")
# client.login("alice", "secret")
# client.disconnect
module QuicProQuo
end

View File

@@ -0,0 +1,122 @@
# frozen_string_literal: true
require "json"
module QuicProQuo
# High-level quicproquo client for Ruby.
#
# Wraps +libquicproquo_ffi+ via the +ffi+ gem.
#
# client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem")
# client.login("alice", "secret")
# client.send("bob", "hello")
# messages = client.receive(timeout_ms: 5000)
# client.disconnect
#
# Or use the block form for automatic cleanup:
#
# QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |c|
# c.login("alice", "secret")
# c.send("bob", "hello")
# end
class Client
# Connect to a quicproquo server.
#
# @param server [String] Server address as +host:port+.
# @param ca_cert [String] Path to PEM-encoded CA certificate.
# @param server_name [String] TLS SNI server name (defaults to host).
# @raise [ConnectionError] if the connection fails.
def initialize(server, ca_cert:, server_name: nil)
sn = server_name || server.split(":").first
@handle = FFIBindings.qpq_connect(server, ca_cert, sn)
raise ConnectionError, "qpq_connect failed for #{server}" if @handle.null?
end
# Connect and yield the client, disconnecting when the block returns.
def self.open(server, **opts)
client = new(server, **opts)
begin
yield client
ensure
client.disconnect
end
end
# Authenticate with OPAQUE credentials.
#
# @param username [String]
# @param password [String]
# @raise [AuthError] on bad credentials.
def login(username, password)
check_connected!
code = FFIBindings.qpq_login(@handle, username, password)
check_status!(code)
end
# Send a message to a recipient by username.
#
# @param recipient [String] Recipient username.
# @param message [String] Message payload (UTF-8 string or binary).
def send(recipient, message)
check_connected!
msg_bytes = message.encode("BINARY")
buf = FFI::MemoryPointer.new(:uint8, msg_bytes.bytesize)
buf.put_bytes(0, msg_bytes)
code = FFIBindings.qpq_send(@handle, recipient, buf, msg_bytes.bytesize)
check_status!(code)
end
# Receive pending messages, blocking up to +timeout_ms+ milliseconds.
#
# @param timeout_ms [Integer] Timeout in milliseconds (default 5000).
# @return [Array<String>] Message strings (UTF-8).
def receive(timeout_ms: 5000)
check_connected!
out_ptr = FFI::MemoryPointer.new(:pointer)
code = FFIBindings.qpq_receive(@handle, timeout_ms, out_ptr)
check_status!(code)
json_ptr = out_ptr.read_pointer
return [] if json_ptr.null?
json_str = json_ptr.read_string
FFIBindings.qpq_free_string(json_ptr)
JSON.parse(json_str)
end
# Disconnect from the server and release resources.
def disconnect
return if @handle.nil? || @handle.null?
FFIBindings.qpq_disconnect(@handle)
@handle = nil
end
# Whether the client is connected.
def connected?
!@handle.nil? && !@handle.null?
end
private
def check_connected!
raise NotConnectedError, "not connected" unless connected?
end
def check_status!(code)
return if code == FFIBindings::QPQ_OK
msg = FFIBindings.qpq_last_error(@handle) || "unknown error"
case code
when FFIBindings::QPQ_AUTH_FAILED
raise AuthError, msg
when FFIBindings::QPQ_TIMEOUT
raise TimeoutError, msg
when FFIBindings::QPQ_NOT_CONNECTED
raise NotConnectedError, msg
else
raise Error, msg
end
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module QuicProQuo
# Base error for quicproquo SDK.
class Error < StandardError; end
# OPAQUE authentication failed (bad credentials).
class AuthError < Error; end
# Operation timed out.
class TimeoutError < Error; end
# Client is not connected.
class NotConnectedError < Error; end
# Connection to the server failed.
class ConnectionError < Error; end
end

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
require "ffi"
module QuicProQuo
# Low-level FFI bindings to libquicproquo_ffi.
#
# The library is located via:
# 1. QPQ_LIB_PATH environment variable
# 2. Common cargo build output paths
# 3. System library path
module FFIBindings
extend FFI::Library
# Status codes (mirrors crates/quicproquo-ffi/src/lib.rs).
QPQ_OK = 0
QPQ_ERROR = 1
QPQ_AUTH_FAILED = 2
QPQ_TIMEOUT = 3
QPQ_NOT_CONNECTED = 4
# Locate and load the shared library.
LIB_SEARCH_PATHS = [
ENV.fetch("QPQ_LIB_PATH", nil),
File.expand_path("../../../../target/release/libquicproquo_ffi.so", __dir__),
File.expand_path("../../../../target/debug/libquicproquo_ffi.so", __dir__),
File.expand_path("../../../../target/release/libquicproquo_ffi.dylib", __dir__),
File.expand_path("../../../../target/debug/libquicproquo_ffi.dylib", __dir__),
"quicproquo_ffi",
].compact.freeze
lib_path = LIB_SEARCH_PATHS.find { |p| File.exist?(p) } || "quicproquo_ffi"
ffi_lib lib_path
# QpqHandle* qpq_connect(const char*, const char*, const char*)
attach_function :qpq_connect, %i[string string string], :pointer
# int qpq_login(QpqHandle*, const char*, const char*)
attach_function :qpq_login, %i[pointer string string], :int
# int qpq_send(QpqHandle*, const char*, :pointer, size_t)
attach_function :qpq_send, %i[pointer string pointer size_t], :int
# int qpq_receive(QpqHandle*, uint32, :pointer)
attach_function :qpq_receive, %i[pointer uint32 pointer], :int
# void qpq_disconnect(QpqHandle*)
attach_function :qpq_disconnect, [:pointer], :void
# const char* qpq_last_error(const QpqHandle*)
attach_function :qpq_last_error, [:pointer], :string
# void qpq_free_string(char*)
attach_function :qpq_free_string, [:pointer], :void
end
end

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
module QuicProQuo
VERSION = "0.1.0"
end

View File

@@ -0,0 +1,20 @@
Gem::Specification.new do |s|
s.name = "quicproquo"
s.version = "0.1.0"
s.summary = "Ruby SDK for quicproquo E2E encrypted messenger"
s.description = "Ruby FFI bindings to libquicproquo_ffi for the quicproquo " \
"end-to-end encrypted messaging system."
s.authors = ["quicproquo contributors"]
s.license = "MIT"
s.homepage = "https://github.com/nicholasgasior/quicproquo"
s.required_ruby_version = ">= 3.1"
s.files = Dir["lib/**/*.rb", "README.md", "LICENSE"]
s.add_dependency "ffi", "~> 1.16"
s.add_dependency "json", "~> 2.7"
s.add_development_dependency "rspec", "~> 3.13"
s.add_development_dependency "rubocop", "~> 1.60"
end