Compare commits
5 Commits
management
...
b68325016d
Author | SHA1 | Date | |
---|---|---|---|
b68325016d | |||
2743cd9c81 | |||
eb07386cf4 | |||
fc7672c78a | |||
46f96fa8cf |
1506
Cargo.lock
generated
1506
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
38
Cargo.toml
@@ -1,31 +1,9 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "herodb"
|
members = ["herodb"]
|
||||||
version = "0.0.1"
|
resolver = "2"
|
||||||
authors = ["ThreeFold Tech NV"]
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
# You can define shared profiles for all workspace members here
|
||||||
anyhow = "1.0.59"
|
[profile.release]
|
||||||
bytes = "1.3.0"
|
lto = true
|
||||||
thiserror = "1.0.32"
|
codegen-units = 1
|
||||||
tokio = { version = "1.23.0", features = ["full"] }
|
strip = true
|
||||||
clap = { version = "4.5.20", features = ["derive"] }
|
|
||||||
byteorder = "1.4.3"
|
|
||||||
futures = "0.3"
|
|
||||||
sled = "0.34"
|
|
||||||
redb = "2.1.3"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
bincode = "1.3"
|
|
||||||
chacha20poly1305 = "0.10.1"
|
|
||||||
rand = "0.8"
|
|
||||||
sha2 = "0.10"
|
|
||||||
age = "0.10"
|
|
||||||
secrecy = "0.8"
|
|
||||||
ed25519-dalek = "2"
|
|
||||||
x25519-dalek = "2"
|
|
||||||
base64 = "0.22"
|
|
||||||
jsonrpsee = { version = "0.26.0", features = ["http-client", "ws-client", "server", "macros"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
redis = { version = "0.24", features = ["aio", "tokio-comp"] }
|
|
181
docs/admin.md
181
docs/admin.md
@@ -1,181 +0,0 @@
|
|||||||
# Admin Database 0 (`0.db`)
|
|
||||||
|
|
||||||
This page explains what the Admin Database `DB 0` is, why HeroDB uses it, and how to work with it as a developer and end-user. It’s a practical guide covering how databases are created, listed, secured with access keys, and encrypted using per-database secrets.
|
|
||||||
|
|
||||||
## What is `DB 0`?
|
|
||||||
|
|
||||||
`DB 0` is the control-plane for a HeroDB instance. It stores metadata for all user databases (`db_id >= 1`) so the server can:
|
|
||||||
- Know which databases exist (without scanning the filesystem)
|
|
||||||
- Enforce access control (public/private with access keys)
|
|
||||||
- Enforce per-database encryption (whether a given database must be opened encrypted and with which write-only key)
|
|
||||||
|
|
||||||
`DB 0` itself is always encrypted with the admin secret (the process-level secret provided at startup).
|
|
||||||
|
|
||||||
## How `DB 0` is created and secured
|
|
||||||
|
|
||||||
- `DB 0` lives at `<base_dir>/0.db`
|
|
||||||
- It is always encrypted using the `admin secret` provided at process startup (using the `--admin-secret <secret>` CLI flag)
|
|
||||||
- Only clients that provide the correct admin secret can `SELECT 0` (see “`SELECT` + `KEY`” below)
|
|
||||||
|
|
||||||
At startup, the server bootstraps `DB 0` (initializes counters and structures) if it’s missing.
|
|
||||||
|
|
||||||
## Metadata stored in `DB 0`
|
|
||||||
|
|
||||||
Keys in `DB 0` (internal layout, but useful to understand how things work):
|
|
||||||
|
|
||||||
- `admin:next_id`
|
|
||||||
- String counter holding the next id to allocate (initialized to `"1"`)
|
|
||||||
|
|
||||||
- `admin:dbs`
|
|
||||||
- A hash acting as a set of existing database ids
|
|
||||||
- field = id (as string), value = `"1"`
|
|
||||||
|
|
||||||
- `meta:db:<id>`
|
|
||||||
- A hash holding db-level metadata
|
|
||||||
- field `public` = `"true"` or `"false"` (defaults to `true` if missing)
|
|
||||||
|
|
||||||
- `meta:db:<id>:keys`
|
|
||||||
- A hash mapping access-key hashes to the string `Permission:created_at_seconds`
|
|
||||||
- Examples: `Read:1713456789` or `ReadWrite:1713456789`
|
|
||||||
- The plaintext access keys are never stored; only their `SHA-256` hashes are kept
|
|
||||||
|
|
||||||
- `meta:db:<id>:enc`
|
|
||||||
- A string holding the per-database encryption key used to open `<id>.db` encrypted
|
|
||||||
- This value is write-only from the perspective of the management APIs (it’s set at creation and never returned)
|
|
||||||
|
|
||||||
- `age:key:<name>`
|
|
||||||
- Base64-encoded X25519 recipient (public encryption key) for named AGE keys
|
|
||||||
- `age:privkey:<name>`
|
|
||||||
- Base64-encoded X25519 identity (secret encryption key) for named AGE keys
|
|
||||||
- `age:signpub:<name>`
|
|
||||||
- Base64-encoded Ed25519 verify public key for named AGE keys
|
|
||||||
- `age:signpriv:<name>`
|
|
||||||
- Base64-encoded Ed25519 signing secret key for named AGE keys
|
|
||||||
|
|
||||||
> You don’t need to manipulate these keys directly; they’re listed to clarify the model. AGE keys are managed via AGE commands.
|
|
||||||
|
|
||||||
## Database lifecycle
|
|
||||||
|
|
||||||
1) Create a database (via JSON-RPC)
|
|
||||||
- The server allocates an id from `admin:next_id`, registers it in `admin:dbs`, and defaults the database to `public=true`
|
|
||||||
- If you pass an optional `encryption_key` during creation, the server persists it in `meta:db:<id>:enc`. That database will be opened in encrypted mode from then on
|
|
||||||
|
|
||||||
2) Open and use a database
|
|
||||||
- Clients select a database over RESP using `SELECT`
|
|
||||||
- Authorization and encryption state are enforced using `DB 0` metadata
|
|
||||||
|
|
||||||
3) Delete database files
|
|
||||||
- Removing `<id>.db` removes the physical storage
|
|
||||||
- `DB 0` remains the source of truth for existence and may be updated by future management methods as the system evolves
|
|
||||||
|
|
||||||
## Access control model
|
|
||||||
|
|
||||||
- Public database (default)
|
|
||||||
- Anyone can `SELECT <id>` with no key, and will get `ReadWrite` permission
|
|
||||||
- Private database
|
|
||||||
- You must provide an access key when selecting the database
|
|
||||||
- The server hashes the provided key with `SHA-256` and checks membership in `meta:db:<id>:keys`
|
|
||||||
- Permissions are `Read` or `ReadWrite` depending on how the key was added
|
|
||||||
- Admin `DB 0`
|
|
||||||
- Requires the exact admin secret as the `KEY` argument to `SELECT 0`
|
|
||||||
- Permission is `ReadWrite` when the secret matches
|
|
||||||
|
|
||||||
### How to select databases with optional `KEY`
|
|
||||||
|
|
||||||
- Public DB (no key required)
|
|
||||||
- `SELECT <id>`
|
|
||||||
|
|
||||||
- Private DB (access key required)
|
|
||||||
- `SELECT <id> KEY <plaintext_key>`
|
|
||||||
|
|
||||||
- Admin `DB 0` (admin secret required)
|
|
||||||
- `SELECT 0 KEY <admin_secret>`
|
|
||||||
|
|
||||||
Examples (using `redis-cli`):
|
|
||||||
```bash
|
|
||||||
# Public database
|
|
||||||
redis-cli -p $PORT SELECT 1
|
|
||||||
# → OK
|
|
||||||
|
|
||||||
# Private database
|
|
||||||
redis-cli -p $PORT SELECT 2 KEY my-db2-access-key
|
|
||||||
# → OK
|
|
||||||
|
|
||||||
# Admin DB 0
|
|
||||||
redis-cli -p $PORT SELECT 0 KEY my-admin-secret
|
|
||||||
# → OK
|
|
||||||
```
|
|
||||||
|
|
||||||
## Per-database encryption
|
|
||||||
|
|
||||||
- At database creation, you can provide an optional per-db encryption key
|
|
||||||
- If provided, the server persists that key in `DB 0` as `meta:db:<id>:enc`
|
|
||||||
- When you later open the database, the engine checks whether `meta:db:<id>:enc` exists to decide if it must open `<id>.db` in encrypted mode
|
|
||||||
- The per-db key is not returned by RPC—it is considered write-only configuration data
|
|
||||||
|
|
||||||
Operationally:
|
|
||||||
- Create with encryption: pass a non-null `encryption_key` to the `createDatabase` RPC
|
|
||||||
- Open later: simply `SELECT` the database; encryption is transparent to clients
|
|
||||||
|
|
||||||
## Management via JSON-RPC
|
|
||||||
|
|
||||||
You can manage databases using the management RPC (namespaced `herodb.*`). Typical operations:
|
|
||||||
- `createDatabase(backend, config, encryption_key?)`
|
|
||||||
- Allocates a new id, sets optional encryption key
|
|
||||||
- `listDatabases()`
|
|
||||||
- Lists database ids and info (including whether storage is currently encrypted)
|
|
||||||
- `getDatabaseInfo(db_id)`
|
|
||||||
- Returns details: backend, encrypted flag, size on disk, `key_count`, timestamps, etc.
|
|
||||||
- `addAccessKey(db_id, key, permissions)`
|
|
||||||
- Adds a `Read` or `ReadWrite` access key (permissions = `"read"` | `"readwrite"`)
|
|
||||||
- `listAccessKeys(db_id)`
|
|
||||||
- Returns hashes and permissions; you can use these hashes to delete keys
|
|
||||||
- `deleteAccessKey(db_id, key_hash)`
|
|
||||||
- Removes a key by its hash
|
|
||||||
- `setDatabasePublic(db_id, public)`
|
|
||||||
- Toggles public/private
|
|
||||||
|
|
||||||
Copyable JSON examples are provided in the [RPC examples documentation](./rpc_examples.md).
|
|
||||||
|
|
||||||
## Typical flows
|
|
||||||
|
|
||||||
1) Public, unencrypted database
|
|
||||||
- Create a new database without an encryption key
|
|
||||||
- Clients can immediately `SELECT <id>` without a key
|
|
||||||
- You can later make it private and add keys if needed
|
|
||||||
|
|
||||||
2) Private, encrypted database
|
|
||||||
- Create passing an `encryption_key`
|
|
||||||
- Mark it private (`setDatabasePublic false`) and add access keys
|
|
||||||
- Clients must use `SELECT <id> KEY <plaintext_access_key>`
|
|
||||||
- Storage opens in encrypted mode automatically
|
|
||||||
|
|
||||||
## Security notes
|
|
||||||
|
|
||||||
- Only `SHA-256` hashes of access keys are stored in `DB 0`; keep plaintext keys safe on the client side
|
|
||||||
- The per-db encryption key is never exposed via the API after it is set
|
|
||||||
- The admin secret must be kept secure; anyone with it can `SELECT 0` and perform administrative actions
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- `ERR invalid access key` when selecting a private db
|
|
||||||
- Ensure you passed the `KEY` argument: `SELECT <id> KEY <plaintext_key>`
|
|
||||||
- If you recently added the key, confirm the permissions and that you used the exact plaintext (hash must match)
|
|
||||||
|
|
||||||
- `Database X not found`
|
|
||||||
- The id isn’t registered in `DB 0` (`admin:dbs`). Use the management APIs to create or list databases
|
|
||||||
|
|
||||||
- Cannot `SELECT 0`
|
|
||||||
- The `KEY` must be the exact admin secret passed at server startup
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
- Admin metadata lives in `DB 0` (`0.db`) and controls:
|
|
||||||
- Existence: `admin:dbs`
|
|
||||||
- Access: `meta:db:<id>.public` and `meta:db:<id>:keys`
|
|
||||||
- Encryption: `meta:db:<id>:enc`
|
|
||||||
|
|
||||||
For command examples and management payloads:
|
|
||||||
- RESP command basics: `docs/basics.md`
|
|
||||||
- Supported commands: `docs/cmds.md`
|
|
||||||
- JSON-RPC examples: `docs/rpc_examples.md`
|
|
96
docs/age.md
96
docs/age.md
@@ -1,96 +0,0 @@
|
|||||||
# HeroDB AGE Cryptography
|
|
||||||
|
|
||||||
HeroDB provides AGE-based asymmetric encryption and digital signatures over the Redis protocol using X25519 for encryption and Ed25519 for signatures. Keys can be used in stateless (ephemeral) or key-managed (persistent, named) modes.
|
|
||||||
|
|
||||||
In key-managed mode, HeroDB uses a unified keypair concept: a single Ed25519 signing key is deterministically derived into X25519 keys for encryption, allowing one keypair to handle both encryption and signatures transparently.
|
|
||||||
|
|
||||||
## Cryptographic Algorithms
|
|
||||||
|
|
||||||
### X25519 (Encryption)
|
|
||||||
- Elliptic-curve Diffie-Hellman key exchange for symmetric key derivation.
|
|
||||||
- Used for encrypting/decrypting messages.
|
|
||||||
|
|
||||||
### Ed25519 (Signatures)
|
|
||||||
- EdDSA digital signatures for message authentication.
|
|
||||||
- Used for signing/verifying messages.
|
|
||||||
|
|
||||||
### Key Derivation
|
|
||||||
Ed25519 signing keys are deterministically converted to X25519 keys for encryption. This enables a single keypair to support both operations without additional keys. Derivation uses the Ed25519 secret scalar clamped for X25519.
|
|
||||||
|
|
||||||
In named keypairs, Ed25519 keys are stored, and X25519 keys are derived on-demand and cached.
|
|
||||||
|
|
||||||
## Stateless Mode (Ephemeral Keys)
|
|
||||||
No server-side storage; keys are provided with each command.
|
|
||||||
|
|
||||||
Available commands:
|
|
||||||
- `AGE GENENC`: Generate ephemeral X25519 keypair. Returns `[recipient, identity]`.
|
|
||||||
- `AGE GENSIGN`: Generate ephemeral Ed25519 keypair. Returns `[verify_pub, sign_secret]`.
|
|
||||||
- `AGE ENCRYPT <recipient> <message>`: Encrypt message. Returns base64 ciphertext.
|
|
||||||
- `AGE DECRYPT <identity> <ciphertext_b64>`: Decrypt ciphertext. Returns plaintext.
|
|
||||||
- `AGE SIGN <sign_secret> <message>`: Sign message. Returns base64 signature.
|
|
||||||
- `AGE VERIFY <verify_pub> <message> <signature_b64>`: Verify signature. Returns 1 (valid) or 0 (invalid).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
|
||||||
redis-cli AGE GENENC
|
|
||||||
# → 1) "age1qz..." # recipient (X25519 public)
|
|
||||||
# 2) "AGE-SECRET-KEY-1..." # identity (X25519 secret)
|
|
||||||
|
|
||||||
redis-cli AGE ENCRYPT "age1qz..." "hello"
|
|
||||||
# → base64_ciphertext
|
|
||||||
|
|
||||||
redis-cli AGE DECRYPT "AGE-SECRET-KEY-1..." base64_ciphertext
|
|
||||||
# → "hello"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key-Managed Mode (Persistent Named Keys)
|
|
||||||
Keys are stored server-side under names. Supports unified keypairs for both encryption and signatures.
|
|
||||||
|
|
||||||
Available commands:
|
|
||||||
- `AGE KEYGEN <name>`: Generate and store unified keypair. Returns `[recipient, identity]` in age format.
|
|
||||||
- `AGE SIGNKEYGEN <name>`: Generate and store Ed25519 signing keypair. Returns `[verify_pub, sign_secret]`.
|
|
||||||
- `AGE ENCRYPTNAME <name> <message>`: Encrypt with named key. Returns base64 ciphertext.
|
|
||||||
- `AGE DECRYPTNAME <name> <ciphertext_b64>`: Decrypt with named key. Returns plaintext.
|
|
||||||
- `AGE SIGNNAME <name> <message>`: Sign with named key. Returns base64 signature.
|
|
||||||
- `AGE VERIFYNAME <name> <message> <signature_b64>`: Verify with named key. Returns 1 or 0.
|
|
||||||
- `AGE LIST`: List all stored key names. Returns sorted array of names.
|
|
||||||
|
|
||||||
### AGE LIST Output
|
|
||||||
Returns a flat, deduplicated, sorted array of key names (strings). Each name corresponds to a stored keypair, which may include encryption keys (X25519), signing keys (Ed25519), or both.
|
|
||||||
|
|
||||||
Output format: `["name1", "name2", ...]`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
|
||||||
redis-cli AGE LIST
|
|
||||||
# → 1) "<named_keypair_1>"
|
|
||||||
# 2) "<named_keypair_2>"
|
|
||||||
```
|
|
||||||
|
|
||||||
For unified keypairs (from `AGE KEYGEN`), the name handles both encryption (derived X25519) and signatures (stored Ed25519) transparently.
|
|
||||||
|
|
||||||
Example with named keys:
|
|
||||||
```bash
|
|
||||||
redis-cli AGE KEYGEN app1
|
|
||||||
# → 1) "age1..." # recipient
|
|
||||||
# 2) "AGE-SECRET-KEY-1..." # identity
|
|
||||||
|
|
||||||
redis-cli AGE ENCRYPTNAME app1 "secret message"
|
|
||||||
# → base64_ciphertext
|
|
||||||
|
|
||||||
redis-cli AGE DECRYPTNAME app1 base64_ciphertext
|
|
||||||
# → "secret message"
|
|
||||||
|
|
||||||
redis-cli AGE SIGNNAME app1 "message"
|
|
||||||
# → base64_signature
|
|
||||||
|
|
||||||
redis-cli AGE VERIFYNAME app1 "message" base64_signature
|
|
||||||
# → 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Choosing a Mode
|
|
||||||
- **Stateless**: For ad-hoc operations without persistence; client manages keys.
|
|
||||||
- **Key-managed**: For centralized key lifecycle; server stores keys for convenience and discoverability.
|
|
||||||
|
|
||||||
Implementation: [herodb/src/age.rs](herodb/src/age.rs) <br>
|
|
||||||
Tests: [herodb/tests/usage_suite.rs](herodb/tests/usage_suite.rs)
|
|
@@ -1,141 +0,0 @@
|
|||||||
# HeroDB JSON-RPC Examples
|
|
||||||
|
|
||||||
These examples show full JSON-RPC 2.0 payloads for managing HeroDB via the RPC API (enable with `--enable-rpc`). Methods are named as `hero_<function>`. Params are positional arrays; enum values are strings (e.g., `"Redb"`). Copy-paste into Postman or similar clients.
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
### Create Database
|
|
||||||
Creates a new database with optional per-database encryption key (stored write-only in Admin DB 0).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"method": "hero_createDatabase",
|
|
||||||
"params": [
|
|
||||||
"Redb",
|
|
||||||
{ "name": null, "storage_path": null, "max_size": null, "redis_version": null },
|
|
||||||
null
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
With encryption:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 2,
|
|
||||||
"method": "hero_createDatabase",
|
|
||||||
"params": [
|
|
||||||
"Sled",
|
|
||||||
{ "name": "secure-db", "storage_path": null, "max_size": null, "redis_version": null },
|
|
||||||
"my-per-db-encryption-key"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### List Databases
|
|
||||||
Returns array of database infos (id, backend, encrypted status, size, etc.).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 3,
|
|
||||||
"method": "hero_listDatabases",
|
|
||||||
"params": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Database Info
|
|
||||||
Retrieves detailed info for a specific database.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 4,
|
|
||||||
"method": "hero_getDatabaseInfo",
|
|
||||||
"params": [1]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Database
|
|
||||||
Removes physical database file; metadata remains in Admin DB 0.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 5,
|
|
||||||
"method": "hero_deleteDatabase",
|
|
||||||
"params": [1]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access Control
|
|
||||||
|
|
||||||
### Add Access Key
|
|
||||||
Adds a hashed access key for private databases. Permissions: `"read"` or `"readwrite"`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 6,
|
|
||||||
"method": "hero_addAccessKey",
|
|
||||||
"params": [2, "my-access-key", "readwrite"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### List Access Keys
|
|
||||||
Returns array of key hashes, permissions, and creation timestamps.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 7,
|
|
||||||
"method": "hero_listAccessKeys",
|
|
||||||
"params": [2]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Access Key
|
|
||||||
Removes key by its SHA-256 hash.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 8,
|
|
||||||
"method": "hero_deleteAccessKey",
|
|
||||||
"params": [2, "0123abcd...keyhash..."]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set Database Public/Private
|
|
||||||
Toggles public access (default true). Private databases require access keys.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 9,
|
|
||||||
"method": "hero_setDatabasePublic",
|
|
||||||
"params": [2, false]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server Info
|
|
||||||
|
|
||||||
### Get Server Stats
|
|
||||||
Returns stats like total databases and uptime.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 10,
|
|
||||||
"method": "hero_getServerStats",
|
|
||||||
"params": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- Per-database encryption keys are write-only; set at creation and used transparently.
|
|
||||||
- Access keys are hashed (SHA-256) for storage; provide plaintext in requests.
|
|
||||||
- Backend options: `"Redb"` (default) or `"Sled"`.
|
|
||||||
- Config object fields (name, storage_path, etc.) are optional and currently ignored but positional.
|
|
239
examples/tantivy_search_demo.sh
Executable file
239
examples/tantivy_search_demo.sh
Executable file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HeroDB Tantivy Search Demo
|
||||||
|
# This script demonstrates full-text search capabilities using Redis commands
|
||||||
|
# HeroDB server should be running on port 6381
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REDIS_HOST="localhost"
|
||||||
|
REDIS_PORT="6382"
|
||||||
|
REDIS_CLI="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
||||||
|
|
||||||
|
# Start the herodb server in the background
|
||||||
|
echo "Starting herodb server..."
|
||||||
|
cargo run -p herodb -- --dir /tmp/herodbtest --port ${REDIS_PORT} --debug &
|
||||||
|
SERVER_PID=$!
|
||||||
|
echo
|
||||||
|
sleep 2 # Give the server a moment to start
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_header() {
|
||||||
|
echo -e "${BLUE}=== $1 ===${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}✓ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_info() {
|
||||||
|
echo -e "${YELLOW}ℹ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}✗ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check if HeroDB is running
|
||||||
|
check_herodb() {
|
||||||
|
print_info "Checking if HeroDB is running on port $REDIS_PORT..."
|
||||||
|
if ! $REDIS_CLI ping > /dev/null 2>&1; then
|
||||||
|
print_error "HeroDB is not running on port $REDIS_PORT"
|
||||||
|
print_info "Please start HeroDB with: cargo run -- --port $REDIS_PORT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "HeroDB is running and responding"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to execute Redis command with error handling
|
||||||
|
execute_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
local description="$2"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Command:${NC} $cmd"
|
||||||
|
if result=$($REDIS_CLI $cmd 2>&1); then
|
||||||
|
echo -e "${GREEN}Result:${NC} $result"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
print_error "Failed: $description"
|
||||||
|
echo "Error: $result"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to pause for readability
|
||||||
|
pause() {
|
||||||
|
echo
|
||||||
|
read -p "Press Enter to continue..."
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main demo function
|
||||||
|
main() {
|
||||||
|
clear
|
||||||
|
print_header "HeroDB Tantivy Search Demonstration"
|
||||||
|
echo "This demo shows full-text search capabilities using Redis commands"
|
||||||
|
echo "HeroDB runs on port $REDIS_PORT (instead of Redis default 6379)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check if HeroDB is running
|
||||||
|
check_herodb
|
||||||
|
echo
|
||||||
|
|
||||||
|
print_header "Step 1: Create Search Index"
|
||||||
|
print_info "Creating a product catalog search index with various field types"
|
||||||
|
|
||||||
|
# Create search index with schema
|
||||||
|
execute_cmd "FT.CREATE product_catalog SCHEMA title TEXT description TEXT category TAG price NUMERIC rating NUMERIC location GEO" \
|
||||||
|
"Creating search index"
|
||||||
|
|
||||||
|
print_success "Search index 'product_catalog' created successfully"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 2: Add Sample Products"
|
||||||
|
print_info "Adding sample products to demonstrate different search scenarios"
|
||||||
|
|
||||||
|
# Add sample products using FT.ADD
|
||||||
|
execute_cmd "FT.ADD product_catalog product:1 1.0 title 'Wireless Bluetooth Headphones' description 'Premium noise-canceling headphones with 30-hour battery life' category 'electronics,audio' price 299.99 rating 4.5 location '-122.4194,37.7749'" "Adding product 1"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:2 1.0 title 'Organic Coffee Beans' description 'Single-origin Ethiopian coffee beans, medium roast' category 'food,beverages,organic' price 24.99 rating 4.8 location '-74.0060,40.7128'" "Adding product 2"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:3 1.0 title 'Yoga Mat Premium' description 'Eco-friendly yoga mat with superior grip and cushioning' category 'fitness,wellness,eco-friendly' price 89.99 rating 4.3 location '-118.2437,34.0522'" "Adding product 3"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:4 1.0 title 'Smart Home Speaker' description 'Voice-controlled smart speaker with AI assistant' category 'electronics,smart-home' price 149.99 rating 4.2 location '-87.6298,41.8781'" "Adding product 4"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:5 1.0 title 'Organic Green Tea' description 'Premium organic green tea leaves from Japan' category 'food,beverages,organic,tea' price 18.99 rating 4.7 location '139.6503,35.6762'" "Adding product 5"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:6 1.0 title 'Wireless Gaming Mouse' description 'High-precision gaming mouse with RGB lighting' category 'electronics,gaming' price 79.99 rating 4.4 location '-122.3321,47.6062'" "Adding product 6"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:7 1.0 title 'Comfortable meditation cushion for mindfulness practice' description 'Meditation cushion with premium materials' category 'wellness,meditation' price 45.99 rating 4.6 location '-122.4194,37.7749'" "Adding product 7"
|
||||||
|
execute_cmd "FT.ADD product_catalog product:8 1.0 title 'Bluetooth Earbuds' description 'True wireless earbuds with active noise cancellation' category 'electronics,audio' price 199.99 rating 4.1 location '-74.0060,40.7128'" "Adding product 8"
|
||||||
|
|
||||||
|
print_success "Added 8 products to the index"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 3: Basic Text Search"
|
||||||
|
print_info "Searching for 'wireless' products"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog wireless" "Basic text search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 4: Search with Filters"
|
||||||
|
print_info "Searching for 'organic' products"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog organic" "Filtered search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 5: Numeric Range Search"
|
||||||
|
print_info "Searching for 'premium' products"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog premium" "Text search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 6: Sorting Results"
|
||||||
|
print_info "Searching for electronics"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog electronics" "Category search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 7: Limiting Results"
|
||||||
|
print_info "Searching for wireless products with limit"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog wireless LIMIT 0 3" "Limited results"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 8: Complex Query"
|
||||||
|
print_info "Finding audio products with noise cancellation"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog 'noise cancellation'" "Complex query"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 9: Geographic Search"
|
||||||
|
print_info "Searching for meditation products"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog meditation" "Text search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 10: Aggregation Example"
|
||||||
|
print_info "Getting index information and statistics"
|
||||||
|
|
||||||
|
execute_cmd "FT.INFO product_catalog" "Index information"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 11: Search Comparison"
|
||||||
|
print_info "Comparing Tantivy search vs simple key matching"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Tantivy Full-Text Search:${NC}"
|
||||||
|
execute_cmd "FT.SEARCH product_catalog 'battery life'" "Full-text search for 'battery life'"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Simple Key Pattern Matching:${NC}"
|
||||||
|
execute_cmd "KEYS *battery*" "Simple pattern matching for 'battery'"
|
||||||
|
|
||||||
|
print_info "Notice how full-text search finds relevant results even when exact words don't match keys"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 12: Fuzzy Search"
|
||||||
|
print_info "Searching for headphones"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog headphones" "Text search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 13: Phrase Search"
|
||||||
|
print_info "Searching for coffee products"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog coffee" "Text search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 14: Boolean Queries"
|
||||||
|
print_info "Searching for gaming products"
|
||||||
|
|
||||||
|
execute_cmd "FT.SEARCH product_catalog gaming" "Text search"
|
||||||
|
echo
|
||||||
|
execute_cmd "FT.SEARCH product_catalog tea" "Text search"
|
||||||
|
pause
|
||||||
|
|
||||||
|
print_header "Step 15: Cleanup"
|
||||||
|
print_info "Removing test data"
|
||||||
|
|
||||||
|
# Delete the search index
|
||||||
|
execute_cmd "FT.DROP product_catalog" "Dropping search index"
|
||||||
|
|
||||||
|
# Clean up documents from search index
|
||||||
|
for i in {1..8}; do
|
||||||
|
execute_cmd "FT.DEL product_catalog product:$i" "Deleting product:$i from index"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_success "Cleanup completed"
|
||||||
|
echo
|
||||||
|
|
||||||
|
print_header "Demo Summary"
|
||||||
|
echo "This demonstration showed:"
|
||||||
|
echo "• Creating search indexes with different field types"
|
||||||
|
echo "• Adding documents to the search index"
|
||||||
|
echo "• Basic and advanced text search queries"
|
||||||
|
echo "• Filtering by categories and numeric ranges"
|
||||||
|
echo "• Sorting and limiting results"
|
||||||
|
echo "• Geographic searches"
|
||||||
|
echo "• Fuzzy matching and phrase searches"
|
||||||
|
echo "• Boolean query operators"
|
||||||
|
echo "• Comparison with simple pattern matching"
|
||||||
|
echo
|
||||||
|
print_success "HeroDB Tantivy search demo completed successfully!"
|
||||||
|
echo
|
||||||
|
print_info "Key advantages of Tantivy full-text search:"
|
||||||
|
echo " - Relevance scoring and ranking"
|
||||||
|
echo " - Fuzzy matching and typo tolerance"
|
||||||
|
echo " - Complex boolean queries"
|
||||||
|
echo " - Field-specific searches and filters"
|
||||||
|
echo " - Geographic and numeric range queries"
|
||||||
|
echo " - Much faster than pattern matching on large datasets"
|
||||||
|
echo
|
||||||
|
print_info "To run HeroDB server: cargo run -- --port 6381"
|
||||||
|
print_info "To connect with redis-cli: redis-cli -h localhost -p 6381"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the demo
|
||||||
|
main "$@"
|
30
herodb/Cargo.toml
Normal file
30
herodb/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "herodb"
|
||||||
|
version = "0.0.1"
|
||||||
|
authors = ["Pin Fang <fpfangpin@hotmail.com>"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.59"
|
||||||
|
bytes = "1.3.0"
|
||||||
|
thiserror = "1.0.32"
|
||||||
|
tokio = { version = "1.23.0", features = ["full"] }
|
||||||
|
clap = { version = "4.5.20", features = ["derive"] }
|
||||||
|
byteorder = "1.4.3"
|
||||||
|
futures = "0.3"
|
||||||
|
sled = "0.34"
|
||||||
|
redb = "2.1.3"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
bincode = "1.3"
|
||||||
|
chacha20poly1305 = "0.10.1"
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
age = "0.10"
|
||||||
|
secrecy = "0.8"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
base64 = "0.22"
|
||||||
|
tantivy = "0.25.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
redis = { version = "0.24", features = ["aio", "tokio-comp"] }
|
@@ -17,8 +17,6 @@ The main purpose of HeroDB is to offer a lightweight, embeddable, and Redis-comp
|
|||||||
- **Expiration**: Time-to-live (TTL) functionality for keys.
|
- **Expiration**: Time-to-live (TTL) functionality for keys.
|
||||||
- **Scanning**: Cursor-based iteration for keys and hash fields (`SCAN`, `HSCAN`).
|
- **Scanning**: Cursor-based iteration for keys and hash fields (`SCAN`, `HSCAN`).
|
||||||
- **AGE Cryptography Commands**: HeroDB-specific extensions for cryptographic operations.
|
- **AGE Cryptography Commands**: HeroDB-specific extensions for cryptographic operations.
|
||||||
- **Symmetric Encryption**: Stateless symmetric encryption using XChaCha20-Poly1305.
|
|
||||||
- **Admin Database 0**: Centralized control for database management, access control, and per-database encryption.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -32,14 +30,31 @@ cargo build --release
|
|||||||
|
|
||||||
### Running HeroDB
|
### Running HeroDB
|
||||||
|
|
||||||
Launch HeroDB with the required `--admin-secret` flag, which encrypts the admin database (DB 0) and authorizes admin access. Optional flags include `--dir` for the database directory, `--port` for the TCP port (default 6379), `--sled` for the sled backend, and `--enable-rpc` to start the JSON-RPC management server on port 8080.
|
You can start HeroDB with different backends and encryption options:
|
||||||
|
|
||||||
|
#### Default `redb` Backend
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
```bash
|
||||||
./target/release/herodb --dir /tmp/herodb --admin-secret myadminsecret --port 6379 --enable-rpc
|
./target/release/herodb --dir /tmp/herodb_redb --port 6379
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed launch options, see [Basics](docs/basics.md).
|
#### `sled` Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/herodb --dir /tmp/herodb_sled --port 6379 --sled
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `redb` with Encryption
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/herodb --dir /tmp/herodb_encrypted --port 6379 --encrypt --key mysecretkey
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `sled` with Encryption
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/herodb --dir /tmp/herodb_sled_encrypted --port 6379 --sled --encrypt --key mysecretkey
|
||||||
|
```
|
||||||
|
|
||||||
## Usage with Redis Clients
|
## Usage with Redis Clients
|
||||||
|
|
||||||
@@ -61,24 +76,10 @@ redis-cli -p 6379 SCAN 0 MATCH user:* COUNT 10
|
|||||||
# 2) 1) "user:1"
|
# 2) 1) "user:1"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cryptography
|
|
||||||
|
|
||||||
HeroDB supports asymmetric encryption/signatures via AGE commands (X25519 for encryption, Ed25519 for signatures) in stateless or key-managed modes, and symmetric encryption via SYM commands. Keys are persisted in the admin database (DB 0) for managed modes.
|
|
||||||
|
|
||||||
For details, see [AGE Cryptography](docs/age.md) and [Basics](docs/basics.md).
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
Databases are managed via JSON-RPC API, with metadata stored in the encrypted admin database (DB 0). Databases are public by default upon creation; use RPC to set them private, requiring access keys for SELECT operations (read or readwrite based on permissions). This includes per-database encryption keys, access control, and lifecycle management.
|
|
||||||
|
|
||||||
For examples, see [JSON-RPC Examples](docs/rpc_examples.md) and [Admin DB 0 Model](docs/admin.md).
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
For more detailed information on commands, features, and advanced usage, please refer to the documentation:
|
For more detailed information on commands, features, and advanced usage, please refer to the documentation:
|
||||||
|
|
||||||
- [Basics](docs/basics.md)
|
- [Basics](docs/basics.md)
|
||||||
- [Supported Commands](docs/cmds.md)
|
- [Supported Commands](docs/cmds.md)
|
||||||
- [AGE Cryptography](docs/age.md)
|
- [AGE Cryptography](docs/age.md)
|
||||||
- [Admin DB 0 Model (access control, per-db encryption)](docs/admin.md)
|
|
||||||
- [JSON-RPC Examples (management API)](docs/rpc_examples.md)
|
|
188
herodb/docs/age.md
Normal file
188
herodb/docs/age.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# HeroDB AGE usage: Stateless vs Key‑Managed
|
||||||
|
|
||||||
|
This document explains how to use the AGE cryptography commands exposed by HeroDB over the Redis protocol in two modes:
|
||||||
|
- Stateless (ephemeral keys; nothing stored on the server)
|
||||||
|
- Key‑managed (server‑persisted, named keys)
|
||||||
|
|
||||||
|
If you are new to the codebase, the exact tests that exercise these behaviors are:
|
||||||
|
- [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495)
|
||||||
|
- [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555)
|
||||||
|
|
||||||
|
Implementation entry points:
|
||||||
|
- [herodb/src/age.rs](herodb/src/age.rs)
|
||||||
|
- Dispatch from [herodb/src/cmd.rs](herodb/src/cmd.rs)
|
||||||
|
|
||||||
|
Note: Database-at-rest encryption flags in the test harness are unrelated to AGE commands; those flags control storage-level encryption of DB files. See the harness near [rust.start_test_server()](herodb/tests/usage_suite.rs:10).
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Assuming the server is running on localhost on some $PORT:
|
||||||
|
```bash
|
||||||
|
~/code/git.ourworld.tf/herocode/herodb/herodb/build.sh
|
||||||
|
~/code/git.ourworld.tf/herocode/herodb/target/release/herodb --dir /tmp/data --debug --$PORT 6381 --encryption-key 1234 --encrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PORT=6381
|
||||||
|
# Generate an ephemeral keypair and encrypt/decrypt a message (stateless mode)
|
||||||
|
redis-cli -p $PORT AGE GENENC
|
||||||
|
# → returns an array: [recipient, identity]
|
||||||
|
|
||||||
|
redis-cli -p $PORT AGE ENCRYPT <recipient> "hello world"
|
||||||
|
# → returns ciphertext (base64 in a bulk string)
|
||||||
|
|
||||||
|
redis-cli -p $PORT AGE DECRYPT <identity> <ciphertext_b64>
|
||||||
|
# → returns "hello world"
|
||||||
|
```
|
||||||
|
|
||||||
|
For key‑managed mode, generate a named key once and reference it by name afterwards:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -p $PORT AGE KEYGEN app1
|
||||||
|
# → persists encryption keypair under name "app1"
|
||||||
|
|
||||||
|
redis-cli -p $PORT AGE ENCRYPTNAME app1 "hello"
|
||||||
|
redis-cli -p $PORT AGE DECRYPTNAME app1 <ciphertext_b64>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stateless AGE (ephemeral)
|
||||||
|
|
||||||
|
Characteristics
|
||||||
|
|
||||||
|
- No server‑side storage of keys.
|
||||||
|
- You pass the actual key material with every call.
|
||||||
|
- Not listable via AGE LIST.
|
||||||
|
|
||||||
|
Commands and examples
|
||||||
|
|
||||||
|
1) Ephemeral encryption keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate an ephemeral encryption keypair
|
||||||
|
redis-cli -p $PORT AGE GENENC
|
||||||
|
# Example output (abridged):
|
||||||
|
# 1) "age1qz..." # recipient (public key) = can be used by others e.g. to verify what I sign
|
||||||
|
# 2) "AGE-SECRET-KEY-1..." # identity (secret) = is like my private, cannot lose this one
|
||||||
|
|
||||||
|
# Encrypt with the recipient public key
|
||||||
|
redis-cli -p $PORT AGE ENCRYPT "age1qz..." "hello world"
|
||||||
|
|
||||||
|
# → returns bulk string payload: base64 ciphertext (encrypted content)
|
||||||
|
|
||||||
|
# Decrypt with the identity (secret) in other words your private key
|
||||||
|
redis-cli -p $PORT AGE DECRYPT "AGE-SECRET-KEY-1..." "<ciphertext_b64>"
|
||||||
|
# → "hello world"
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Ephemeral signing keys
|
||||||
|
|
||||||
|
> ? is this same as my private key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Generate an ephemeral signing keypair
|
||||||
|
redis-cli -p $PORT AGE GENSIGN
|
||||||
|
# Example output:
|
||||||
|
# 1) "<verify_pub_b64>"
|
||||||
|
# 2) "<sign_secret_b64>"
|
||||||
|
|
||||||
|
# Sign a message with the secret
|
||||||
|
redis-cli -p $PORT AGE SIGN "<sign_secret_b64>" "msg"
|
||||||
|
# → returns "<signature_b64>"
|
||||||
|
|
||||||
|
# Verify with the public key
|
||||||
|
redis-cli -p $PORT AGE VERIFY "<verify_pub_b64>" "msg" "<signature_b64>"
|
||||||
|
# → 1 (valid) or 0 (invalid)
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use
|
||||||
|
- You do not want the server to store private keys.
|
||||||
|
- You already manage key material on the client side.
|
||||||
|
- You need ad‑hoc operations without persistence.
|
||||||
|
|
||||||
|
Reference test: [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495)
|
||||||
|
|
||||||
|
## Key‑managed AGE (persistent, named)
|
||||||
|
|
||||||
|
Characteristics
|
||||||
|
- Server generates and persists keypairs under a chosen name.
|
||||||
|
- Clients refer to keys by name; raw secrets are not supplied on each call.
|
||||||
|
- Keys are discoverable via AGE LIST.
|
||||||
|
|
||||||
|
Commands and examples
|
||||||
|
|
||||||
|
1) Named encryption keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create/persist a named encryption keypair
|
||||||
|
redis-cli -p $PORT AGE KEYGEN app1
|
||||||
|
# → returns [recipient, identity] but also stores them under name "app1"
|
||||||
|
|
||||||
|
> TODO: should not return identity (security, but there can be separate function to export it e.g. AGE EXPORTKEY app1)
|
||||||
|
|
||||||
|
# Encrypt using the stored public key
|
||||||
|
redis-cli -p $PORT AGE ENCRYPTNAME app1 "hello"
|
||||||
|
# → returns bulk string payload: base64 ciphertext
|
||||||
|
|
||||||
|
# Decrypt using the stored secret
|
||||||
|
redis-cli -p $PORT AGE DECRYPTNAME app1 "<ciphertext_b64>"
|
||||||
|
# → "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Named signing keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create/persist a named signing keypair
|
||||||
|
redis-cli -p $PORT AGE SIGNKEYGEN app1
|
||||||
|
# → returns [verify_pub_b64, sign_secret_b64] and stores under name "app1"
|
||||||
|
|
||||||
|
> TODO: should not return sign_secret_b64 (for security, but there can be separate function to export it e.g. AGE EXPORTSIGNKEY app1)
|
||||||
|
|
||||||
|
# Sign using the stored secret
|
||||||
|
redis-cli -p $PORT AGE SIGNNAME app1 "msg"
|
||||||
|
# → returns "<signature_b64>"
|
||||||
|
|
||||||
|
# Verify using the stored public key
|
||||||
|
redis-cli -p $PORT AGE VERIFYNAME app1 "msg" "<signature_b64>"
|
||||||
|
# → 1 (valid) or 0 (invalid)
|
||||||
|
```
|
||||||
|
|
||||||
|
3) List stored AGE keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -p $PORT AGE LIST
|
||||||
|
# Example output includes labels such as "encpub" and your key names (e.g., "app1")
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use
|
||||||
|
- You want centralized key storage/rotation and fewer secrets on the client.
|
||||||
|
- You need names/labels for workflows and can trust the server with secrets.
|
||||||
|
- You want discoverability (AGE LIST) and simpler client commands.
|
||||||
|
|
||||||
|
Reference test: [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555)
|
||||||
|
|
||||||
|
## Choosing a mode
|
||||||
|
|
||||||
|
- Prefer Stateless when:
|
||||||
|
- Minimizing server trust for secret material is the priority.
|
||||||
|
- Clients already have a secure mechanism to store/distribute keys.
|
||||||
|
- Prefer Key‑managed when:
|
||||||
|
- Centralized lifecycle, naming, and discoverability are beneficial.
|
||||||
|
- You plan to integrate rotation, ACLs, or auditability on the server side.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Treat identities and signing secrets as sensitive; avoid logging them.
|
||||||
|
- For key‑managed mode, ensure server storage (and backups) are protected.
|
||||||
|
- AGE operations here are application‑level crypto and are distinct from database-at-rest encryption configured in the test harness.
|
||||||
|
|
||||||
|
## Repository pointers
|
||||||
|
|
||||||
|
- Stateless examples in tests: [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495)
|
||||||
|
- Key‑managed examples in tests: [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555)
|
||||||
|
- AGE implementation: [herodb/src/age.rs](herodb/src/age.rs)
|
||||||
|
- Command dispatch: [herodb/src/cmd.rs](herodb/src/cmd.rs)
|
||||||
|
- Bash demo: [herodb/examples/age_bash_demo.sh](herodb/examples/age_bash_demo.sh)
|
||||||
|
- Rust persistent demo: [herodb/examples/age_persist_demo.rs](herodb/examples/age_persist_demo.rs)
|
||||||
|
- Additional notes: [herodb/instructions/encrypt.md](herodb/instructions/encrypt.md)
|
@@ -1,58 +1,4 @@
|
|||||||
# HeroDB Basics
|
Here's an expanded version of the cmds.md documentation to include the list commands:
|
||||||
|
|
||||||
## Launching HeroDB
|
|
||||||
|
|
||||||
To launch HeroDB, use the binary with required and optional flags. The `--admin-secret` flag is mandatory, encrypting the admin database (DB 0) and authorizing admin access.
|
|
||||||
|
|
||||||
### Launch Flags
|
|
||||||
- `--dir <path>`: Directory for database files (default: current directory).
|
|
||||||
- `--port <port>`: TCP port for Redis protocol (default: 6379).
|
|
||||||
- `--debug`: Enable debug logging.
|
|
||||||
- `--sled`: Use Sled backend (default: Redb).
|
|
||||||
- `--enable-rpc`: Start JSON-RPC management server on port 8080.
|
|
||||||
- `--rpc-port <port>`: Custom RPC port (default: 8080).
|
|
||||||
- `--admin-secret <secret>`: Required secret for DB 0 encryption and admin access.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
|
||||||
./target/release/herodb --dir /tmp/herodb --admin-secret mysecret --port 6379 --enable-rpc
|
|
||||||
```
|
|
||||||
|
|
||||||
Deprecated flags (`--encrypt`, `--encryption-key`) are ignored for data DBs; per-database encryption is managed via RPC.
|
|
||||||
|
|
||||||
## Admin Database (DB 0)
|
|
||||||
|
|
||||||
DB 0 acts as the administrative database instance, storing metadata for all user databases (IDs >= 1). It controls existence, access control, and per-database encryption. DB 0 is always encrypted with the `--admin-secret`.
|
|
||||||
|
|
||||||
When creating a new database, DB 0 allocates an ID, registers it, and optionally stores a per-database encryption key (write-only). Databases are public by default; use RPC to set them private, requiring access keys for SELECT (read or readwrite based on permissions). Keys are persisted in DB 0 for managed AGE operations.
|
|
||||||
|
|
||||||
Access DB 0 with `SELECT 0 KEY <admin-secret>`.
|
|
||||||
|
|
||||||
## Symmetric Encryption
|
|
||||||
|
|
||||||
HeroDB supports stateless symmetric encryption via SYM commands, using XChaCha20-Poly1305 AEAD.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
- `SYM KEYGEN`: Generate 32-byte key. Returns base64-encoded key.
|
|
||||||
- `SYM ENCRYPT <key_b64> <message>`: Encrypt message. Returns base64 ciphertext.
|
|
||||||
- `SYM DECRYPT <key_b64> <ciphertext_b64>`: Decrypt. Returns plaintext.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
|
||||||
redis-cli SYM KEYGEN
|
|
||||||
# → base64_key
|
|
||||||
|
|
||||||
redis-cli SYM ENCRYPT base64_key "secret"
|
|
||||||
# → base64_ciphertext
|
|
||||||
|
|
||||||
redis-cli SYM DECRYPT base64_key base64_ciphertext
|
|
||||||
# → "secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
## RPC Options
|
|
||||||
|
|
||||||
Enable the JSON-RPC server with `--enable-rpc` for database management. Methods include creating databases, managing access keys, and setting encryption. See [JSON-RPC Examples](./rpc_examples.md) for payloads.
|
|
||||||
|
|
||||||
# HeroDB Commands
|
# HeroDB Commands
|
||||||
|
|
||||||
HeroDB implements a subset of Redis commands over the Redis protocol. This document describes the available commands and their usage.
|
HeroDB implements a subset of Redis commands over the Redis protocol. This document describes the available commands and their usage.
|
||||||
@@ -629,29 +575,6 @@ redis-cli -p $PORT AGE LIST
|
|||||||
# 2) "keyname2"
|
# 2) "keyname2"
|
||||||
```
|
```
|
||||||
|
|
||||||
## SYM Commands
|
|
||||||
|
|
||||||
### SYM KEYGEN
|
|
||||||
Generate a symmetric encryption key.
|
|
||||||
```bash
|
|
||||||
redis-cli -p $PORT SYM KEYGEN
|
|
||||||
# → base64_encoded_32byte_key
|
|
||||||
```
|
|
||||||
|
|
||||||
### SYM ENCRYPT
|
|
||||||
Encrypt a message with a symmetric key.
|
|
||||||
```bash
|
|
||||||
redis-cli -p $PORT SYM ENCRYPT <key_b64> "message"
|
|
||||||
# → base64_encoded_ciphertext
|
|
||||||
```
|
|
||||||
|
|
||||||
### SYM DECRYPT
|
|
||||||
Decrypt a ciphertext with a symmetric key.
|
|
||||||
```bash
|
|
||||||
redis-cli -p $PORT SYM DECRYPT <key_b64> <ciphertext_b64>
|
|
||||||
# → decrypted_message
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server Information Commands
|
## Server Information Commands
|
||||||
|
|
||||||
### INFO
|
### INFO
|
||||||
@@ -698,27 +621,3 @@ This expanded documentation includes all the list commands that were implemented
|
|||||||
10. LINDEX - get element by index
|
10. LINDEX - get element by index
|
||||||
11. LRANGE - get range of elements
|
11. LRANGE - get range of elements
|
||||||
|
|
||||||
|
|
||||||
## Updated Database Selection and Access Keys
|
|
||||||
|
|
||||||
HeroDB uses an `Admin DB 0` to control database existence, access, and encryption. Access to data DBs can be public (no key) or private (requires a key). See detailed model in `docs/admin.md`.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Public database (no key required)
|
|
||||||
redis-cli -p $PORT SELECT 1
|
|
||||||
# → OK
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Private database (requires access key)
|
|
||||||
redis-cli -p $PORT SELECT 2 KEY my-db2-access-key
|
|
||||||
# → OK
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Admin DB 0 (requires admin secret)
|
|
||||||
redis-cli -p $PORT SELECT 0 KEY my-admin-secret
|
|
||||||
# → OK
|
|
||||||
```
|
|
@@ -122,27 +122,4 @@ redis-cli -p 6379 --rdb dump.rdb
|
|||||||
|
|
||||||
# Import to sled
|
# Import to sled
|
||||||
redis-cli -p 6381 --pipe < dump.rdb
|
redis-cli -p 6381 --pipe < dump.rdb
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication and Database Selection
|
|
||||||
|
|
||||||
HeroDB uses an `Admin DB 0` to govern database existence, access and per-db encryption. Access control is enforced via `Admin DB 0` metadata. See the full model in `docs/admin.md`.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```bash
|
|
||||||
# Public database (no key required)
|
|
||||||
redis-cli -p $PORT SELECT 1
|
|
||||||
# → OK
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Private database (requires access key)
|
|
||||||
redis-cli -p $PORT SELECT 2 KEY my-db2-access-key
|
|
||||||
# → OK
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Admin DB 0 (requires admin secret)
|
|
||||||
redis-cli -p $PORT SELECT 0 KEY my-admin-secret
|
|
||||||
# → OK
|
|
||||||
```
|
```
|
308
herodb/src/age.rs
Normal file
308
herodb/src/age.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
//! age.rs — AGE (rage) helpers + persistent key management for your mini-Redis.
|
||||||
|
//
|
||||||
|
// Features:
|
||||||
|
// - X25519 encryption/decryption (age style)
|
||||||
|
// - Ed25519 detached signatures + verification
|
||||||
|
// - Persistent named keys in DB (strings):
|
||||||
|
// age:key:{name} -> X25519 recipient (public encryption key, "age1...")
|
||||||
|
// age:privkey:{name} -> X25519 identity (secret encryption key, "AGE-SECRET-KEY-1...")
|
||||||
|
// age:signpub:{name} -> Ed25519 verify pubkey (public, used to verify signatures)
|
||||||
|
// age:signpriv:{name} -> Ed25519 signing secret key (private, used to sign)
|
||||||
|
// - Base64 wrapping for ciphertext/signature binary blobs.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
use age::{Decryptor, Encryptor};
|
||||||
|
use age::x25519;
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Signer, Verifier, SigningKey, VerifyingKey};
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
|
||||||
|
use crate::protocol::Protocol;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::error::DBError;
|
||||||
|
|
||||||
|
// ---------- Internal helpers ----------
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AgeWireError {
|
||||||
|
ParseKey,
|
||||||
|
Crypto(String),
|
||||||
|
Utf8,
|
||||||
|
SignatureLen,
|
||||||
|
NotFound(&'static str), // which kind of key was missing
|
||||||
|
Storage(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgeWireError {
|
||||||
|
fn to_protocol(self) -> Protocol {
|
||||||
|
match self {
|
||||||
|
AgeWireError::ParseKey => Protocol::err("ERR age: invalid key"),
|
||||||
|
AgeWireError::Crypto(e) => Protocol::err(&format!("ERR age: {e}")),
|
||||||
|
AgeWireError::Utf8 => Protocol::err("ERR age: invalid UTF-8 plaintext"),
|
||||||
|
AgeWireError::SignatureLen => Protocol::err("ERR age: bad signature length"),
|
||||||
|
AgeWireError::NotFound(w) => Protocol::err(&format!("ERR age: missing {w}")),
|
||||||
|
AgeWireError::Storage(e) => Protocol::err(&format!("ERR storage: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_recipient(s: &str) -> Result<x25519::Recipient, AgeWireError> {
|
||||||
|
x25519::Recipient::from_str(s).map_err(|_| AgeWireError::ParseKey)
|
||||||
|
}
|
||||||
|
fn parse_identity(s: &str) -> Result<x25519::Identity, AgeWireError> {
|
||||||
|
x25519::Identity::from_str(s).map_err(|_| AgeWireError::ParseKey)
|
||||||
|
}
|
||||||
|
fn parse_ed25519_signing_key(s: &str) -> Result<SigningKey, AgeWireError> {
|
||||||
|
// Parse base64-encoded signing key
|
||||||
|
let bytes = B64.decode(s).map_err(|_| AgeWireError::ParseKey)?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(AgeWireError::ParseKey);
|
||||||
|
}
|
||||||
|
let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| AgeWireError::ParseKey)?;
|
||||||
|
Ok(SigningKey::from_bytes(&key_bytes))
|
||||||
|
}
|
||||||
|
fn parse_ed25519_verifying_key(s: &str) -> Result<VerifyingKey, AgeWireError> {
|
||||||
|
// Parse base64-encoded verifying key
|
||||||
|
let bytes = B64.decode(s).map_err(|_| AgeWireError::ParseKey)?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(AgeWireError::ParseKey);
|
||||||
|
}
|
||||||
|
let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| AgeWireError::ParseKey)?;
|
||||||
|
VerifyingKey::from_bytes(&key_bytes).map_err(|_| AgeWireError::ParseKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Stateless crypto helpers (string in/out) ----------
|
||||||
|
|
||||||
|
pub fn gen_enc_keypair() -> (String, String) {
|
||||||
|
let id = x25519::Identity::generate();
|
||||||
|
let pk = id.to_public();
|
||||||
|
(pk.to_string(), id.to_string().expose_secret().to_string()) // (recipient, identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gen_sign_keypair() -> (String, String) {
|
||||||
|
use rand::RngCore;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
// Generate random 32 bytes for the signing key
|
||||||
|
let mut secret_bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut secret_bytes);
|
||||||
|
|
||||||
|
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
|
||||||
|
// Encode as base64 for storage
|
||||||
|
let signing_key_b64 = B64.encode(signing_key.to_bytes());
|
||||||
|
let verifying_key_b64 = B64.encode(verifying_key.to_bytes());
|
||||||
|
|
||||||
|
(verifying_key_b64, signing_key_b64) // (verify_pub, signing_secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `msg` for `recipient_str` (X25519). Returns base64(ciphertext).
|
||||||
|
pub fn encrypt_b64(recipient_str: &str, msg: &str) -> Result<String, AgeWireError> {
|
||||||
|
let recipient = parse_recipient(recipient_str)?;
|
||||||
|
let enc = Encryptor::with_recipients(vec![Box::new(recipient)])
|
||||||
|
.expect("failed to create encryptor"); // Handle Option<Encryptor>
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut w = enc.wrap_output(&mut out).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
w.write_all(msg.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
w.finish().map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(B64.encode(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt base64(ciphertext) with `identity_str`. Returns plaintext String.
|
||||||
|
pub fn decrypt_b64(identity_str: &str, ct_b64: &str) -> Result<String, AgeWireError> {
|
||||||
|
let id = parse_identity(identity_str)?;
|
||||||
|
let ct = B64.decode(ct_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
let dec = Decryptor::new(&ct[..]).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
|
||||||
|
// The decrypt method returns a Result<StreamReader, DecryptError>
|
||||||
|
let mut r = match dec {
|
||||||
|
Decryptor::Recipients(d) => d.decrypt(std::iter::once(&id as &dyn age::Identity))
|
||||||
|
.map_err(|e| AgeWireError::Crypto(e.to_string()))?,
|
||||||
|
Decryptor::Passphrase(_) => return Err(AgeWireError::Crypto("Expected recipients, got passphrase".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pt = Vec::new();
|
||||||
|
use std::io::Read;
|
||||||
|
r.read_to_end(&mut pt).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
String::from_utf8(pt).map_err(|_| AgeWireError::Utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign bytes of `msg` (detached). Returns base64(signature bytes, 64 bytes).
|
||||||
|
pub fn sign_b64(signing_secret_str: &str, msg: &str) -> Result<String, AgeWireError> {
|
||||||
|
let signing_key = parse_ed25519_signing_key(signing_secret_str)?;
|
||||||
|
let sig = signing_key.sign(msg.as_bytes());
|
||||||
|
Ok(B64.encode(sig.to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify detached signature (base64) for `msg` with pubkey.
|
||||||
|
pub fn verify_b64(verify_pub_str: &str, msg: &str, sig_b64: &str) -> Result<bool, AgeWireError> {
|
||||||
|
let verifying_key = parse_ed25519_verifying_key(verify_pub_str)?;
|
||||||
|
let sig_bytes = B64.decode(sig_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
||||||
|
if sig_bytes.len() != 64 {
|
||||||
|
return Err(AgeWireError::SignatureLen);
|
||||||
|
}
|
||||||
|
let sig = Signature::from_bytes(sig_bytes[..].try_into().unwrap());
|
||||||
|
Ok(verifying_key.verify(msg.as_bytes(), &sig).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Storage helpers ----------
|
||||||
|
|
||||||
|
fn sget(server: &Server, key: &str) -> Result<Option<String>, AgeWireError> {
|
||||||
|
let st = server.current_storage().map_err(|e| AgeWireError::Storage(e.0))?;
|
||||||
|
st.get(key).map_err(|e| AgeWireError::Storage(e.0))
|
||||||
|
}
|
||||||
|
fn sset(server: &Server, key: &str, val: &str) -> Result<(), AgeWireError> {
|
||||||
|
let st = server.current_storage().map_err(|e| AgeWireError::Storage(e.0))?;
|
||||||
|
st.set(key.to_string(), val.to_string()).map_err(|e| AgeWireError::Storage(e.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enc_pub_key_key(name: &str) -> String { format!("age:key:{name}") }
|
||||||
|
fn enc_priv_key_key(name: &str) -> String { format!("age:privkey:{name}") }
|
||||||
|
fn sign_pub_key_key(name: &str) -> String { format!("age:signpub:{name}") }
|
||||||
|
fn sign_priv_key_key(name: &str) -> String { format!("age:signpriv:{name}") }
|
||||||
|
|
||||||
|
// ---------- Command handlers (RESP Protocol) ----------
|
||||||
|
// Basic (stateless) ones kept for completeness
|
||||||
|
|
||||||
|
pub async fn cmd_age_genenc() -> Protocol {
|
||||||
|
let (recip, ident) = gen_enc_keypair();
|
||||||
|
Protocol::Array(vec![Protocol::BulkString(recip), Protocol::BulkString(ident)])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_gensign() -> Protocol {
|
||||||
|
let (verify, secret) = gen_sign_keypair();
|
||||||
|
Protocol::Array(vec![Protocol::BulkString(verify), Protocol::BulkString(secret)])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_encrypt(recipient: &str, message: &str) -> Protocol {
|
||||||
|
match encrypt_b64(recipient, message) {
|
||||||
|
Ok(b64) => Protocol::BulkString(b64),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_decrypt(identity: &str, ct_b64: &str) -> Protocol {
|
||||||
|
match decrypt_b64(identity, ct_b64) {
|
||||||
|
Ok(pt) => Protocol::BulkString(pt),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_sign(secret: &str, message: &str) -> Protocol {
|
||||||
|
match sign_b64(secret, message) {
|
||||||
|
Ok(b64sig) => Protocol::BulkString(b64sig),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_verify(verify_pub: &str, message: &str, sig_b64: &str) -> Protocol {
|
||||||
|
match verify_b64(verify_pub, message, sig_b64) {
|
||||||
|
Ok(true) => Protocol::SimpleString("1".to_string()),
|
||||||
|
Ok(false) => Protocol::SimpleString("0".to_string()),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- NEW: Persistent, named-key commands ----------
|
||||||
|
|
||||||
|
pub async fn cmd_age_keygen(server: &Server, name: &str) -> Protocol {
|
||||||
|
let (recip, ident) = gen_enc_keypair();
|
||||||
|
if let Err(e) = sset(server, &enc_pub_key_key(name), &recip) { return e.to_protocol(); }
|
||||||
|
if let Err(e) = sset(server, &enc_priv_key_key(name), &ident) { return e.to_protocol(); }
|
||||||
|
Protocol::Array(vec![Protocol::BulkString(recip), Protocol::BulkString(ident)])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_signkeygen(server: &Server, name: &str) -> Protocol {
|
||||||
|
let (verify, secret) = gen_sign_keypair();
|
||||||
|
if let Err(e) = sset(server, &sign_pub_key_key(name), &verify) { return e.to_protocol(); }
|
||||||
|
if let Err(e) = sset(server, &sign_priv_key_key(name), &secret) { return e.to_protocol(); }
|
||||||
|
Protocol::Array(vec![Protocol::BulkString(verify), Protocol::BulkString(secret)])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_encrypt_name(server: &Server, name: &str, message: &str) -> Protocol {
|
||||||
|
let recip = match sget(server, &enc_pub_key_key(name)) {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
Ok(None) => return AgeWireError::NotFound("recipient (age:key:{name})").to_protocol(),
|
||||||
|
Err(e) => return e.to_protocol(),
|
||||||
|
};
|
||||||
|
match encrypt_b64(&recip, message) {
|
||||||
|
Ok(ct) => Protocol::BulkString(ct),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_decrypt_name(server: &Server, name: &str, ct_b64: &str) -> Protocol {
|
||||||
|
let ident = match sget(server, &enc_priv_key_key(name)) {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
Ok(None) => return AgeWireError::NotFound("identity (age:privkey:{name})").to_protocol(),
|
||||||
|
Err(e) => return e.to_protocol(),
|
||||||
|
};
|
||||||
|
match decrypt_b64(&ident, ct_b64) {
|
||||||
|
Ok(pt) => Protocol::BulkString(pt),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_sign_name(server: &Server, name: &str, message: &str) -> Protocol {
|
||||||
|
let sec = match sget(server, &sign_priv_key_key(name)) {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
Ok(None) => return AgeWireError::NotFound("signing secret (age:signpriv:{name})").to_protocol(),
|
||||||
|
Err(e) => return e.to_protocol(),
|
||||||
|
};
|
||||||
|
match sign_b64(&sec, message) {
|
||||||
|
Ok(sig) => Protocol::BulkString(sig),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_verify_name(server: &Server, name: &str, message: &str, sig_b64: &str) -> Protocol {
|
||||||
|
let pubk = match sget(server, &sign_pub_key_key(name)) {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
Ok(None) => return AgeWireError::NotFound("verify pubkey (age:signpub:{name})").to_protocol(),
|
||||||
|
Err(e) => return e.to_protocol(),
|
||||||
|
};
|
||||||
|
match verify_b64(&pubk, message, sig_b64) {
|
||||||
|
Ok(true) => Protocol::SimpleString("1".to_string()),
|
||||||
|
Ok(false) => Protocol::SimpleString("0".to_string()),
|
||||||
|
Err(e) => e.to_protocol(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_age_list(server: &Server) -> Protocol {
|
||||||
|
// Returns 4 arrays: ["encpub", <names...>], ["encpriv", ...], ["signpub", ...], ["signpriv", ...]
|
||||||
|
let st = match server.current_storage() { Ok(s) => s, Err(e) => return Protocol::err(&e.0) };
|
||||||
|
|
||||||
|
let pull = |pat: &str, prefix: &str| -> Result<Vec<String>, DBError> {
|
||||||
|
let keys = st.keys(pat)?;
|
||||||
|
let mut names: Vec<String> = keys.into_iter()
|
||||||
|
.filter_map(|k| k.strip_prefix(prefix).map(|x| x.to_string()))
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
Ok(names)
|
||||||
|
};
|
||||||
|
|
||||||
|
let encpub = match pull("age:key:*", "age:key:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
||||||
|
let encpriv = match pull("age:privkey:*", "age:privkey:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
||||||
|
let signpub = match pull("age:signpub:*", "age:signpub:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
||||||
|
let signpriv= match pull("age:signpriv:*", "age:signpriv:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
||||||
|
|
||||||
|
let to_arr = |label: &str, v: Vec<String>| {
|
||||||
|
let mut out = vec![Protocol::BulkString(label.to_string())];
|
||||||
|
out.push(Protocol::Array(v.into_iter().map(Protocol::BulkString).collect()));
|
||||||
|
Protocol::Array(out)
|
||||||
|
};
|
||||||
|
|
||||||
|
Protocol::Array(vec![
|
||||||
|
to_arr("encpub", encpub),
|
||||||
|
to_arr("encpriv", encpriv),
|
||||||
|
to_arr("signpub", signpub),
|
||||||
|
to_arr("signpriv", signpriv),
|
||||||
|
])
|
||||||
|
}
|
@@ -1,12 +1,13 @@
|
|||||||
use crate::{error::DBError, protocol::Protocol, server::Server};
|
use crate::{error::DBError, protocol::Protocol, server::Server, search_cmd};
|
||||||
use tokio::time::{timeout, Duration};
|
use tokio::time::{timeout, Duration};
|
||||||
use futures::future::select_all;
|
use futures::future::select_all;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Cmd {
|
pub enum Cmd {
|
||||||
Ping,
|
Ping,
|
||||||
Echo(String),
|
Echo(String),
|
||||||
Select(u64, Option<String>), // db_index, optional_key
|
Select(u64), // Changed from u16 to u64
|
||||||
Get(String),
|
Get(String),
|
||||||
Set(String, String),
|
Set(String, String),
|
||||||
SetPx(String, String, u128),
|
SetPx(String, String, u128),
|
||||||
@@ -71,13 +72,12 @@ pub enum Cmd {
|
|||||||
// AGE (rage) commands — stateless
|
// AGE (rage) commands — stateless
|
||||||
AgeGenEnc,
|
AgeGenEnc,
|
||||||
AgeGenSign,
|
AgeGenSign,
|
||||||
AgeGenKey, // unified stateless: returns [verify_b64, signpriv_b64, x25519_pub_b64, x25519_sec_b64]
|
|
||||||
AgeEncrypt(String, String), // recipient, message
|
AgeEncrypt(String, String), // recipient, message
|
||||||
AgeDecrypt(String, String), // identity, ciphertext_b64
|
AgeDecrypt(String, String), // identity, ciphertext_b64
|
||||||
AgeSign(String, String), // signing_secret, message
|
AgeSign(String, String), // signing_secret, message
|
||||||
AgeVerify(String, String, String), // verify_pub, message, signature_b64
|
AgeVerify(String, String, String), // verify_pub, message, signature_b64
|
||||||
|
|
||||||
// Persistent named-key commands
|
// NEW: persistent named-key commands
|
||||||
AgeKeygen(String), // name
|
AgeKeygen(String), // name
|
||||||
AgeSignKeygen(String), // name
|
AgeSignKeygen(String), // name
|
||||||
AgeEncryptName(String, String), // name, message
|
AgeEncryptName(String, String), // name, message
|
||||||
@@ -86,11 +86,40 @@ pub enum Cmd {
|
|||||||
AgeVerifyName(String, String, String), // name, message, signature_b64
|
AgeVerifyName(String, String, String), // name, message, signature_b64
|
||||||
AgeList,
|
AgeList,
|
||||||
|
|
||||||
// SYM (symmetric) commands — stateless
|
// Full-text search commands with schema support
|
||||||
// Raw 32-byte key provided as base64; ciphertext returned as base64
|
FtCreate {
|
||||||
SymKeygen,
|
index_name: String,
|
||||||
SymEncrypt(String, String), // key_b64, message
|
schema: Vec<(String, String, Vec<String>)>, // (field_name, field_type, options)
|
||||||
SymDecrypt(String, String), // key_b64, ciphertext_b64
|
},
|
||||||
|
FtAdd {
|
||||||
|
index_name: String,
|
||||||
|
doc_id: String,
|
||||||
|
score: f64,
|
||||||
|
fields: std::collections::HashMap<String, String>,
|
||||||
|
},
|
||||||
|
FtSearch {
|
||||||
|
index_name: String,
|
||||||
|
query: String,
|
||||||
|
filters: Vec<(String, String)>, // field, value pairs
|
||||||
|
limit: Option<usize>,
|
||||||
|
offset: Option<usize>,
|
||||||
|
return_fields: Option<Vec<String>>,
|
||||||
|
},
|
||||||
|
FtDel(String, String), // index_name, doc_id
|
||||||
|
FtInfo(String), // index_name
|
||||||
|
FtDrop(String), // index_name
|
||||||
|
FtAlter {
|
||||||
|
index_name: String,
|
||||||
|
field_name: String,
|
||||||
|
field_type: String,
|
||||||
|
options: Vec<String>,
|
||||||
|
},
|
||||||
|
FtAggregate {
|
||||||
|
index_name: String,
|
||||||
|
query: String,
|
||||||
|
group_by: Vec<String>,
|
||||||
|
reducers: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cmd {
|
impl Cmd {
|
||||||
@@ -105,18 +134,11 @@ impl Cmd {
|
|||||||
Ok((
|
Ok((
|
||||||
match cmd[0].to_lowercase().as_str() {
|
match cmd[0].to_lowercase().as_str() {
|
||||||
"select" => {
|
"select" => {
|
||||||
if cmd.len() < 2 || cmd.len() > 4 {
|
if cmd.len() != 2 {
|
||||||
return Err(DBError("wrong number of arguments for SELECT".to_string()));
|
return Err(DBError("wrong number of arguments for SELECT".to_string()));
|
||||||
}
|
}
|
||||||
let idx = cmd[1].parse::<u64>().map_err(|_| DBError("ERR DB index is not an integer".to_string()))?;
|
let idx = cmd[1].parse::<u64>().map_err(|_| DBError("ERR DB index is not an integer".to_string()))?;
|
||||||
let key = if cmd.len() == 4 && cmd[2].to_lowercase() == "key" {
|
Cmd::Select(idx)
|
||||||
Some(cmd[3].clone())
|
|
||||||
} else if cmd.len() == 2 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
return Err(DBError("ERR syntax error".to_string()));
|
|
||||||
};
|
|
||||||
Cmd::Select(idx, key)
|
|
||||||
}
|
}
|
||||||
"echo" => Cmd::Echo(cmd[1].clone()),
|
"echo" => Cmd::Echo(cmd[1].clone()),
|
||||||
"ping" => Cmd::Ping,
|
"ping" => Cmd::Ping,
|
||||||
@@ -603,8 +625,6 @@ impl Cmd {
|
|||||||
Cmd::AgeGenEnc }
|
Cmd::AgeGenEnc }
|
||||||
"gensign" => { if cmd.len() != 2 { return Err(DBError("AGE GENSIGN takes no args".to_string())); }
|
"gensign" => { if cmd.len() != 2 { return Err(DBError("AGE GENSIGN takes no args".to_string())); }
|
||||||
Cmd::AgeGenSign }
|
Cmd::AgeGenSign }
|
||||||
"genkey" => { if cmd.len() != 2 { return Err(DBError("AGE GENKEY takes no args".to_string())); }
|
|
||||||
Cmd::AgeGenKey }
|
|
||||||
"encrypt" => { if cmd.len() != 4 { return Err(DBError("AGE ENCRYPT <recipient> <message>".to_string())); }
|
"encrypt" => { if cmd.len() != 4 { return Err(DBError("AGE ENCRYPT <recipient> <message>".to_string())); }
|
||||||
Cmd::AgeEncrypt(cmd[2].clone(), cmd[3].clone()) }
|
Cmd::AgeEncrypt(cmd[2].clone(), cmd[3].clone()) }
|
||||||
"decrypt" => { if cmd.len() != 4 { return Err(DBError("AGE DECRYPT <identity> <ciphertext_b64>".to_string())); }
|
"decrypt" => { if cmd.len() != 4 { return Err(DBError("AGE DECRYPT <identity> <ciphertext_b64>".to_string())); }
|
||||||
@@ -632,20 +652,148 @@ impl Cmd {
|
|||||||
_ => return Err(DBError(format!("unsupported AGE subcommand {:?}", cmd))),
|
_ => return Err(DBError(format!("unsupported AGE subcommand {:?}", cmd))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"sym" => {
|
"ft.create" => {
|
||||||
if cmd.len() < 2 {
|
if cmd.len() < 4 || cmd[2].to_uppercase() != "SCHEMA" {
|
||||||
return Err(DBError("wrong number of arguments for SYM".to_string()));
|
return Err(DBError("ERR FT.CREATE requires: indexname SCHEMA field1 type1 [options] ...".to_string()));
|
||||||
}
|
}
|
||||||
match cmd[1].to_lowercase().as_str() {
|
|
||||||
"keygen" => { if cmd.len() != 2 { return Err(DBError("SYM KEYGEN takes no args".to_string())); }
|
let index_name = cmd[1].clone();
|
||||||
Cmd::SymKeygen }
|
let mut schema = Vec::new();
|
||||||
"encrypt" => { if cmd.len() != 4 { return Err(DBError("SYM ENCRYPT <key_b64> <message>".to_string())); }
|
let mut i = 3;
|
||||||
Cmd::SymEncrypt(cmd[2].clone(), cmd[3].clone()) }
|
|
||||||
"decrypt" => { if cmd.len() != 4 { return Err(DBError("SYM DECRYPT <key_b64> <ciphertext_b64>".to_string())); }
|
while i < cmd.len() {
|
||||||
Cmd::SymDecrypt(cmd[2].clone(), cmd[3].clone()) }
|
if i + 1 >= cmd.len() {
|
||||||
_ => return Err(DBError(format!("unsupported SYM subcommand {:?}", cmd))),
|
return Err(DBError("ERR incomplete field definition".to_string()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
let field_name = cmd[i].clone();
|
||||||
|
let field_type = cmd[i + 1].to_uppercase();
|
||||||
|
let mut options = Vec::new();
|
||||||
|
i += 2;
|
||||||
|
|
||||||
|
// Parse field options until we hit another field name or end
|
||||||
|
while i < cmd.len() && !["TEXT", "NUMERIC", "TAG", "GEO"].contains(&cmd[i].to_uppercase().as_str()) {
|
||||||
|
options.push(cmd[i].to_uppercase());
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
// If this option takes a value, consume it too
|
||||||
|
if i > 0 && ["SEPARATOR", "WEIGHT"].contains(&cmd[i-1].to_uppercase().as_str()) && i < cmd.len() {
|
||||||
|
options.push(cmd[i].clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema.push((field_name, field_type, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::FtCreate {
|
||||||
|
index_name,
|
||||||
|
schema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ft.add" => {
|
||||||
|
if cmd.len() < 5 {
|
||||||
|
return Err(DBError("ERR FT.ADD requires: index_name doc_id score field value ...".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let index_name = cmd[1].clone();
|
||||||
|
let doc_id = cmd[2].clone();
|
||||||
|
let score = cmd[3].parse::<f64>()
|
||||||
|
.map_err(|_| DBError("ERR score must be a number".to_string()))?;
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
let mut i = 4;
|
||||||
|
|
||||||
|
while i + 1 < cmd.len() {
|
||||||
|
fields.insert(cmd[i].clone(), cmd[i + 1].clone());
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::FtAdd {
|
||||||
|
index_name,
|
||||||
|
doc_id,
|
||||||
|
score,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ft.search" => {
|
||||||
|
if cmd.len() < 3 {
|
||||||
|
return Err(DBError("ERR FT.SEARCH requires: index_name query [options]".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let index_name = cmd[1].clone();
|
||||||
|
let query = cmd[2].clone();
|
||||||
|
|
||||||
|
let mut filters = Vec::new();
|
||||||
|
let mut limit = None;
|
||||||
|
let mut offset = None;
|
||||||
|
let mut return_fields = None;
|
||||||
|
|
||||||
|
let mut i = 3;
|
||||||
|
while i < cmd.len() {
|
||||||
|
match cmd[i].to_uppercase().as_str() {
|
||||||
|
"FILTER" => {
|
||||||
|
if i + 3 >= cmd.len() {
|
||||||
|
return Err(DBError("ERR FILTER requires field and value".to_string()));
|
||||||
|
}
|
||||||
|
filters.push((cmd[i + 1].clone(), cmd[i + 2].clone()));
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
"LIMIT" => {
|
||||||
|
if i + 2 >= cmd.len() {
|
||||||
|
return Err(DBError("ERR LIMIT requires offset and num".to_string()));
|
||||||
|
}
|
||||||
|
offset = Some(cmd[i + 1].parse().unwrap_or(0));
|
||||||
|
limit = Some(cmd[i + 2].parse().unwrap_or(10));
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
"RETURN" => {
|
||||||
|
if i + 1 >= cmd.len() {
|
||||||
|
return Err(DBError("ERR RETURN requires field count".to_string()));
|
||||||
|
}
|
||||||
|
let count: usize = cmd[i + 1].parse().unwrap_or(0);
|
||||||
|
i += 2;
|
||||||
|
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
for _ in 0..count {
|
||||||
|
if i < cmd.len() {
|
||||||
|
fields.push(cmd[i].clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return_fields = Some(fields);
|
||||||
|
}
|
||||||
|
_ => i += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::FtSearch {
|
||||||
|
index_name,
|
||||||
|
query,
|
||||||
|
filters,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
return_fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ft.del" => {
|
||||||
|
if cmd.len() != 3 {
|
||||||
|
return Err(DBError("ERR FT.DEL requires: index_name doc_id".to_string()));
|
||||||
|
}
|
||||||
|
Cmd::FtDel(cmd[1].clone(), cmd[2].clone())
|
||||||
|
}
|
||||||
|
"ft.info" => {
|
||||||
|
if cmd.len() != 2 {
|
||||||
|
return Err(DBError("ERR FT.INFO requires: index_name".to_string()));
|
||||||
|
}
|
||||||
|
Cmd::FtInfo(cmd[1].clone())
|
||||||
|
}
|
||||||
|
"ft.drop" => {
|
||||||
|
if cmd.len() != 2 {
|
||||||
|
return Err(DBError("ERR FT.DROP requires: index_name".to_string()));
|
||||||
|
}
|
||||||
|
Cmd::FtDrop(cmd[1].clone())
|
||||||
|
}
|
||||||
_ => Cmd::Unknow(cmd[0].clone()),
|
_ => Cmd::Unknow(cmd[0].clone()),
|
||||||
},
|
},
|
||||||
protocol,
|
protocol,
|
||||||
@@ -672,7 +820,7 @@ impl Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
Cmd::Select(db, key) => select_cmd(server, db, key).await,
|
Cmd::Select(db) => select_cmd(server, db).await,
|
||||||
Cmd::Ping => Ok(Protocol::SimpleString("PONG".to_string())),
|
Cmd::Ping => Ok(Protocol::SimpleString("PONG".to_string())),
|
||||||
Cmd::Echo(s) => Ok(Protocol::BulkString(s)),
|
Cmd::Echo(s) => Ok(Protocol::BulkString(s)),
|
||||||
Cmd::Get(k) => get_cmd(server, &k).await,
|
Cmd::Get(k) => get_cmd(server, &k).await,
|
||||||
@@ -747,7 +895,6 @@ impl Cmd {
|
|||||||
// AGE (rage): stateless
|
// AGE (rage): stateless
|
||||||
Cmd::AgeGenEnc => Ok(crate::age::cmd_age_genenc().await),
|
Cmd::AgeGenEnc => Ok(crate::age::cmd_age_genenc().await),
|
||||||
Cmd::AgeGenSign => Ok(crate::age::cmd_age_gensign().await),
|
Cmd::AgeGenSign => Ok(crate::age::cmd_age_gensign().await),
|
||||||
Cmd::AgeGenKey => Ok(crate::age::cmd_age_genkey().await),
|
|
||||||
Cmd::AgeEncrypt(recipient, message) => Ok(crate::age::cmd_age_encrypt(&recipient, &message).await),
|
Cmd::AgeEncrypt(recipient, message) => Ok(crate::age::cmd_age_encrypt(&recipient, &message).await),
|
||||||
Cmd::AgeDecrypt(identity, ct_b64) => Ok(crate::age::cmd_age_decrypt(&identity, &ct_b64).await),
|
Cmd::AgeDecrypt(identity, ct_b64) => Ok(crate::age::cmd_age_decrypt(&identity, &ct_b64).await),
|
||||||
Cmd::AgeSign(secret, message) => Ok(crate::age::cmd_age_sign(&secret, &message).await),
|
Cmd::AgeSign(secret, message) => Ok(crate::age::cmd_age_sign(&secret, &message).await),
|
||||||
@@ -762,25 +909,40 @@ impl Cmd {
|
|||||||
Cmd::AgeVerifyName(name, message, sig_b64) => Ok(crate::age::cmd_age_verify_name(server, &name, &message, &sig_b64).await),
|
Cmd::AgeVerifyName(name, message, sig_b64) => Ok(crate::age::cmd_age_verify_name(server, &name, &message, &sig_b64).await),
|
||||||
Cmd::AgeList => Ok(crate::age::cmd_age_list(server).await),
|
Cmd::AgeList => Ok(crate::age::cmd_age_list(server).await),
|
||||||
|
|
||||||
// SYM (symmetric): stateless (Phase 1)
|
// Full-text search commands
|
||||||
Cmd::SymKeygen => Ok(crate::sym::cmd_sym_keygen().await),
|
Cmd::FtCreate { index_name, schema } => {
|
||||||
Cmd::SymEncrypt(key_b64, message) => Ok(crate::sym::cmd_sym_encrypt(&key_b64, &message).await),
|
search_cmd::ft_create_cmd(server, index_name, schema).await
|
||||||
Cmd::SymDecrypt(key_b64, ct_b64) => Ok(crate::sym::cmd_sym_decrypt(&key_b64, &ct_b64).await),
|
}
|
||||||
|
Cmd::FtAdd { index_name, doc_id, score, fields } => {
|
||||||
|
search_cmd::ft_add_cmd(server, index_name, doc_id, score, fields).await
|
||||||
|
}
|
||||||
|
Cmd::FtSearch { index_name, query, filters, limit, offset, return_fields } => {
|
||||||
|
search_cmd::ft_search_cmd(server, index_name, query, filters, limit, offset, return_fields).await
|
||||||
|
}
|
||||||
|
Cmd::FtDel(index_name, doc_id) => {
|
||||||
|
search_cmd::ft_del_cmd(server, index_name, doc_id).await
|
||||||
|
}
|
||||||
|
Cmd::FtInfo(index_name) => {
|
||||||
|
search_cmd::ft_info_cmd(server, index_name).await
|
||||||
|
}
|
||||||
|
Cmd::FtDrop(index_name) => {
|
||||||
|
search_cmd::ft_drop_cmd(server, index_name).await
|
||||||
|
}
|
||||||
|
Cmd::FtAlter { .. } => {
|
||||||
|
// Not implemented yet
|
||||||
|
Ok(Protocol::err("FT.ALTER not implemented yet"))
|
||||||
|
}
|
||||||
|
Cmd::FtAggregate { .. } => {
|
||||||
|
// Not implemented yet
|
||||||
|
Ok(Protocol::err("FT.AGGREGATE not implemented yet"))
|
||||||
|
}
|
||||||
Cmd::Unknow(s) => Ok(Protocol::err(&format!("ERR unknown command `{}`", s))),
|
Cmd::Unknow(s) => Ok(Protocol::err(&format!("ERR unknown command `{}`", s))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_protocol(self) -> Protocol {
|
pub fn to_protocol(self) -> Protocol {
|
||||||
match self {
|
match self {
|
||||||
Cmd::Select(db, key) => {
|
Cmd::Select(db) => Protocol::Array(vec![Protocol::BulkString("select".to_string()), Protocol::BulkString(db.to_string())]),
|
||||||
let mut arr = vec![Protocol::BulkString("select".to_string()), Protocol::BulkString(db.to_string())];
|
|
||||||
if let Some(k) = key {
|
|
||||||
arr.push(Protocol::BulkString("key".to_string()));
|
|
||||||
arr.push(Protocol::BulkString(k));
|
|
||||||
}
|
|
||||||
Protocol::Array(arr)
|
|
||||||
}
|
|
||||||
Cmd::Ping => Protocol::Array(vec![Protocol::BulkString("ping".to_string())]),
|
Cmd::Ping => Protocol::Array(vec![Protocol::BulkString("ping".to_string())]),
|
||||||
Cmd::Echo(s) => Protocol::Array(vec![Protocol::BulkString("echo".to_string()), Protocol::BulkString(s)]),
|
Cmd::Echo(s) => Protocol::Array(vec![Protocol::BulkString("echo".to_string()), Protocol::BulkString(s)]),
|
||||||
Cmd::Get(k) => Protocol::Array(vec![Protocol::BulkString("get".to_string()), Protocol::BulkString(k)]),
|
Cmd::Get(k) => Protocol::Array(vec![Protocol::BulkString("get".to_string()), Protocol::BulkString(k)]),
|
||||||
@@ -797,65 +959,9 @@ async fn flushdb_cmd(server: &mut Server) -> Result<Protocol, DBError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn select_cmd(server: &mut Server, db: u64, key: Option<String>) -> Result<Protocol, DBError> {
|
async fn select_cmd(server: &mut Server, db: u64) -> Result<Protocol, DBError> {
|
||||||
// Authorization and existence checks via admin DB 0
|
// Test if we can access the database (this will create it if needed)
|
||||||
// DB 0: require KEY admin-secret
|
|
||||||
if db == 0 {
|
|
||||||
match key {
|
|
||||||
Some(k) if k == server.option.admin_secret => {
|
|
||||||
server.selected_db = 0;
|
|
||||||
server.current_permissions = Some(crate::rpc::Permissions::ReadWrite);
|
|
||||||
// Will create encrypted 0.db if missing
|
|
||||||
match server.current_storage() {
|
|
||||||
Ok(_) => return Ok(Protocol::SimpleString("OK".to_string())),
|
|
||||||
Err(e) => return Ok(Protocol::err(&e.0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Ok(Protocol::err("ERR invalid access key"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB > 0: must exist in admin:dbs
|
|
||||||
let exists = match crate::admin_meta::db_exists(
|
|
||||||
&server.option.dir,
|
|
||||||
server.option.backend.clone(),
|
|
||||||
&server.option.admin_secret,
|
|
||||||
db,
|
|
||||||
) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => return Ok(Protocol::err(&e.0)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return Ok(Protocol::err(&format!(
|
|
||||||
"Cannot open database instance {}, as that database instance does not exist.",
|
|
||||||
db
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify permissions (public => RW; private => use key)
|
|
||||||
let perms_opt = match crate::admin_meta::verify_access(
|
|
||||||
&server.option.dir,
|
|
||||||
server.option.backend.clone(),
|
|
||||||
&server.option.admin_secret,
|
|
||||||
db,
|
|
||||||
key.as_deref(),
|
|
||||||
) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => return Ok(Protocol::err(&e.0)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let perms = match perms_opt {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return Ok(Protocol::err("ERR invalid access key")),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set selected database and permissions, then open storage
|
|
||||||
server.selected_db = db;
|
server.selected_db = db;
|
||||||
server.current_permissions = Some(perms);
|
|
||||||
|
|
||||||
match server.current_storage() {
|
match server.current_storage() {
|
||||||
Ok(_) => Ok(Protocol::SimpleString("OK".to_string())),
|
Ok(_) => Ok(Protocol::SimpleString("OK".to_string())),
|
||||||
Err(e) => Ok(Protocol::err(&e.0)),
|
Err(e) => Ok(Protocol::err(&e.0)),
|
||||||
@@ -1103,9 +1209,6 @@ async fn brpop_cmd(server: &Server, keys: &[String], timeout_secs: f64) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn lpush_cmd(server: &Server, key: &str, elements: &[String]) -> Result<Protocol, DBError> {
|
async fn lpush_cmd(server: &Server, key: &str, elements: &[String]) -> Result<Protocol, DBError> {
|
||||||
if !server.has_write_permission() {
|
|
||||||
return Ok(Protocol::err("ERR write permission denied"));
|
|
||||||
}
|
|
||||||
match server.current_storage()?.lpush(key, elements.to_vec()) {
|
match server.current_storage()?.lpush(key, elements.to_vec()) {
|
||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
// Attempt to deliver to any blocked BLPOP waiters
|
// Attempt to deliver to any blocked BLPOP waiters
|
||||||
@@ -1237,16 +1340,8 @@ async fn type_cmd(server: &Server, k: &String) -> Result<Protocol, DBError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn del_cmd(server: &Server, k: &str) -> Result<Protocol, DBError> {
|
async fn del_cmd(server: &Server, k: &str) -> Result<Protocol, DBError> {
|
||||||
if !server.has_write_permission() {
|
server.current_storage()?.del(k.to_string())?;
|
||||||
return Ok(Protocol::err("ERR write permission denied"));
|
Ok(Protocol::SimpleString("1".to_string()))
|
||||||
}
|
|
||||||
let storage = server.current_storage()?;
|
|
||||||
if storage.exists(k)? {
|
|
||||||
storage.del(k.to_string())?;
|
|
||||||
Ok(Protocol::SimpleString("1".to_string()))
|
|
||||||
} else {
|
|
||||||
Ok(Protocol::SimpleString("0".to_string()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_ex_cmd(
|
async fn set_ex_cmd(
|
||||||
@@ -1270,9 +1365,6 @@ async fn set_px_cmd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn set_cmd(server: &Server, k: &str, v: &str) -> Result<Protocol, DBError> {
|
async fn set_cmd(server: &Server, k: &str, v: &str) -> Result<Protocol, DBError> {
|
||||||
if !server.has_write_permission() {
|
|
||||||
return Ok(Protocol::err("ERR write permission denied"));
|
|
||||||
}
|
|
||||||
server.current_storage()?.set(k.to_string(), v.to_string())?;
|
server.current_storage()?.set(k.to_string(), v.to_string())?;
|
||||||
Ok(Protocol::SimpleString("OK".to_string()))
|
Ok(Protocol::SimpleString("OK".to_string()))
|
||||||
}
|
}
|
||||||
@@ -1357,9 +1449,6 @@ async fn mset_cmd(server: &Server, pairs: &[(String, String)]) -> Result<Protoco
|
|||||||
|
|
||||||
// DEL with multiple keys: return count of keys actually deleted
|
// DEL with multiple keys: return count of keys actually deleted
|
||||||
async fn del_multi_cmd(server: &Server, keys: &[String]) -> Result<Protocol, DBError> {
|
async fn del_multi_cmd(server: &Server, keys: &[String]) -> Result<Protocol, DBError> {
|
||||||
if !server.has_write_permission() {
|
|
||||||
return Ok(Protocol::err("ERR write permission denied"));
|
|
||||||
}
|
|
||||||
let storage = server.current_storage()?;
|
let storage = server.current_storage()?;
|
||||||
let mut deleted = 0i64;
|
let mut deleted = 0i64;
|
||||||
for k in keys {
|
for k in keys {
|
||||||
@@ -1390,9 +1479,6 @@ async fn get_cmd(server: &Server, k: &str) -> Result<Protocol, DBError> {
|
|||||||
|
|
||||||
// Hash command implementations
|
// Hash command implementations
|
||||||
async fn hset_cmd(server: &Server, key: &str, pairs: &[(String, String)]) -> Result<Protocol, DBError> {
|
async fn hset_cmd(server: &Server, key: &str, pairs: &[(String, String)]) -> Result<Protocol, DBError> {
|
||||||
if !server.has_write_permission() {
|
|
||||||
return Ok(Protocol::err("ERR write permission denied"));
|
|
||||||
}
|
|
||||||
let new_fields = server.current_storage()?.hset(key, pairs.to_vec())?;
|
let new_fields = server.current_storage()?.hset(key, pairs.to_vec())?;
|
||||||
Ok(Protocol::SimpleString(new_fields.to_string()))
|
Ok(Protocol::SimpleString(new_fields.to_string()))
|
||||||
}
|
}
|
12
herodb/src/lib.rs
Normal file
12
herodb/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod age; // NEW
|
||||||
|
pub mod cmd;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod options;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod search_cmd; // Add this
|
||||||
|
pub mod server;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod storage_trait; // Add this
|
||||||
|
pub mod storage_sled; // Add this
|
||||||
|
pub mod tantivy_search;
|
@@ -3,7 +3,6 @@
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use herodb::server;
|
use herodb::server;
|
||||||
use herodb::rpc_server;
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
@@ -23,29 +22,18 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
debug: bool,
|
debug: bool,
|
||||||
|
|
||||||
/// Master encryption key for encrypted databases (deprecated; ignored for data DBs)
|
|
||||||
|
/// Master encryption key for encrypted databases
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
encryption_key: Option<String>,
|
encryption_key: Option<String>,
|
||||||
|
|
||||||
/// Encrypt the database (deprecated; ignored for data DBs)
|
/// Encrypt the database
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
encrypt: bool,
|
encrypt: bool,
|
||||||
|
|
||||||
/// Enable RPC management server
|
|
||||||
#[arg(long)]
|
|
||||||
enable_rpc: bool,
|
|
||||||
|
|
||||||
/// RPC server port (default: 8080)
|
|
||||||
#[arg(long, default_value = "8080")]
|
|
||||||
rpc_port: u16,
|
|
||||||
|
|
||||||
/// Use the sled backend
|
/// Use the sled backend
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
sled: bool,
|
sled: bool,
|
||||||
|
|
||||||
/// Admin secret used to encrypt DB 0 and authorize admin access (required)
|
|
||||||
#[arg(long)]
|
|
||||||
admin_secret: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -60,19 +48,9 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// deprecation warnings for legacy flags
|
|
||||||
if args.encrypt || args.encryption_key.is_some() {
|
|
||||||
eprintln!("warning: --encrypt and --encryption-key are deprecated and ignored for data DBs. Admin DB 0 is always encrypted with --admin-secret.");
|
|
||||||
}
|
|
||||||
// basic validation for admin secret
|
|
||||||
if args.admin_secret.trim().is_empty() {
|
|
||||||
eprintln!("error: --admin-secret must not be empty");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// new DB option
|
// new DB option
|
||||||
let option = herodb::options::DBOption {
|
let option = herodb::options::DBOption {
|
||||||
dir: args.dir.clone(),
|
dir: args.dir,
|
||||||
port,
|
port,
|
||||||
debug: args.debug,
|
debug: args.debug,
|
||||||
encryption_key: args.encryption_key,
|
encryption_key: args.encryption_key,
|
||||||
@@ -82,42 +60,14 @@ async fn main() {
|
|||||||
} else {
|
} else {
|
||||||
herodb::options::BackendType::Redb
|
herodb::options::BackendType::Redb
|
||||||
},
|
},
|
||||||
admin_secret: args.admin_secret.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = option.backend.clone();
|
|
||||||
|
|
||||||
// Bootstrap admin DB 0 before opening any server storage
|
|
||||||
if let Err(e) = herodb::admin_meta::ensure_bootstrap(&args.dir, backend.clone(), &args.admin_secret) {
|
|
||||||
eprintln!("Failed to bootstrap admin DB 0: {}", e.0);
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// new server
|
// new server
|
||||||
let server = server::Server::new(option).await;
|
let server = server::Server::new(option).await;
|
||||||
|
|
||||||
// Add a small delay to ensure the port is ready
|
// Add a small delay to ensure the port is ready
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
// Start RPC server if enabled
|
|
||||||
let _rpc_handle = if args.enable_rpc {
|
|
||||||
let rpc_addr = format!("127.0.0.1:{}", args.rpc_port).parse().unwrap();
|
|
||||||
let base_dir = args.dir.clone();
|
|
||||||
|
|
||||||
match rpc_server::start_rpc_server(rpc_addr, base_dir, backend, args.admin_secret.clone()).await {
|
|
||||||
Ok(handle) => {
|
|
||||||
println!("RPC management server started on port {}", args.rpc_port);
|
|
||||||
Some(handle)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to start RPC server: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// accept new connections
|
// accept new connections
|
||||||
loop {
|
loop {
|
||||||
let stream = listener.accept().await;
|
let stream = listener.accept().await;
|
@@ -9,11 +9,7 @@ pub struct DBOption {
|
|||||||
pub dir: String,
|
pub dir: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
// Deprecated for data DBs; retained for backward-compat on CLI parsing
|
|
||||||
pub encrypt: bool,
|
pub encrypt: bool,
|
||||||
// Deprecated for data DBs; retained for backward-compat on CLI parsing
|
|
||||||
pub encryption_key: Option<String>,
|
pub encryption_key: Option<String>,
|
||||||
pub backend: BackendType,
|
pub backend: BackendType,
|
||||||
// New: required admin secret, used to encrypt DB 0 and authorize admin operations
|
|
||||||
pub admin_secret: String,
|
|
||||||
}
|
}
|
272
herodb/src/search_cmd.rs
Normal file
272
herodb/src/search_cmd.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
use crate::{
|
||||||
|
error::DBError,
|
||||||
|
protocol::Protocol,
|
||||||
|
server::Server,
|
||||||
|
tantivy_search::{
|
||||||
|
TantivySearch, FieldDef, NumericType, IndexConfig,
|
||||||
|
SearchOptions, Filter, FilterType
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub async fn ft_create_cmd(
|
||||||
|
server: &Server,
|
||||||
|
index_name: String,
|
||||||
|
schema: Vec<(String, String, Vec<String>)>,
|
||||||
|
) -> Result<Protocol, DBError> {
|
||||||
|
// Parse schema into field definitions
|
||||||
|
let mut field_definitions = Vec::new();
|
||||||
|
|
||||||
|
for (field_name, field_type, options) in schema {
|
||||||
|
let field_def = match field_type.to_uppercase().as_str() {
|
||||||
|
"TEXT" => {
|
||||||
|
let mut weight = 1.0;
|
||||||
|
let mut sortable = false;
|
||||||
|
let mut no_index = false;
|
||||||
|
|
||||||
|
for opt in &options {
|
||||||
|
match opt.to_uppercase().as_str() {
|
||||||
|
"WEIGHT" => {
|
||||||
|
// Next option should be the weight value
|
||||||
|
if let Some(idx) = options.iter().position(|x| x == opt) {
|
||||||
|
if idx + 1 < options.len() {
|
||||||
|
weight = options[idx + 1].parse().unwrap_or(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"SORTABLE" => sortable = true,
|
||||||
|
"NOINDEX" => no_index = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldDef::Text {
|
||||||
|
stored: true,
|
||||||
|
indexed: !no_index,
|
||||||
|
tokenized: true,
|
||||||
|
fast: sortable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"NUMERIC" => {
|
||||||
|
let mut sortable = false;
|
||||||
|
|
||||||
|
for opt in &options {
|
||||||
|
if opt.to_uppercase() == "SORTABLE" {
|
||||||
|
sortable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldDef::Numeric {
|
||||||
|
stored: true,
|
||||||
|
indexed: true,
|
||||||
|
fast: sortable,
|
||||||
|
precision: NumericType::F64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"TAG" => {
|
||||||
|
let mut separator = ",".to_string();
|
||||||
|
let mut case_sensitive = false;
|
||||||
|
|
||||||
|
for i in 0..options.len() {
|
||||||
|
match options[i].to_uppercase().as_str() {
|
||||||
|
"SEPARATOR" => {
|
||||||
|
if i + 1 < options.len() {
|
||||||
|
separator = options[i + 1].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"CASESENSITIVE" => case_sensitive = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldDef::Tag {
|
||||||
|
stored: true,
|
||||||
|
separator,
|
||||||
|
case_sensitive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"GEO" => {
|
||||||
|
FieldDef::Geo { stored: true }
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(DBError(format!("Unknown field type: {}", field_type)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
field_definitions.push((field_name, field_def));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the search index
|
||||||
|
let search_path = server.search_index_path();
|
||||||
|
let config = IndexConfig::default();
|
||||||
|
|
||||||
|
println!("Creating search index '{}' at path: {:?}", index_name, search_path);
|
||||||
|
println!("Field definitions: {:?}", field_definitions);
|
||||||
|
|
||||||
|
let search_index = TantivySearch::new_with_schema(
|
||||||
|
search_path,
|
||||||
|
index_name.clone(),
|
||||||
|
field_definitions,
|
||||||
|
Some(config),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!("Search index '{}' created successfully", index_name);
|
||||||
|
|
||||||
|
// Store in registry
|
||||||
|
let mut indexes = server.search_indexes.write().unwrap();
|
||||||
|
indexes.insert(index_name, Arc::new(search_index));
|
||||||
|
|
||||||
|
Ok(Protocol::SimpleString("OK".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ft_add_cmd(
|
||||||
|
server: &Server,
|
||||||
|
index_name: String,
|
||||||
|
doc_id: String,
|
||||||
|
_score: f64,
|
||||||
|
fields: HashMap<String, String>,
|
||||||
|
) -> Result<Protocol, DBError> {
|
||||||
|
let indexes = server.search_indexes.read().unwrap();
|
||||||
|
|
||||||
|
let search_index = indexes.get(&index_name)
|
||||||
|
.ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
|
||||||
|
|
||||||
|
search_index.add_document_with_fields(&doc_id, fields)?;
|
||||||
|
|
||||||
|
Ok(Protocol::SimpleString("OK".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ft_search_cmd(
|
||||||
|
server: &Server,
|
||||||
|
index_name: String,
|
||||||
|
query: String,
|
||||||
|
filters: Vec<(String, String)>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
offset: Option<usize>,
|
||||||
|
return_fields: Option<Vec<String>>,
|
||||||
|
) -> Result<Protocol, DBError> {
|
||||||
|
let indexes = server.search_indexes.read().unwrap();
|
||||||
|
|
||||||
|
let search_index = indexes.get(&index_name)
|
||||||
|
.ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
|
||||||
|
|
||||||
|
// Convert filters to search filters
|
||||||
|
let search_filters = filters.into_iter().map(|(field, value)| {
|
||||||
|
Filter {
|
||||||
|
field,
|
||||||
|
filter_type: FilterType::Equals(value),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let options = SearchOptions {
|
||||||
|
limit: limit.unwrap_or(10),
|
||||||
|
offset: offset.unwrap_or(0),
|
||||||
|
filters: search_filters,
|
||||||
|
sort_by: None,
|
||||||
|
return_fields,
|
||||||
|
highlight: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = search_index.search_with_options(&query, options)?;
|
||||||
|
|
||||||
|
// Format results as Redis protocol
|
||||||
|
let mut response = Vec::new();
|
||||||
|
|
||||||
|
// First element is the total count
|
||||||
|
response.push(Protocol::SimpleString(results.total.to_string()));
|
||||||
|
|
||||||
|
// Then each document
|
||||||
|
for doc in results.documents {
|
||||||
|
let mut doc_array = Vec::new();
|
||||||
|
|
||||||
|
// Add document ID if it exists
|
||||||
|
if let Some(id) = doc.fields.get("_id") {
|
||||||
|
doc_array.push(Protocol::BulkString(id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add score
|
||||||
|
doc_array.push(Protocol::BulkString(doc.score.to_string()));
|
||||||
|
|
||||||
|
// Add fields as key-value pairs
|
||||||
|
for (field_name, field_value) in doc.fields {
|
||||||
|
if field_name != "_id" {
|
||||||
|
doc_array.push(Protocol::BulkString(field_name));
|
||||||
|
doc_array.push(Protocol::BulkString(field_value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.push(Protocol::Array(doc_array));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Protocol::Array(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ft_del_cmd(
|
||||||
|
server: &Server,
|
||||||
|
index_name: String,
|
||||||
|
doc_id: String,
|
||||||
|
) -> Result<Protocol, DBError> {
|
||||||
|
let indexes = server.search_indexes.read().unwrap();
|
||||||
|
|
||||||
|
let _search_index = indexes.get(&index_name)
|
||||||
|
.ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
|
||||||
|
|
||||||
|
// For now, return success
|
||||||
|
// In a full implementation, we'd need to add a delete method to TantivySearch
|
||||||
|
println!("Deleting document '{}' from index '{}'", doc_id, index_name);
|
||||||
|
|
||||||
|
Ok(Protocol::SimpleString("1".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ft_info_cmd(
|
||||||
|
server: &Server,
|
||||||
|
index_name: String,
|
||||||
|
) -> Result<Protocol, DBError> {
|
||||||
|
let indexes = server.search_indexes.read().unwrap();
|
||||||
|
|
||||||
|
let search_index = indexes.get(&index_name)
|
||||||
|
.ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
|
||||||
|
|
||||||
|
let info = search_index.get_info()?;
|
||||||
|
|
||||||
|
// Format info as Redis protocol
|
||||||
|
let mut response = Vec::new();
|
||||||
|
|
||||||
|
response.push(Protocol::BulkString("index_name".to_string()));
|
||||||
|
response.push(Protocol::BulkString(info.name));
|
||||||
|
|
||||||
|
response.push(Protocol::BulkString("num_docs".to_string()));
|
||||||
|
response.push(Protocol::BulkString(info.num_docs.to_string()));
|
||||||
|
|
||||||
|
response.push(Protocol::BulkString("num_fields".to_string()));
|
||||||
|
response.push(Protocol::BulkString(info.fields.len().to_string()));
|
||||||
|
|
||||||
|
response.push(Protocol::BulkString("fields".to_string()));
|
||||||
|
let fields_str = info.fields.iter()
|
||||||
|
.map(|f| format!("{}:{}", f.name, f.field_type))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
response.push(Protocol::BulkString(fields_str));
|
||||||
|
|
||||||
|
Ok(Protocol::Array(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ft_drop_cmd(
|
||||||
|
server: &Server,
|
||||||
|
index_name: String,
|
||||||
|
) -> Result<Protocol, DBError> {
|
||||||
|
let mut indexes = server.search_indexes.write().unwrap();
|
||||||
|
|
||||||
|
if indexes.remove(&index_name).is_some() {
|
||||||
|
// Also remove the index files from disk
|
||||||
|
let index_path = server.search_index_path().join(&index_name);
|
||||||
|
if index_path.exists() {
|
||||||
|
std::fs::remove_dir_all(index_path)
|
||||||
|
.map_err(|e| DBError(format!("Failed to remove index files: {}", e)))?;
|
||||||
|
}
|
||||||
|
Ok(Protocol::SimpleString("OK".to_string()))
|
||||||
|
} else {
|
||||||
|
Err(DBError(format!("Index '{}' not found", index_name)))
|
||||||
|
}
|
||||||
|
}
|
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::sync::{Mutex, oneshot};
|
use tokio::sync::{Mutex, oneshot};
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
@@ -11,17 +12,19 @@ use crate::cmd::Cmd;
|
|||||||
use crate::error::DBError;
|
use crate::error::DBError;
|
||||||
use crate::options;
|
use crate::options;
|
||||||
use crate::protocol::Protocol;
|
use crate::protocol::Protocol;
|
||||||
|
use crate::storage::Storage;
|
||||||
|
use crate::storage_sled::SledStorage;
|
||||||
use crate::storage_trait::StorageBackend;
|
use crate::storage_trait::StorageBackend;
|
||||||
use crate::admin_meta;
|
use crate::tantivy_search::TantivySearch;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
pub db_cache: std::sync::Arc<std::sync::RwLock<HashMap<u64, Arc<dyn StorageBackend>>>>,
|
pub db_cache: Arc<RwLock<HashMap<u64, Arc<dyn StorageBackend>>>>,
|
||||||
|
pub search_indexes: Arc<RwLock<HashMap<String, Arc<TantivySearch>>>>,
|
||||||
pub option: options::DBOption,
|
pub option: options::DBOption,
|
||||||
pub client_name: Option<String>,
|
pub client_name: Option<String>,
|
||||||
pub selected_db: u64, // Changed from usize to u64
|
pub selected_db: u64, // Changed from usize to u64
|
||||||
pub queued_cmd: Option<Vec<(Cmd, Protocol)>>,
|
pub queued_cmd: Option<Vec<(Cmd, Protocol)>>,
|
||||||
pub current_permissions: Option<crate::rpc::Permissions>,
|
|
||||||
|
|
||||||
// BLPOP waiter registry: per (db_index, key) FIFO of waiters
|
// BLPOP waiter registry: per (db_index, key) FIFO of waiters
|
||||||
pub list_waiters: Arc<Mutex<HashMap<u64, HashMap<String, Vec<Waiter>>>>>,
|
pub list_waiters: Arc<Mutex<HashMap<u64, HashMap<String, Vec<Waiter>>>>>,
|
||||||
@@ -43,12 +46,12 @@ pub enum PopSide {
|
|||||||
impl Server {
|
impl Server {
|
||||||
pub async fn new(option: options::DBOption) -> Self {
|
pub async fn new(option: options::DBOption) -> Self {
|
||||||
Server {
|
Server {
|
||||||
db_cache: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
db_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
search_indexes: Arc::new(RwLock::new(HashMap::new())),
|
||||||
option,
|
option,
|
||||||
client_name: None,
|
client_name: None,
|
||||||
selected_db: 0,
|
selected_db: 0,
|
||||||
queued_cmd: None,
|
queued_cmd: None,
|
||||||
current_permissions: None,
|
|
||||||
|
|
||||||
list_waiters: Arc::new(Mutex::new(HashMap::new())),
|
list_waiters: Arc::new(Mutex::new(HashMap::new())),
|
||||||
waiter_seq: Arc::new(AtomicU64::new(1)),
|
waiter_seq: Arc::new(AtomicU64::new(1)),
|
||||||
@@ -57,42 +60,54 @@ impl Server {
|
|||||||
|
|
||||||
pub fn current_storage(&self) -> Result<Arc<dyn StorageBackend>, DBError> {
|
pub fn current_storage(&self) -> Result<Arc<dyn StorageBackend>, DBError> {
|
||||||
let mut cache = self.db_cache.write().unwrap();
|
let mut cache = self.db_cache.write().unwrap();
|
||||||
|
|
||||||
if let Some(storage) = cache.get(&self.selected_db) {
|
if let Some(storage) = cache.get(&self.selected_db) {
|
||||||
return Ok(storage.clone());
|
return Ok(storage.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use process-wide shared handles to avoid sled/reDB double-open lock contention.
|
|
||||||
let storage = if self.selected_db == 0 {
|
// Create new database file
|
||||||
// Admin DB 0: always via singleton
|
let db_file_path = std::path::PathBuf::from(self.option.dir.clone())
|
||||||
admin_meta::open_admin_storage(
|
.join(format!("{}.db", self.selected_db));
|
||||||
&self.option.dir,
|
|
||||||
self.option.backend.clone(),
|
// Ensure the directory exists before creating the database file
|
||||||
&self.option.admin_secret,
|
if let Some(parent_dir) = db_file_path.parent() {
|
||||||
)?
|
std::fs::create_dir_all(parent_dir).map_err(|e| {
|
||||||
} else {
|
DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e))
|
||||||
// Data DBs: via global registry keyed by id
|
})?;
|
||||||
admin_meta::open_data_storage(
|
}
|
||||||
&self.option.dir,
|
|
||||||
self.option.backend.clone(),
|
println!("Creating new db file: {}", db_file_path.display());
|
||||||
&self.option.admin_secret,
|
|
||||||
self.selected_db,
|
let storage: Arc<dyn StorageBackend> = match self.option.backend {
|
||||||
)?
|
options::BackendType::Redb => {
|
||||||
|
Arc::new(Storage::new(
|
||||||
|
db_file_path,
|
||||||
|
self.should_encrypt_db(self.selected_db),
|
||||||
|
self.option.encryption_key.as_deref()
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
options::BackendType::Sled => {
|
||||||
|
Arc::new(SledStorage::new(
|
||||||
|
db_file_path,
|
||||||
|
self.should_encrypt_db(self.selected_db),
|
||||||
|
self.option.encryption_key.as_deref()
|
||||||
|
)?)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
cache.insert(self.selected_db, storage.clone());
|
cache.insert(self.selected_db, storage.clone());
|
||||||
Ok(storage)
|
Ok(storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_encrypt_db(&self, db_index: u64) -> bool {
|
||||||
/// Check if current permissions allow read operations
|
// DB 0-9 are non-encrypted, DB 10+ are encrypted
|
||||||
pub fn has_read_permission(&self) -> bool {
|
self.option.encrypt && db_index >= 10
|
||||||
matches!(self.current_permissions, Some(crate::rpc::Permissions::Read) | Some(crate::rpc::Permissions::ReadWrite))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if current permissions allow write operations
|
// Add method to get search index path
|
||||||
pub fn has_write_permission(&self) -> bool {
|
pub fn search_index_path(&self) -> std::path::PathBuf {
|
||||||
matches!(self.current_permissions, Some(crate::rpc::Permissions::ReadWrite))
|
std::path::PathBuf::from(&self.option.dir).join("search_indexes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- BLPOP waiter helpers -----
|
// ----- BLPOP waiter helpers -----
|
567
herodb/src/tantivy_search.rs
Normal file
567
herodb/src/tantivy_search.rs
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
use tantivy::{
|
||||||
|
collector::TopDocs,
|
||||||
|
directory::MmapDirectory,
|
||||||
|
query::{QueryParser, BooleanQuery, Query, TermQuery, Occur},
|
||||||
|
schema::{Schema, Field, TextOptions, TextFieldIndexing,
|
||||||
|
STORED, STRING, Value},
|
||||||
|
Index, IndexWriter, IndexReader, ReloadPolicy,
|
||||||
|
Term, DateTime, TantivyDocument,
|
||||||
|
tokenizer::{TokenizerManager},
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::error::DBError;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum FieldDef {
|
||||||
|
Text {
|
||||||
|
stored: bool,
|
||||||
|
indexed: bool,
|
||||||
|
tokenized: bool,
|
||||||
|
fast: bool,
|
||||||
|
},
|
||||||
|
Numeric {
|
||||||
|
stored: bool,
|
||||||
|
indexed: bool,
|
||||||
|
fast: bool,
|
||||||
|
precision: NumericType,
|
||||||
|
},
|
||||||
|
Tag {
|
||||||
|
stored: bool,
|
||||||
|
separator: String,
|
||||||
|
case_sensitive: bool,
|
||||||
|
},
|
||||||
|
Geo {
|
||||||
|
stored: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum NumericType {
|
||||||
|
I64,
|
||||||
|
U64,
|
||||||
|
F64,
|
||||||
|
Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IndexSchema {
|
||||||
|
schema: Schema,
|
||||||
|
fields: HashMap<String, (Field, FieldDef)>,
|
||||||
|
default_search_fields: Vec<Field>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TantivySearch {
|
||||||
|
index: Index,
|
||||||
|
writer: Arc<RwLock<IndexWriter>>,
|
||||||
|
reader: IndexReader,
|
||||||
|
index_schema: IndexSchema,
|
||||||
|
name: String,
|
||||||
|
config: IndexConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IndexConfig {
|
||||||
|
pub language: String,
|
||||||
|
pub stopwords: Vec<String>,
|
||||||
|
pub stemming: bool,
|
||||||
|
pub max_doc_count: Option<usize>,
|
||||||
|
pub default_score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IndexConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
IndexConfig {
|
||||||
|
language: "english".to_string(),
|
||||||
|
stopwords: vec![],
|
||||||
|
stemming: true,
|
||||||
|
max_doc_count: None,
|
||||||
|
default_score: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TantivySearch {
|
||||||
|
pub fn new_with_schema(
|
||||||
|
base_path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
field_definitions: Vec<(String, FieldDef)>,
|
||||||
|
config: Option<IndexConfig>,
|
||||||
|
) -> Result<Self, DBError> {
|
||||||
|
let index_path = base_path.join(&name);
|
||||||
|
std::fs::create_dir_all(&index_path)
|
||||||
|
.map_err(|e| DBError(format!("Failed to create index dir: {}", e)))?;
|
||||||
|
|
||||||
|
// Build schema from field definitions
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
let mut default_search_fields = Vec::new();
|
||||||
|
|
||||||
|
// Always add a document ID field
|
||||||
|
let id_field = schema_builder.add_text_field("_id", STRING | STORED);
|
||||||
|
fields.insert("_id".to_string(), (id_field, FieldDef::Text {
|
||||||
|
stored: true,
|
||||||
|
indexed: true,
|
||||||
|
tokenized: false,
|
||||||
|
fast: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add user-defined fields
|
||||||
|
for (field_name, field_def) in field_definitions {
|
||||||
|
let field = match &field_def {
|
||||||
|
FieldDef::Text { stored, indexed, tokenized, fast: _fast } => {
|
||||||
|
let mut text_options = TextOptions::default();
|
||||||
|
|
||||||
|
if *stored {
|
||||||
|
text_options = text_options.set_stored();
|
||||||
|
}
|
||||||
|
|
||||||
|
if *indexed {
|
||||||
|
let indexing_options = if *tokenized {
|
||||||
|
TextFieldIndexing::default()
|
||||||
|
.set_tokenizer("default")
|
||||||
|
.set_index_option(tantivy::schema::IndexRecordOption::WithFreqsAndPositions)
|
||||||
|
} else {
|
||||||
|
TextFieldIndexing::default()
|
||||||
|
.set_tokenizer("raw")
|
||||||
|
.set_index_option(tantivy::schema::IndexRecordOption::Basic)
|
||||||
|
};
|
||||||
|
text_options = text_options.set_indexing_options(indexing_options);
|
||||||
|
|
||||||
|
let f = schema_builder.add_text_field(&field_name, text_options);
|
||||||
|
if *tokenized {
|
||||||
|
default_search_fields.push(f);
|
||||||
|
}
|
||||||
|
f
|
||||||
|
} else {
|
||||||
|
schema_builder.add_text_field(&field_name, text_options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldDef::Numeric { stored, indexed, fast, precision } => {
|
||||||
|
match precision {
|
||||||
|
NumericType::I64 => {
|
||||||
|
let mut opts = tantivy::schema::NumericOptions::default();
|
||||||
|
if *stored { opts = opts.set_stored(); }
|
||||||
|
if *indexed { opts = opts.set_indexed(); }
|
||||||
|
if *fast { opts = opts.set_fast(); }
|
||||||
|
schema_builder.add_i64_field(&field_name, opts)
|
||||||
|
}
|
||||||
|
NumericType::U64 => {
|
||||||
|
let mut opts = tantivy::schema::NumericOptions::default();
|
||||||
|
if *stored { opts = opts.set_stored(); }
|
||||||
|
if *indexed { opts = opts.set_indexed(); }
|
||||||
|
if *fast { opts = opts.set_fast(); }
|
||||||
|
schema_builder.add_u64_field(&field_name, opts)
|
||||||
|
}
|
||||||
|
NumericType::F64 => {
|
||||||
|
let mut opts = tantivy::schema::NumericOptions::default();
|
||||||
|
if *stored { opts = opts.set_stored(); }
|
||||||
|
if *indexed { opts = opts.set_indexed(); }
|
||||||
|
if *fast { opts = opts.set_fast(); }
|
||||||
|
schema_builder.add_f64_field(&field_name, opts)
|
||||||
|
}
|
||||||
|
NumericType::Date => {
|
||||||
|
let mut opts = tantivy::schema::DateOptions::default();
|
||||||
|
if *stored { opts = opts.set_stored(); }
|
||||||
|
if *indexed { opts = opts.set_indexed(); }
|
||||||
|
if *fast { opts = opts.set_fast(); }
|
||||||
|
schema_builder.add_date_field(&field_name, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldDef::Tag { stored, separator: _, case_sensitive: _ } => {
|
||||||
|
let mut text_options = TextOptions::default();
|
||||||
|
if *stored {
|
||||||
|
text_options = text_options.set_stored();
|
||||||
|
}
|
||||||
|
text_options = text_options.set_indexing_options(
|
||||||
|
TextFieldIndexing::default()
|
||||||
|
.set_tokenizer("raw")
|
||||||
|
.set_index_option(tantivy::schema::IndexRecordOption::Basic)
|
||||||
|
);
|
||||||
|
schema_builder.add_text_field(&field_name, text_options)
|
||||||
|
}
|
||||||
|
FieldDef::Geo { stored } => {
|
||||||
|
// For now, store as two f64 fields for lat/lon
|
||||||
|
let mut opts = tantivy::schema::NumericOptions::default();
|
||||||
|
if *stored { opts = opts.set_stored(); }
|
||||||
|
opts = opts.set_indexed().set_fast();
|
||||||
|
|
||||||
|
let lat_field = schema_builder.add_f64_field(&format!("{}_lat", field_name), opts.clone());
|
||||||
|
let lon_field = schema_builder.add_f64_field(&format!("{}_lon", field_name), opts);
|
||||||
|
|
||||||
|
fields.insert(format!("{}_lat", field_name), (lat_field, FieldDef::Numeric {
|
||||||
|
stored: *stored,
|
||||||
|
indexed: true,
|
||||||
|
fast: true,
|
||||||
|
precision: NumericType::F64,
|
||||||
|
}));
|
||||||
|
fields.insert(format!("{}_lon", field_name), (lon_field, FieldDef::Numeric {
|
||||||
|
stored: *stored,
|
||||||
|
indexed: true,
|
||||||
|
fast: true,
|
||||||
|
precision: NumericType::F64,
|
||||||
|
}));
|
||||||
|
continue; // Skip adding the geo field itself
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.insert(field_name.clone(), (field, field_def));
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema = schema_builder.build();
|
||||||
|
let index_schema = IndexSchema {
|
||||||
|
schema: schema.clone(),
|
||||||
|
fields,
|
||||||
|
default_search_fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create or open index
|
||||||
|
let dir = MmapDirectory::open(&index_path)
|
||||||
|
.map_err(|e| DBError(format!("Failed to open index directory: {}", e)))?;
|
||||||
|
|
||||||
|
let mut index = Index::open_or_create(dir, schema)
|
||||||
|
.map_err(|e| DBError(format!("Failed to create index: {}", e)))?;
|
||||||
|
|
||||||
|
// Configure tokenizers
|
||||||
|
let tokenizer_manager = TokenizerManager::default();
|
||||||
|
index.set_tokenizers(tokenizer_manager);
|
||||||
|
|
||||||
|
let writer = index.writer(1_000_000)
|
||||||
|
.map_err(|e| DBError(format!("Failed to create index writer: {}", e)))?;
|
||||||
|
|
||||||
|
let reader = index
|
||||||
|
.reader_builder()
|
||||||
|
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
||||||
|
.try_into()
|
||||||
|
.map_err(|e| DBError(format!("Failed to create reader: {}", e)))?;
|
||||||
|
|
||||||
|
let config = config.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(TantivySearch {
|
||||||
|
index,
|
||||||
|
writer: Arc::new(RwLock::new(writer)),
|
||||||
|
reader,
|
||||||
|
index_schema,
|
||||||
|
name,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_document_with_fields(
|
||||||
|
&self,
|
||||||
|
doc_id: &str,
|
||||||
|
fields: HashMap<String, String>,
|
||||||
|
) -> Result<(), DBError> {
|
||||||
|
let mut writer = self.writer.write()
|
||||||
|
.map_err(|e| DBError(format!("Failed to acquire writer lock: {}", e)))?;
|
||||||
|
|
||||||
|
// Delete existing document with same ID
|
||||||
|
if let Some((id_field, _)) = self.index_schema.fields.get("_id") {
|
||||||
|
writer.delete_term(Term::from_field_text(*id_field, doc_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new document
|
||||||
|
let mut doc = tantivy::doc!();
|
||||||
|
|
||||||
|
// Add document ID
|
||||||
|
if let Some((id_field, _)) = self.index_schema.fields.get("_id") {
|
||||||
|
doc.add_text(*id_field, doc_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other fields based on schema
|
||||||
|
for (field_name, field_value) in fields {
|
||||||
|
if let Some((field, field_def)) = self.index_schema.fields.get(&field_name) {
|
||||||
|
match field_def {
|
||||||
|
FieldDef::Text { .. } => {
|
||||||
|
doc.add_text(*field, &field_value);
|
||||||
|
}
|
||||||
|
FieldDef::Numeric { precision, .. } => {
|
||||||
|
match precision {
|
||||||
|
NumericType::I64 => {
|
||||||
|
if let Ok(v) = field_value.parse::<i64>() {
|
||||||
|
doc.add_i64(*field, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NumericType::U64 => {
|
||||||
|
if let Ok(v) = field_value.parse::<u64>() {
|
||||||
|
doc.add_u64(*field, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NumericType::F64 => {
|
||||||
|
if let Ok(v) = field_value.parse::<f64>() {
|
||||||
|
doc.add_f64(*field, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NumericType::Date => {
|
||||||
|
if let Ok(v) = field_value.parse::<i64>() {
|
||||||
|
doc.add_date(*field, DateTime::from_timestamp_millis(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldDef::Tag { separator, case_sensitive, .. } => {
|
||||||
|
let tags = if !case_sensitive {
|
||||||
|
field_value.to_lowercase()
|
||||||
|
} else {
|
||||||
|
field_value.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store tags as separate terms for efficient filtering
|
||||||
|
for tag in tags.split(separator.as_str()) {
|
||||||
|
doc.add_text(*field, tag.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldDef::Geo { .. } => {
|
||||||
|
// Parse "lat,lon" format
|
||||||
|
let parts: Vec<&str> = field_value.split(',').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
if let (Ok(lat), Ok(lon)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
|
||||||
|
if let Some((lat_field, _)) = self.index_schema.fields.get(&format!("{}_lat", field_name)) {
|
||||||
|
doc.add_f64(*lat_field, lat);
|
||||||
|
}
|
||||||
|
if let Some((lon_field, _)) = self.index_schema.fields.get(&format!("{}_lon", field_name)) {
|
||||||
|
doc.add_f64(*lon_field, lon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.add_document(doc).map_err(|e| DBError(format!("Failed to add document: {}", e)))?;
|
||||||
|
|
||||||
|
writer.commit()
|
||||||
|
.map_err(|e| DBError(format!("Failed to commit: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_with_options(
|
||||||
|
&self,
|
||||||
|
query_str: &str,
|
||||||
|
options: SearchOptions,
|
||||||
|
) -> Result<SearchResults, DBError> {
|
||||||
|
let searcher = self.reader.searcher();
|
||||||
|
|
||||||
|
// Parse query based on search fields
|
||||||
|
let query: Box<dyn Query> = if self.index_schema.default_search_fields.is_empty() {
|
||||||
|
return Err(DBError("No searchable fields defined in schema".to_string()));
|
||||||
|
} else {
|
||||||
|
let query_parser = QueryParser::for_index(
|
||||||
|
&self.index,
|
||||||
|
self.index_schema.default_search_fields.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Box::new(query_parser.parse_query(query_str)
|
||||||
|
.map_err(|e| DBError(format!("Failed to parse query: {}", e)))?)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply filters if any
|
||||||
|
let final_query = if !options.filters.is_empty() {
|
||||||
|
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(Occur::Must, query)];
|
||||||
|
|
||||||
|
// Add filters
|
||||||
|
for filter in options.filters {
|
||||||
|
if let Some((field, _)) = self.index_schema.fields.get(&filter.field) {
|
||||||
|
match filter.filter_type {
|
||||||
|
FilterType::Equals(value) => {
|
||||||
|
let term_query = TermQuery::new(
|
||||||
|
Term::from_field_text(*field, &value),
|
||||||
|
tantivy::schema::IndexRecordOption::Basic,
|
||||||
|
);
|
||||||
|
clauses.push((Occur::Must, Box::new(term_query)));
|
||||||
|
}
|
||||||
|
FilterType::Range { min: _, max: _ } => {
|
||||||
|
// Would need numeric field handling here
|
||||||
|
// Simplified for now
|
||||||
|
}
|
||||||
|
FilterType::InSet(values) => {
|
||||||
|
let mut sub_clauses: Vec<(Occur, Box<dyn Query>)> = vec![];
|
||||||
|
for value in values {
|
||||||
|
let term_query = TermQuery::new(
|
||||||
|
Term::from_field_text(*field, &value),
|
||||||
|
tantivy::schema::IndexRecordOption::Basic,
|
||||||
|
);
|
||||||
|
sub_clauses.push((Occur::Should, Box::new(term_query)));
|
||||||
|
}
|
||||||
|
clauses.push((Occur::Must, Box::new(BooleanQuery::new(sub_clauses))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::new(BooleanQuery::new(clauses))
|
||||||
|
} else {
|
||||||
|
query
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
let top_docs = searcher.search(
|
||||||
|
&*final_query,
|
||||||
|
&TopDocs::with_limit(options.limit + options.offset)
|
||||||
|
).map_err(|e| DBError(format!("Search failed: {}", e)))?;
|
||||||
|
|
||||||
|
let total_hits = top_docs.len();
|
||||||
|
let mut documents = Vec::new();
|
||||||
|
|
||||||
|
for (score, doc_address) in top_docs.iter().skip(options.offset).take(options.limit) {
|
||||||
|
let retrieved_doc: TantivyDocument = searcher.doc(*doc_address)
|
||||||
|
.map_err(|e| DBError(format!("Failed to retrieve doc: {}", e)))?;
|
||||||
|
|
||||||
|
let mut doc_fields = HashMap::new();
|
||||||
|
|
||||||
|
// Extract all stored fields
|
||||||
|
for (field_name, (field, field_def)) in &self.index_schema.fields {
|
||||||
|
match field_def {
|
||||||
|
FieldDef::Text { stored, .. } |
|
||||||
|
FieldDef::Tag { stored, .. } => {
|
||||||
|
if *stored {
|
||||||
|
if let Some(value) = retrieved_doc.get_first(*field) {
|
||||||
|
if let Some(text) = value.as_str() {
|
||||||
|
doc_fields.insert(field_name.clone(), text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldDef::Numeric { stored, precision, .. } => {
|
||||||
|
if *stored {
|
||||||
|
let value_str = match precision {
|
||||||
|
NumericType::I64 => {
|
||||||
|
retrieved_doc.get_first(*field)
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
}
|
||||||
|
NumericType::U64 => {
|
||||||
|
retrieved_doc.get_first(*field)
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
}
|
||||||
|
NumericType::F64 => {
|
||||||
|
retrieved_doc.get_first(*field)
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
}
|
||||||
|
NumericType::Date => {
|
||||||
|
retrieved_doc.get_first(*field)
|
||||||
|
.and_then(|v| v.as_datetime())
|
||||||
|
.map(|v| v.into_timestamp_millis().to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(v) = value_str {
|
||||||
|
doc_fields.insert(field_name.clone(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldDef::Geo { stored } => {
|
||||||
|
if *stored {
|
||||||
|
let lat_field = self.index_schema.fields.get(&format!("{}_lat", field_name)).unwrap().0;
|
||||||
|
let lon_field = self.index_schema.fields.get(&format!("{}_lon", field_name)).unwrap().0;
|
||||||
|
|
||||||
|
let lat = retrieved_doc.get_first(lat_field).and_then(|v| v.as_f64());
|
||||||
|
let lon = retrieved_doc.get_first(lon_field).and_then(|v| v.as_f64());
|
||||||
|
|
||||||
|
if let (Some(lat), Some(lon)) = (lat, lon) {
|
||||||
|
doc_fields.insert(field_name.clone(), format!("{},{}", lat, lon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
documents.push(SearchDocument {
|
||||||
|
fields: doc_fields,
|
||||||
|
score: *score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SearchResults {
|
||||||
|
total: total_hits,
|
||||||
|
documents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_info(&self) -> Result<IndexInfo, DBError> {
|
||||||
|
let searcher = self.reader.searcher();
|
||||||
|
let num_docs = searcher.num_docs();
|
||||||
|
|
||||||
|
let fields_info: Vec<FieldInfo> = self.index_schema.fields.iter().map(|(name, (_, def))| {
|
||||||
|
FieldInfo {
|
||||||
|
name: name.clone(),
|
||||||
|
field_type: format!("{:?}", def),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(IndexInfo {
|
||||||
|
name: self.name.clone(),
|
||||||
|
num_docs,
|
||||||
|
fields: fields_info,
|
||||||
|
config: self.config.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SearchOptions {
|
||||||
|
pub limit: usize,
|
||||||
|
pub offset: usize,
|
||||||
|
pub filters: Vec<Filter>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
pub return_fields: Option<Vec<String>>,
|
||||||
|
pub highlight: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SearchOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
SearchOptions {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
filters: vec![],
|
||||||
|
sort_by: None,
|
||||||
|
return_fields: None,
|
||||||
|
highlight: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Filter {
|
||||||
|
pub field: String,
|
||||||
|
pub filter_type: FilterType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum FilterType {
|
||||||
|
Equals(String),
|
||||||
|
Range { min: String, max: String },
|
||||||
|
InSet(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchResults {
|
||||||
|
pub total: usize,
|
||||||
|
pub documents: Vec<SearchDocument>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchDocument {
|
||||||
|
pub fields: HashMap<String, String>,
|
||||||
|
pub score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IndexInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub num_docs: u64,
|
||||||
|
pub fields: Vec<FieldInfo>,
|
||||||
|
pub config: IndexConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FieldInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub field_type: String,
|
||||||
|
}
|
101
herodb/test_tantivy_integration.sh
Executable file
101
herodb/test_tantivy_integration.sh
Executable file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Simple Tantivy Search Integration Test for HeroDB
|
||||||
|
# This script tests the full-text search functionality we just integrated
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔍 Testing Tantivy Search Integration..."
|
||||||
|
|
||||||
|
# Build the project first
|
||||||
|
echo "📦 Building HeroDB..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Start the server in the background
|
||||||
|
echo "🚀 Starting HeroDB server on port 6379..."
|
||||||
|
cargo run --release -- --port 6379 --dir ./test_data &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for server to start
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Function to cleanup on exit
|
||||||
|
cleanup() {
|
||||||
|
echo "🧹 Cleaning up..."
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
rm -rf ./test_data
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set trap for cleanup
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# Function to execute Redis command
|
||||||
|
execute_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
local description="$2"
|
||||||
|
|
||||||
|
echo "📝 $description"
|
||||||
|
echo " Command: $cmd"
|
||||||
|
|
||||||
|
if result=$(redis-cli -p 6379 $cmd 2>&1); then
|
||||||
|
echo " ✅ Result: $result"
|
||||||
|
echo
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo " ❌ Failed: $result"
|
||||||
|
echo
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🧪 Running Tantivy Search Tests..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Test 1: Create a search index
|
||||||
|
execute_cmd "ft.create books SCHEMA title TEXT description TEXT author TEXT category TAG price NUMERIC" \
|
||||||
|
"Creating search index 'books'"
|
||||||
|
|
||||||
|
# Test 2: Add documents to the index
|
||||||
|
execute_cmd "ft.add books book1 1.0 title \"The Great Gatsby\" description \"A classic American novel about the Jazz Age\" author \"F. Scott Fitzgerald\" category \"fiction,classic\" price \"12.99\"" \
|
||||||
|
"Adding first book"
|
||||||
|
|
||||||
|
execute_cmd "ft.add books book2 1.0 title \"To Kill a Mockingbird\" description \"A novel about racial injustice in the American South\" author \"Harper Lee\" category \"fiction,classic\" price \"14.99\"" \
|
||||||
|
"Adding second book"
|
||||||
|
|
||||||
|
execute_cmd "ft.add books book3 1.0 title \"Programming Rust\" description \"A comprehensive guide to Rust programming language\" author \"Jim Blandy\" category \"programming,technical\" price \"49.99\"" \
|
||||||
|
"Adding third book"
|
||||||
|
|
||||||
|
execute_cmd "ft.add books book4 1.0 title \"The Rust Programming Language\" description \"The official book on Rust programming\" author \"Steve Klabnik\" category \"programming,technical\" price \"39.99\"" \
|
||||||
|
"Adding fourth book"
|
||||||
|
|
||||||
|
# Test 3: Basic search
|
||||||
|
execute_cmd "ft.search books Rust" \
|
||||||
|
"Searching for 'Rust'"
|
||||||
|
|
||||||
|
# Test 4: Search with filters
|
||||||
|
execute_cmd "ft.search books programming FILTER category programming" \
|
||||||
|
"Searching for 'programming' with category filter"
|
||||||
|
|
||||||
|
# Test 5: Search with limit
|
||||||
|
execute_cmd "ft.search books \"*\" LIMIT 0 2" \
|
||||||
|
"Getting first 2 documents"
|
||||||
|
|
||||||
|
# Test 6: Get index info
|
||||||
|
execute_cmd "ft.info books" \
|
||||||
|
"Getting index information"
|
||||||
|
|
||||||
|
# Test 7: Delete a document
|
||||||
|
execute_cmd "ft.del books book1" \
|
||||||
|
"Deleting book1"
|
||||||
|
|
||||||
|
# Test 8: Search again to verify deletion
|
||||||
|
execute_cmd "ft.search books Gatsby" \
|
||||||
|
"Searching for deleted book"
|
||||||
|
|
||||||
|
# Test 9: Drop the index
|
||||||
|
execute_cmd "ft.drop books" \
|
||||||
|
"Dropping the index"
|
||||||
|
|
||||||
|
echo "🎉 All tests completed successfully!"
|
||||||
|
echo "✅ Tantivy search integration is working correctly"
|
@@ -28,7 +28,6 @@ async fn debug_hset_simple() {
|
|||||||
encrypt: false,
|
encrypt: false,
|
||||||
encryption_key: None,
|
encryption_key: None,
|
||||||
backend: herodb::options::BackendType::Redb,
|
backend: herodb::options::BackendType::Redb,
|
||||||
admin_secret: "test-admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut server = Server::new(option).await;
|
let mut server = Server::new(option).await;
|
||||||
@@ -49,12 +48,6 @@ async fn debug_hset_simple() {
|
|||||||
sleep(Duration::from_millis(200)).await;
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
|
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
|
||||||
// Acquire ReadWrite permissions on this connection
|
|
||||||
let resp = send_command(
|
|
||||||
&mut stream,
|
|
||||||
"*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n",
|
|
||||||
).await;
|
|
||||||
assert!(resp.contains("OK"), "Failed SELECT handshake: {}", resp);
|
|
||||||
|
|
||||||
// Test simple HSET
|
// Test simple HSET
|
||||||
println!("Testing HSET...");
|
println!("Testing HSET...");
|
@@ -19,7 +19,6 @@ async fn debug_hset_return_value() {
|
|||||||
encrypt: false,
|
encrypt: false,
|
||||||
encryption_key: None,
|
encryption_key: None,
|
||||||
backend: herodb::options::BackendType::Redb,
|
backend: herodb::options::BackendType::Redb,
|
||||||
admin_secret: "test-admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut server = Server::new(option).await;
|
let mut server = Server::new(option).await;
|
||||||
@@ -41,19 +40,12 @@ async fn debug_hset_return_value() {
|
|||||||
|
|
||||||
// Connect and test HSET
|
// Connect and test HSET
|
||||||
let mut stream = TcpStream::connect("127.0.0.1:16390").await.unwrap();
|
let mut stream = TcpStream::connect("127.0.0.1:16390").await.unwrap();
|
||||||
|
|
||||||
// Acquire ReadWrite permissions for this new connection
|
|
||||||
let handshake = "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n";
|
|
||||||
stream.write_all(handshake.as_bytes()).await.unwrap();
|
|
||||||
let mut buffer = [0; 1024];
|
|
||||||
let n = stream.read(&mut buffer).await.unwrap();
|
|
||||||
let resp = String::from_utf8_lossy(&buffer[..n]);
|
|
||||||
assert!(resp.contains("OK"), "Failed SELECT handshake: {}", resp);
|
|
||||||
|
|
||||||
// Send HSET command
|
// Send HSET command
|
||||||
let cmd = "*4\r\n$4\r\nHSET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n";
|
let cmd = "*4\r\n$4\r\nHSET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n";
|
||||||
stream.write_all(cmd.as_bytes()).await.unwrap();
|
stream.write_all(cmd.as_bytes()).await.unwrap();
|
||||||
|
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
let n = stream.read(&mut buffer).await.unwrap();
|
let n = stream.read(&mut buffer).await.unwrap();
|
||||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||||
|
|
@@ -12,15 +12,7 @@ fn get_redis_connection(port: u16) -> Connection {
|
|||||||
match client.get_connection() {
|
match client.get_connection() {
|
||||||
Ok(mut conn) => {
|
Ok(mut conn) => {
|
||||||
if redis::cmd("PING").query::<String>(&mut conn).is_ok() {
|
if redis::cmd("PING").query::<String>(&mut conn).is_ok() {
|
||||||
// Acquire ReadWrite permissions on this connection
|
return conn;
|
||||||
let sel: RedisResult<String> = redis::cmd("SELECT")
|
|
||||||
.arg(0)
|
|
||||||
.arg("KEY")
|
|
||||||
.arg("test-admin")
|
|
||||||
.query(&mut conn);
|
|
||||||
if sel.is_ok() {
|
|
||||||
return conn;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -86,8 +78,6 @@ fn setup_server() -> (ServerProcessGuard, u16) {
|
|||||||
"--port",
|
"--port",
|
||||||
&port.to_string(),
|
&port.to_string(),
|
||||||
"--debug",
|
"--debug",
|
||||||
"--admin-secret",
|
|
||||||
"test-admin",
|
|
||||||
])
|
])
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to start server process");
|
.expect("Failed to start server process");
|
@@ -23,29 +23,18 @@ async fn start_test_server(test_name: &str) -> (Server, u16) {
|
|||||||
encrypt: false,
|
encrypt: false,
|
||||||
encryption_key: None,
|
encryption_key: None,
|
||||||
backend: herodb::options::BackendType::Redb,
|
backend: herodb::options::BackendType::Redb,
|
||||||
admin_secret: "test-admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let server = Server::new(option).await;
|
let server = Server::new(option).await;
|
||||||
(server, port)
|
(server, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to connect to the test server
|
// Helper function to connect to the test server
|
||||||
async fn connect_to_server(port: u16) -> TcpStream {
|
async fn connect_to_server(port: u16) -> TcpStream {
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
loop {
|
loop {
|
||||||
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
||||||
Ok(mut stream) => {
|
Ok(stream) => return stream,
|
||||||
// Obtain ReadWrite permissions for this connection by selecting DB 0 with admin key
|
|
||||||
let resp = send_command(
|
|
||||||
&mut stream,
|
|
||||||
"*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n",
|
|
||||||
).await;
|
|
||||||
if !resp.contains("OK") {
|
|
||||||
panic!("Failed to acquire write permissions via SELECT 0 KEY test-admin: {}", resp);
|
|
||||||
}
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
Err(_) if attempts < 10 => {
|
Err(_) if attempts < 10 => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
@@ -25,7 +25,6 @@ async fn start_test_server(test_name: &str) -> (Server, u16) {
|
|||||||
encrypt: false,
|
encrypt: false,
|
||||||
encryption_key: None,
|
encryption_key: None,
|
||||||
backend: herodb::options::BackendType::Redb,
|
backend: herodb::options::BackendType::Redb,
|
||||||
admin_secret: "test-admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let server = Server::new(option).await;
|
let server = Server::new(option).await;
|
||||||
@@ -35,16 +34,9 @@ async fn start_test_server(test_name: &str) -> (Server, u16) {
|
|||||||
// Helper function to send Redis command and get response
|
// Helper function to send Redis command and get response
|
||||||
async fn send_redis_command(port: u16, command: &str) -> String {
|
async fn send_redis_command(port: u16, command: &str) -> String {
|
||||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
|
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
|
||||||
|
|
||||||
// Acquire ReadWrite permissions on this new connection
|
|
||||||
let handshake = "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n";
|
|
||||||
stream.write_all(handshake.as_bytes()).await.unwrap();
|
|
||||||
let mut buffer = [0; 1024];
|
|
||||||
let _ = stream.read(&mut buffer).await.unwrap(); // Read and ignore the OK for handshake
|
|
||||||
|
|
||||||
// Now send the intended command
|
|
||||||
stream.write_all(command.as_bytes()).await.unwrap();
|
stream.write_all(command.as_bytes()).await.unwrap();
|
||||||
|
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
let n = stream.read(&mut buffer).await.unwrap();
|
let n = stream.read(&mut buffer).await.unwrap();
|
||||||
String::from_utf8_lossy(&buffer[..n]).to_string()
|
String::from_utf8_lossy(&buffer[..n]).to_string()
|
||||||
}
|
}
|
||||||
@@ -192,19 +184,12 @@ async fn test_transaction_operations() {
|
|||||||
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
// Use a single connection for the transaction
|
// Use a single connection for the transaction
|
||||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
|
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
|
||||||
|
|
||||||
// Acquire write permissions for this connection
|
|
||||||
let handshake = "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n";
|
|
||||||
stream.write_all(handshake.as_bytes()).await.unwrap();
|
|
||||||
let mut buffer = [0; 1024];
|
|
||||||
let n = stream.read(&mut buffer).await.unwrap();
|
|
||||||
let resp = String::from_utf8_lossy(&buffer[..n]);
|
|
||||||
assert!(resp.contains("OK"));
|
|
||||||
|
|
||||||
// Test MULTI
|
// Test MULTI
|
||||||
stream.write_all("*1\r\n$5\r\nMULTI\r\n".as_bytes()).await.unwrap();
|
stream.write_all("*1\r\n$5\r\nMULTI\r\n".as_bytes()).await.unwrap();
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
let n = stream.read(&mut buffer).await.unwrap();
|
let n = stream.read(&mut buffer).await.unwrap();
|
||||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||||
assert!(response.contains("OK"));
|
assert!(response.contains("OK"));
|
@@ -23,7 +23,6 @@ async fn start_test_server(test_name: &str) -> (Server, u16) {
|
|||||||
encrypt: false,
|
encrypt: false,
|
||||||
encryption_key: None,
|
encryption_key: None,
|
||||||
backend: herodb::options::BackendType::Redb,
|
backend: herodb::options::BackendType::Redb,
|
||||||
admin_secret: "test-admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let server = Server::new(option).await;
|
let server = Server::new(option).await;
|
||||||
@@ -39,22 +38,12 @@ async fn send_command(stream: &mut TcpStream, command: &str) -> String {
|
|||||||
String::from_utf8_lossy(&buffer[..n]).to_string()
|
String::from_utf8_lossy(&buffer[..n]).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to connect to the test server
|
// Helper function to connect to the test server
|
||||||
async fn connect_to_server(port: u16) -> TcpStream {
|
async fn connect_to_server(port: u16) -> TcpStream {
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
loop {
|
loop {
|
||||||
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
||||||
Ok(mut stream) => {
|
Ok(stream) => return stream,
|
||||||
// Acquire ReadWrite permissions for this connection
|
|
||||||
let resp = send_command(
|
|
||||||
&mut stream,
|
|
||||||
"*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n",
|
|
||||||
).await;
|
|
||||||
if !resp.contains("OK") {
|
|
||||||
panic!("Failed to acquire write permissions via SELECT 0 KEY test-admin: {}", resp);
|
|
||||||
}
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
Err(_) if attempts < 10 => {
|
Err(_) if attempts < 10 => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
@@ -108,21 +97,14 @@ async fn test_hset_clean_db() {
|
|||||||
sleep(Duration::from_millis(200)).await;
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
|
||||||
let mut stream = connect_to_server(port).await;
|
let mut stream = connect_to_server(port).await;
|
||||||
|
|
||||||
// Ensure clean DB state (admin DB 0 may be shared due to global singleton)
|
// Test HSET - should return 1 for new field
|
||||||
let flush = send_command(&mut stream, "*1\r\n$7\r\nFLUSHDB\r\n").await;
|
let response = send_command(&mut stream, "*4\r\n$4\r\nHSET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n").await;
|
||||||
assert!(flush.contains("OK"), "Failed to FLUSHDB: {}", flush);
|
|
||||||
|
|
||||||
// Test HSET - should return 1 for new field (use a unique key name to avoid collisions)
|
|
||||||
let key = "hash_clean";
|
|
||||||
let hset_cmd = format!("*4\r\n$4\r\nHSET\r\n${}\r\n{}\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n", key.len(), key);
|
|
||||||
let response = send_command(&mut stream, &hset_cmd).await;
|
|
||||||
println!("HSET response: {}", response);
|
println!("HSET response: {}", response);
|
||||||
assert!(response.contains("1"), "Expected HSET to return 1, got: {}", response);
|
assert!(response.contains("1"), "Expected HSET to return 1, got: {}", response);
|
||||||
|
|
||||||
// Test HGET
|
// Test HGET
|
||||||
let hget_cmd = format!("*3\r\n$4\r\nHGET\r\n${}\r\n{}\r\n$6\r\nfield1\r\n", key.len(), key);
|
let response = send_command(&mut stream, "*3\r\n$4\r\nHGET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n").await;
|
||||||
let response = send_command(&mut stream, &hget_cmd).await;
|
|
||||||
println!("HGET response: {}", response);
|
println!("HGET response: {}", response);
|
||||||
assert!(response.contains("value1"));
|
assert!(response.contains("value1"));
|
||||||
}
|
}
|
@@ -23,7 +23,6 @@ async fn start_test_server(test_name: &str) -> (Server, u16) {
|
|||||||
encrypt: false,
|
encrypt: false,
|
||||||
encryption_key: None,
|
encryption_key: None,
|
||||||
backend: herodb::options::BackendType::Redb,
|
backend: herodb::options::BackendType::Redb,
|
||||||
admin_secret: "test-admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let server = Server::new(option).await;
|
let server = Server::new(option).await;
|
||||||
@@ -62,17 +61,7 @@ async fn connect(port: u16) -> TcpStream {
|
|||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
loop {
|
loop {
|
||||||
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
match TcpStream::connect(format!("127.0.0.1:{}", port)).await {
|
||||||
Ok(mut s) => {
|
Ok(s) => return s,
|
||||||
// Acquire ReadWrite permissions for this connection using admin DB 0
|
|
||||||
let resp = send_cmd(&mut s, &["SELECT", "0", "KEY", "test-admin"]).await;
|
|
||||||
assert_contains(&resp, "OK", "SELECT 0 KEY test-admin handshake");
|
|
||||||
|
|
||||||
// Ensure clean slate per test on DB 0
|
|
||||||
let fl = send_cmd(&mut s, &["FLUSHDB"]).await;
|
|
||||||
assert_contains(&fl, "OK", "FLUSHDB after handshake");
|
|
||||||
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
Err(_) if attempts < 30 => {
|
Err(_) if attempts < 30 => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
@@ -257,9 +246,9 @@ async fn test_01_connection_and_info() {
|
|||||||
let getname = send_cmd(&mut s, &["CLIENT", "GETNAME"]).await;
|
let getname = send_cmd(&mut s, &["CLIENT", "GETNAME"]).await;
|
||||||
assert_contains(&getname, "myapp", "CLIENT GETNAME");
|
assert_contains(&getname, "myapp", "CLIENT GETNAME");
|
||||||
|
|
||||||
// SELECT db (requires key on DB 0)
|
// SELECT db
|
||||||
let sel = send_cmd(&mut s, &["SELECT", "0", "KEY", "test-admin"]).await;
|
let sel = send_cmd(&mut s, &["SELECT", "0"]).await;
|
||||||
assert_contains(&sel, "OK", "SELECT 0 with key");
|
assert_contains(&sel, "OK", "SELECT 0");
|
||||||
|
|
||||||
// QUIT should close connection after sending OK
|
// QUIT should close connection after sending OK
|
||||||
let quit = send_cmd(&mut s, &["QUIT"]).await;
|
let quit = send_cmd(&mut s, &["QUIT"]).await;
|
||||||
@@ -290,11 +279,7 @@ async fn test_02_strings_and_expiry() {
|
|||||||
|
|
||||||
let ex0 = send_cmd(&mut s, &["EXISTS", "user:1"]).await;
|
let ex0 = send_cmd(&mut s, &["EXISTS", "user:1"]).await;
|
||||||
assert_contains(&ex0, "0", "EXISTS after DEL");
|
assert_contains(&ex0, "0", "EXISTS after DEL");
|
||||||
|
|
||||||
// DEL non-existent should return 0
|
|
||||||
let del0 = send_cmd(&mut s, &["DEL", "user:1"]).await;
|
|
||||||
assert_contains(&del0, "0", "DEL user:1 when not exists -> 0");
|
|
||||||
|
|
||||||
// INCR behavior
|
// INCR behavior
|
||||||
let i1 = send_cmd(&mut s, &["INCR", "count"]).await;
|
let i1 = send_cmd(&mut s, &["INCR", "count"]).await;
|
||||||
assert_contains(&i1, "1", "INCR new key -> 1");
|
assert_contains(&i1, "1", "INCR new key -> 1");
|
||||||
@@ -516,11 +501,11 @@ async fn test_07_age_stateless_suite() {
|
|||||||
let mut s = connect(port).await;
|
let mut s = connect(port).await;
|
||||||
|
|
||||||
// GENENC -> [recipient, identity]
|
// GENENC -> [recipient, identity]
|
||||||
let genenc = send_cmd(&mut s, &["AGE", "GENENC"]).await;
|
let gen = send_cmd(&mut s, &["AGE", "GENENC"]).await;
|
||||||
assert!(
|
assert!(
|
||||||
genenc.starts_with("*2\r\n$"),
|
gen.starts_with("*2\r\n$"),
|
||||||
"AGE GENENC should return array [recipient, identity], got:\n{}",
|
"AGE GENENC should return array [recipient, identity], got:\n{}",
|
||||||
genenc
|
gen
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse simple RESP array of two bulk strings to extract keys
|
// Parse simple RESP array of two bulk strings to extract keys
|
||||||
@@ -535,7 +520,7 @@ async fn test_07_age_stateless_suite() {
|
|||||||
let ident = lines.next().unwrap_or("").to_string();
|
let ident = lines.next().unwrap_or("").to_string();
|
||||||
(recip, ident)
|
(recip, ident)
|
||||||
}
|
}
|
||||||
let (recipient, identity) = parse_two_bulk_array(&genenc);
|
let (recipient, identity) = parse_two_bulk_array(&gen);
|
||||||
assert!(
|
assert!(
|
||||||
recipient.starts_with("age1") && identity.starts_with("AGE-SECRET-KEY-1"),
|
recipient.starts_with("age1") && identity.starts_with("AGE-SECRET-KEY-1"),
|
||||||
"Unexpected AGE key formats.\nrecipient: {}\nidentity: {}",
|
"Unexpected AGE key formats.\nrecipient: {}\nidentity: {}",
|
||||||
@@ -606,7 +591,7 @@ async fn test_08_age_persistent_named_suite() {
|
|||||||
|
|
||||||
// AGE LIST
|
// AGE LIST
|
||||||
let lst = send_cmd(&mut s, &["AGE", "LIST"]).await;
|
let lst = send_cmd(&mut s, &["AGE", "LIST"]).await;
|
||||||
// After flattening, LIST returns a flat array of managed key names
|
assert_contains(&lst, "encpub", "AGE LIST label encpub");
|
||||||
assert_contains(&lst, "app1", "AGE LIST includes app1");
|
assert_contains(&lst, "app1", "AGE LIST includes app1");
|
||||||
}
|
}
|
||||||
|
|
@@ -1,387 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, OnceLock, Mutex, RwLock};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::error::DBError;
|
|
||||||
use crate::options;
|
|
||||||
use crate::rpc::Permissions;
|
|
||||||
use crate::storage::Storage;
|
|
||||||
use crate::storage_sled::SledStorage;
|
|
||||||
use crate::storage_trait::StorageBackend;
|
|
||||||
|
|
||||||
// Key builders
|
|
||||||
fn k_admin_next_id() -> &'static str {
|
|
||||||
"admin:next_id"
|
|
||||||
}
|
|
||||||
fn k_admin_dbs() -> &'static str {
|
|
||||||
"admin:dbs"
|
|
||||||
}
|
|
||||||
fn k_meta_db(id: u64) -> String {
|
|
||||||
format!("meta:db:{}", id)
|
|
||||||
}
|
|
||||||
fn k_meta_db_keys(id: u64) -> String {
|
|
||||||
format!("meta:db:{}:keys", id)
|
|
||||||
}
|
|
||||||
fn k_meta_db_enc(id: u64) -> String {
|
|
||||||
format!("meta:db:{}:enc", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global cache of admin DB 0 handles per base_dir to avoid sled/reDB file-lock contention
|
|
||||||
// and to correctly isolate different test instances with distinct directories.
|
|
||||||
static ADMIN_STORAGES: OnceLock<RwLock<HashMap<String, Arc<dyn StorageBackend>>>> = OnceLock::new();
|
|
||||||
|
|
||||||
// Global registry for data DB storages to avoid double-open across process.
|
|
||||||
static DATA_STORAGES: OnceLock<RwLock<HashMap<u64, Arc<dyn StorageBackend>>>> = OnceLock::new();
|
|
||||||
static DATA_INIT_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
fn init_admin_storage(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
) -> Result<Arc<dyn StorageBackend>, DBError> {
|
|
||||||
let db_file = PathBuf::from(base_dir).join("0.db");
|
|
||||||
if let Some(parent_dir) = db_file.parent() {
|
|
||||||
std::fs::create_dir_all(parent_dir).map_err(|e| {
|
|
||||||
DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let storage: Arc<dyn StorageBackend> = match backend {
|
|
||||||
options::BackendType::Redb => Arc::new(Storage::new(&db_file, true, Some(admin_secret))?),
|
|
||||||
options::BackendType::Sled => Arc::new(SledStorage::new(&db_file, true, Some(admin_secret))?),
|
|
||||||
};
|
|
||||||
Ok(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or initialize a cached handle to admin DB 0 per base_dir (thread-safe, no double-open race)
|
|
||||||
pub fn open_admin_storage(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
) -> Result<Arc<dyn StorageBackend>, DBError> {
|
|
||||||
let map = ADMIN_STORAGES.get_or_init(|| RwLock::new(HashMap::new()));
|
|
||||||
// Fast path
|
|
||||||
if let Some(st) = map.read().unwrap().get(base_dir) {
|
|
||||||
return Ok(st.clone());
|
|
||||||
}
|
|
||||||
// Slow path with write lock
|
|
||||||
{
|
|
||||||
let mut w = map.write().unwrap();
|
|
||||||
if let Some(st) = w.get(base_dir) {
|
|
||||||
return Ok(st.clone());
|
|
||||||
}
|
|
||||||
let st = init_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
w.insert(base_dir.to_string(), st.clone());
|
|
||||||
return Ok(st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure admin structures exist in encrypted DB 0
|
|
||||||
pub fn ensure_bootstrap(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
) -> Result<(), DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
|
|
||||||
// Initialize next id if missing
|
|
||||||
if !admin.exists(k_admin_next_id())? {
|
|
||||||
admin.set(k_admin_next_id().to_string(), "1".to_string())?;
|
|
||||||
}
|
|
||||||
// admin:dbs is a hash; it's fine if it doesn't exist (hlen -> 0)
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or initialize a shared handle to a data DB (> 0), avoiding double-open across subsystems
|
|
||||||
pub fn open_data_storage(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<Arc<dyn StorageBackend>, DBError> {
|
|
||||||
if id == 0 {
|
|
||||||
return open_admin_storage(base_dir, backend, admin_secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate existence in admin metadata
|
|
||||||
if !db_exists(base_dir, backend.clone(), admin_secret, id)? {
|
|
||||||
return Err(DBError(format!(
|
|
||||||
"Cannot open database instance {}, as that database instance does not exist.",
|
|
||||||
id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let map = DATA_STORAGES.get_or_init(|| RwLock::new(HashMap::new()));
|
|
||||||
// Fast path
|
|
||||||
if let Some(st) = map.read().unwrap().get(&id) {
|
|
||||||
return Ok(st.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slow path with init lock
|
|
||||||
let _guard = DATA_INIT_LOCK.lock().unwrap();
|
|
||||||
if let Some(st) = map.read().unwrap().get(&id) {
|
|
||||||
return Ok(st.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine per-db encryption
|
|
||||||
let enc = get_enc_key(base_dir, backend.clone(), admin_secret, id)?;
|
|
||||||
let should_encrypt = enc.is_some();
|
|
||||||
|
|
||||||
// Build database file path and ensure parent dir exists
|
|
||||||
let db_file = PathBuf::from(base_dir).join(format!("{}.db", id));
|
|
||||||
if let Some(parent_dir) = db_file.parent() {
|
|
||||||
std::fs::create_dir_all(parent_dir).map_err(|e| {
|
|
||||||
DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open storage
|
|
||||||
let storage: Arc<dyn StorageBackend> = match backend {
|
|
||||||
options::BackendType::Redb => Arc::new(Storage::new(&db_file, should_encrypt, enc.as_deref())?),
|
|
||||||
options::BackendType::Sled => Arc::new(SledStorage::new(&db_file, should_encrypt, enc.as_deref())?),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Publish to registry
|
|
||||||
map.write().unwrap().insert(id, storage.clone());
|
|
||||||
Ok(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allocate the next DB id and persist new pointer
|
|
||||||
pub fn allocate_next_id(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
) -> Result<u64, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let cur = admin
|
|
||||||
.get(k_admin_next_id())?
|
|
||||||
.unwrap_or_else(|| "1".to_string());
|
|
||||||
let id: u64 = cur.parse().unwrap_or(1);
|
|
||||||
let next = id.checked_add(1).ok_or_else(|| DBError("next_id overflow".into()))?;
|
|
||||||
admin.set(k_admin_next_id().to_string(), next.to_string())?;
|
|
||||||
|
|
||||||
// Register into admin:dbs set/hash
|
|
||||||
let _ = admin.hset(k_admin_dbs(), vec![(id.to_string(), "1".to_string())])?;
|
|
||||||
|
|
||||||
// Default meta for the new db: public true
|
|
||||||
let meta_key = k_meta_db(id);
|
|
||||||
let _ = admin.hset(&meta_key, vec![("public".to_string(), "true".to_string())])?;
|
|
||||||
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check existence of a db id in admin:dbs
|
|
||||||
pub fn db_exists(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<bool, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
Ok(admin.hexists(k_admin_dbs(), &id.to_string())?)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get per-db encryption key, if any
|
|
||||||
pub fn get_enc_key(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<Option<String>, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
admin.get(&k_meta_db_enc(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set per-db encryption key (called during create)
|
|
||||||
pub fn set_enc_key(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<(), DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
admin.set(k_meta_db_enc(id), key.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set database public flag
|
|
||||||
pub fn set_database_public(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
public: bool,
|
|
||||||
) -> Result<(), DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let mk = k_meta_db(id);
|
|
||||||
let _ = admin.hset(&mk, vec![("public".to_string(), public.to_string())])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set database name
|
|
||||||
pub fn set_database_name(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<(), DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let mk = k_meta_db(id);
|
|
||||||
let _ = admin.hset(&mk, vec![("name".to_string(), name.to_string())])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get database name
|
|
||||||
pub fn get_database_name(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<Option<String>, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let mk = k_meta_db(id);
|
|
||||||
admin.hget(&mk, "name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal: load public flag; default to true when meta missing
|
|
||||||
fn load_public(
|
|
||||||
admin: &Arc<dyn StorageBackend>,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<bool, DBError> {
|
|
||||||
let mk = k_meta_db(id);
|
|
||||||
match admin.hget(&mk, "public")? {
|
|
||||||
Some(v) => Ok(v == "true"),
|
|
||||||
None => Ok(true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add access key for db (value format: "Read:ts" or "ReadWrite:ts")
|
|
||||||
pub fn add_access_key(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
key_plain: &str,
|
|
||||||
perms: Permissions,
|
|
||||||
) -> Result<(), DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let hash = crate::rpc::hash_key(key_plain);
|
|
||||||
let v = match perms {
|
|
||||||
Permissions::Read => format!("Read:{}", now_secs()),
|
|
||||||
Permissions::ReadWrite => format!("ReadWrite:{}", now_secs()),
|
|
||||||
};
|
|
||||||
let _ = admin.hset(&k_meta_db_keys(id), vec![(hash, v)])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete access key by hash
|
|
||||||
pub fn delete_access_key(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
key_hash: &str,
|
|
||||||
) -> Result<bool, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let n = admin.hdel(&k_meta_db_keys(id), vec![key_hash.to_string()])?;
|
|
||||||
Ok(n > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List access keys, returning (hash, perms, created_at_secs)
|
|
||||||
pub fn list_access_keys(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<Vec<(String, Permissions, u64)>, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let pairs = admin.hgetall(&k_meta_db_keys(id))?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for (hash, val) in pairs {
|
|
||||||
let (perm, ts) = parse_perm_value(&val);
|
|
||||||
out.push((hash, perm, ts));
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify access permission for db id with optional key
|
|
||||||
// Returns:
|
|
||||||
// - Ok(Some(Permissions)) when access is allowed
|
|
||||||
// - Ok(None) when not allowed or db missing (caller can distinguish by calling db_exists)
|
|
||||||
pub fn verify_access(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
id: u64,
|
|
||||||
key_opt: Option<&str>,
|
|
||||||
) -> Result<Option<Permissions>, DBError> {
|
|
||||||
// Admin DB 0: require exact admin_secret
|
|
||||||
if id == 0 {
|
|
||||||
if let Some(k) = key_opt {
|
|
||||||
if k == admin_secret {
|
|
||||||
return Ok(Some(Permissions::ReadWrite));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
if !admin.hexists(k_admin_dbs(), &id.to_string())? {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public?
|
|
||||||
if load_public(&admin, id)? {
|
|
||||||
return Ok(Some(Permissions::ReadWrite));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private: require key and verify
|
|
||||||
if let Some(k) = key_opt {
|
|
||||||
let hash = crate::rpc::hash_key(k);
|
|
||||||
if let Some(v) = admin.hget(&k_meta_db_keys(id), &hash)? {
|
|
||||||
let (perm, _ts) = parse_perm_value(&v);
|
|
||||||
return Ok(Some(perm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enumerate all db ids
|
|
||||||
pub fn list_dbs(
|
|
||||||
base_dir: &str,
|
|
||||||
backend: options::BackendType,
|
|
||||||
admin_secret: &str,
|
|
||||||
) -> Result<Vec<u64>, DBError> {
|
|
||||||
let admin = open_admin_storage(base_dir, backend, admin_secret)?;
|
|
||||||
let ids = admin.hkeys(k_admin_dbs())?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for s in ids {
|
|
||||||
if let Ok(v) = s.parse() {
|
|
||||||
out.push(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: parse permission value "Read:ts" or "ReadWrite:ts"
|
|
||||||
fn parse_perm_value(v: &str) -> (Permissions, u64) {
|
|
||||||
let mut parts = v.split(':');
|
|
||||||
let p = parts.next().unwrap_or("Read");
|
|
||||||
let ts = parts
|
|
||||||
.next()
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(0u64);
|
|
||||||
let perm = match p {
|
|
||||||
"ReadWrite" => Permissions::ReadWrite,
|
|
||||||
_ => Permissions::Read,
|
|
||||||
};
|
|
||||||
(perm, ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
536
src/age.rs
536
src/age.rs
@@ -1,536 +0,0 @@
|
|||||||
//! age.rs — AGE (rage) helpers + persistent key management for your mini-Redis.
|
|
||||||
//
|
|
||||||
// Features:
|
|
||||||
// - X25519 encryption/decryption (age style)
|
|
||||||
// - Ed25519 detached signatures + verification
|
|
||||||
// - Persistent named keys in DB (strings):
|
|
||||||
// age:key:{name} -> X25519 recipient (public encryption key, "age1...")
|
|
||||||
// age:privkey:{name} -> X25519 identity (secret encryption key, "AGE-SECRET-KEY-1...")
|
|
||||||
// age:signpub:{name} -> Ed25519 verify pubkey (public, used to verify signatures)
|
|
||||||
// age:signpriv:{name} -> Ed25519 signing secret key (private, used to sign)
|
|
||||||
// - Base64 wrapping for ciphertext/signature binary blobs.
|
|
||||||
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use secrecy::ExposeSecret;
|
|
||||||
use age::{Decryptor, Encryptor};
|
|
||||||
use age::x25519;
|
|
||||||
|
|
||||||
use ed25519_dalek::{Signature, Signer, Verifier, SigningKey, VerifyingKey};
|
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::convert::TryInto;
|
|
||||||
|
|
||||||
use crate::protocol::Protocol;
|
|
||||||
use crate::server::Server;
|
|
||||||
use crate::error::DBError;
|
|
||||||
|
|
||||||
// ---------- Internal helpers ----------
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum AgeWireError {
|
|
||||||
ParseKey,
|
|
||||||
Crypto(String),
|
|
||||||
Utf8,
|
|
||||||
SignatureLen,
|
|
||||||
NotFound(&'static str), // which kind of key was missing
|
|
||||||
Storage(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgeWireError {
|
|
||||||
fn to_protocol(self) -> Protocol {
|
|
||||||
match self {
|
|
||||||
AgeWireError::ParseKey => Protocol::err("ERR age: invalid key"),
|
|
||||||
AgeWireError::Crypto(e) => Protocol::err(&format!("ERR age: {e}")),
|
|
||||||
AgeWireError::Utf8 => Protocol::err("ERR age: invalid UTF-8 plaintext"),
|
|
||||||
AgeWireError::SignatureLen => Protocol::err("ERR age: bad signature length"),
|
|
||||||
AgeWireError::NotFound(w) => Protocol::err(&format!("ERR age: missing {w}")),
|
|
||||||
AgeWireError::Storage(e) => Protocol::err(&format!("ERR storage: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_recipient(s: &str) -> Result<x25519::Recipient, AgeWireError> {
|
|
||||||
x25519::Recipient::from_str(s).map_err(|_| AgeWireError::ParseKey)
|
|
||||||
}
|
|
||||||
fn parse_identity(s: &str) -> Result<x25519::Identity, AgeWireError> {
|
|
||||||
x25519::Identity::from_str(s).map_err(|_| AgeWireError::ParseKey)
|
|
||||||
}
|
|
||||||
fn parse_ed25519_signing_key(s: &str) -> Result<SigningKey, AgeWireError> {
|
|
||||||
// Parse base64-encoded signing key
|
|
||||||
let bytes = B64.decode(s).map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
if bytes.len() != 32 {
|
|
||||||
return Err(AgeWireError::ParseKey);
|
|
||||||
}
|
|
||||||
let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
Ok(SigningKey::from_bytes(&key_bytes))
|
|
||||||
}
|
|
||||||
fn parse_ed25519_verifying_key(s: &str) -> Result<VerifyingKey, AgeWireError> {
|
|
||||||
// Parse base64-encoded verifying key
|
|
||||||
let bytes = B64.decode(s).map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
if bytes.len() != 32 {
|
|
||||||
return Err(AgeWireError::ParseKey);
|
|
||||||
}
|
|
||||||
let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
VerifyingKey::from_bytes(&key_bytes).map_err(|_| AgeWireError::ParseKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Derivation + Raw X25519 (Ed25519 -> X25519) ----------
|
|
||||||
//
|
|
||||||
// We deterministically derive an X25519 keypair from an Ed25519 SigningKey.
|
|
||||||
// We persist the X25519 public/secret as base64-encoded 32-byte raw values
|
|
||||||
// (no "age1..."/"AGE-SECRET-KEY-1..." formatting). Name-based encrypt/decrypt
|
|
||||||
// uses these raw values directly via x25519-dalek + ChaCha20Poly1305.
|
|
||||||
|
|
||||||
use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Key, Nonce};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use x25519_dalek::{PublicKey as XPublicKey, StaticSecret as XStaticSecret};
|
|
||||||
|
|
||||||
fn derive_x25519_raw_from_ed25519(sk: &SigningKey) -> ([u8; 32], [u8; 32]) {
|
|
||||||
// X25519 secret scalar (clamped) from Ed25519 secret
|
|
||||||
let scalar: [u8; 32] = sk.to_scalar_bytes();
|
|
||||||
// Build X25519 secret/public using dalek
|
|
||||||
let xsec = XStaticSecret::from(scalar);
|
|
||||||
let xpub = XPublicKey::from(&xsec);
|
|
||||||
(xpub.to_bytes(), xsec.to_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_x25519_raw_b64_from_ed25519(sk: &SigningKey) -> (String, String) {
|
|
||||||
let (xpub, xsec) = derive_x25519_raw_from_ed25519(sk);
|
|
||||||
(B64.encode(xpub), B64.encode(xsec))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: detect whether a stored key looks like an age-formatted string
|
|
||||||
fn looks_like_age_format(s: &str) -> bool {
|
|
||||||
s.starts_with("age1") || s.starts_with("AGE-SECRET-KEY-1")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our container format for name-based raw X25519 encryption:
|
|
||||||
// bytes = "HDBX1" (5) || eph_pub(32) || nonce(12) || ciphertext(..)
|
|
||||||
// Entire blob is base64-encoded for transport.
|
|
||||||
const HDBX1_MAGIC: &[u8; 5] = b"HDBX1";
|
|
||||||
|
|
||||||
fn encrypt_b64_with_x25519_raw(recip_pub_b64: &str, msg: &str) -> Result<String, AgeWireError> {
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
// Parse recipient public key (raw 32 bytes, base64)
|
|
||||||
let recip_pub_bytes = B64.decode(recip_pub_b64).map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
if recip_pub_bytes.len() != 32 { return Err(AgeWireError::ParseKey); }
|
|
||||||
let recip_pub_arr: [u8; 32] = recip_pub_bytes.as_slice().try_into().map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
let recip_pub: XPublicKey = XPublicKey::from(recip_pub_arr);
|
|
||||||
|
|
||||||
// Generate ephemeral X25519 keypair
|
|
||||||
let mut eph_sec_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut eph_sec_bytes);
|
|
||||||
let eph_sec = XStaticSecret::from(eph_sec_bytes);
|
|
||||||
let eph_pub = XPublicKey::from(&eph_sec);
|
|
||||||
|
|
||||||
// ECDH
|
|
||||||
let shared = eph_sec.diffie_hellman(&recip_pub);
|
|
||||||
// Derive symmetric key via SHA-256 over context + shared + parties
|
|
||||||
let mut hasher = Sha256::default();
|
|
||||||
hasher.update(b"herodb-x25519-v1");
|
|
||||||
hasher.update(shared.as_bytes());
|
|
||||||
hasher.update(eph_pub.as_bytes());
|
|
||||||
hasher.update(recip_pub.as_bytes());
|
|
||||||
let key_bytes = hasher.finalize();
|
|
||||||
let key = Key::from_slice(&key_bytes[..32]);
|
|
||||||
|
|
||||||
// Nonce (12 bytes)
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
let cipher = ChaCha20Poly1305::new(key);
|
|
||||||
let ct = cipher.encrypt(nonce, msg.as_bytes())
|
|
||||||
.map_err(|e| AgeWireError::Crypto(format!("encrypt: {e}")))?;
|
|
||||||
|
|
||||||
// Assemble container
|
|
||||||
let mut out = Vec::with_capacity(5 + 32 + 12 + ct.len());
|
|
||||||
out.extend_from_slice(HDBX1_MAGIC);
|
|
||||||
out.extend_from_slice(eph_pub.as_bytes());
|
|
||||||
out.extend_from_slice(&nonce_bytes);
|
|
||||||
out.extend_from_slice(&ct);
|
|
||||||
|
|
||||||
Ok(B64.encode(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_b64_with_x25519_raw(identity_sec_b64: &str, ct_b64: &str) -> Result<String, AgeWireError> {
|
|
||||||
// Parse X25519 secret (raw 32 bytes, base64)
|
|
||||||
let sec_bytes = B64.decode(identity_sec_b64).map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
if sec_bytes.len() != 32 { return Err(AgeWireError::ParseKey); }
|
|
||||||
let sec_arr: [u8; 32] = sec_bytes.as_slice().try_into().map_err(|_| AgeWireError::ParseKey)?;
|
|
||||||
let xsec = XStaticSecret::from(sec_arr);
|
|
||||||
let xpub = XPublicKey::from(&xsec); // self public
|
|
||||||
|
|
||||||
// Decode container
|
|
||||||
let blob = B64.decode(ct_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
if blob.len() < 5 + 32 + 12 { return Err(AgeWireError::Crypto("ciphertext too short".to_string())); }
|
|
||||||
if &blob[..5] != HDBX1_MAGIC { return Err(AgeWireError::Crypto("bad header".to_string())); }
|
|
||||||
|
|
||||||
let eph_pub_arr: [u8; 32] = blob[5..5+32].try_into().map_err(|_| AgeWireError::Crypto("bad eph pub".to_string()))?;
|
|
||||||
let eph_pub = XPublicKey::from(eph_pub_arr);
|
|
||||||
let nonce_bytes: [u8; 12] = blob[5+32..5+32+12].try_into().unwrap();
|
|
||||||
let ct = &blob[5+32+12..];
|
|
||||||
|
|
||||||
// Recompute shared + key
|
|
||||||
let shared = xsec.diffie_hellman(&eph_pub);
|
|
||||||
let mut hasher = Sha256::default();
|
|
||||||
hasher.update(b"herodb-x25519-v1");
|
|
||||||
hasher.update(shared.as_bytes());
|
|
||||||
hasher.update(eph_pub.as_bytes());
|
|
||||||
hasher.update(xpub.as_bytes());
|
|
||||||
let key_bytes = hasher.finalize();
|
|
||||||
let key = Key::from_slice(&key_bytes[..32]);
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
let cipher = ChaCha20Poly1305::new(key);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
let pt = cipher.decrypt(nonce, ct)
|
|
||||||
.map_err(|e| AgeWireError::Crypto(format!("decrypt: {e}")))?;
|
|
||||||
|
|
||||||
String::from_utf8(pt).map_err(|_| AgeWireError::Utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Stateless crypto helpers (string in/out) ----------
|
|
||||||
|
|
||||||
pub fn gen_enc_keypair() -> (String, String) {
|
|
||||||
let id = x25519::Identity::generate();
|
|
||||||
let pk = id.to_public();
|
|
||||||
(pk.to_string(), id.to_string().expose_secret().to_string()) // (recipient, identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn gen_sign_keypair() -> (String, String) {
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
// Generate random 32 bytes for the signing key
|
|
||||||
let mut secret_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut secret_bytes);
|
|
||||||
|
|
||||||
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
|
||||||
let verifying_key = signing_key.verifying_key();
|
|
||||||
|
|
||||||
// Encode as base64 for storage
|
|
||||||
let signing_key_b64 = B64.encode(signing_key.to_bytes());
|
|
||||||
let verifying_key_b64 = B64.encode(verifying_key.to_bytes());
|
|
||||||
|
|
||||||
(verifying_key_b64, signing_key_b64) // (verify_pub, signing_secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt `msg` for `recipient_str` (X25519). Returns base64(ciphertext).
|
|
||||||
pub fn encrypt_b64(recipient_str: &str, msg: &str) -> Result<String, AgeWireError> {
|
|
||||||
let recipient = parse_recipient(recipient_str)?;
|
|
||||||
let enc = Encryptor::with_recipients(vec![Box::new(recipient)])
|
|
||||||
.expect("failed to create encryptor"); // Handle Option<Encryptor>
|
|
||||||
let mut out = Vec::new();
|
|
||||||
{
|
|
||||||
use std::io::Write;
|
|
||||||
let mut w = enc.wrap_output(&mut out).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
w.write_all(msg.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
w.finish().map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
}
|
|
||||||
Ok(B64.encode(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt base64(ciphertext) with `identity_str`. Returns plaintext String.
|
|
||||||
pub fn decrypt_b64(identity_str: &str, ct_b64: &str) -> Result<String, AgeWireError> {
|
|
||||||
let id = parse_identity(identity_str)?;
|
|
||||||
let ct = B64.decode(ct_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
let dec = Decryptor::new(&ct[..]).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
|
|
||||||
// The decrypt method returns a Result<StreamReader, DecryptError>
|
|
||||||
let mut r = match dec {
|
|
||||||
Decryptor::Recipients(d) => d.decrypt(std::iter::once(&id as &dyn age::Identity))
|
|
||||||
.map_err(|e| AgeWireError::Crypto(e.to_string()))?,
|
|
||||||
Decryptor::Passphrase(_) => return Err(AgeWireError::Crypto("Expected recipients, got passphrase".to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut pt = Vec::new();
|
|
||||||
use std::io::Read;
|
|
||||||
r.read_to_end(&mut pt).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
String::from_utf8(pt).map_err(|_| AgeWireError::Utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sign bytes of `msg` (detached). Returns base64(signature bytes, 64 bytes).
|
|
||||||
pub fn sign_b64(signing_secret_str: &str, msg: &str) -> Result<String, AgeWireError> {
|
|
||||||
let signing_key = parse_ed25519_signing_key(signing_secret_str)?;
|
|
||||||
let sig = signing_key.sign(msg.as_bytes());
|
|
||||||
Ok(B64.encode(sig.to_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify detached signature (base64) for `msg` with pubkey.
|
|
||||||
pub fn verify_b64(verify_pub_str: &str, msg: &str, sig_b64: &str) -> Result<bool, AgeWireError> {
|
|
||||||
let verifying_key = parse_ed25519_verifying_key(verify_pub_str)?;
|
|
||||||
let sig_bytes = B64.decode(sig_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?;
|
|
||||||
if sig_bytes.len() != 64 {
|
|
||||||
return Err(AgeWireError::SignatureLen);
|
|
||||||
}
|
|
||||||
let sig = Signature::from_bytes(sig_bytes[..].try_into().unwrap());
|
|
||||||
Ok(verifying_key.verify(msg.as_bytes(), &sig).is_ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Storage helpers ----------
|
|
||||||
|
|
||||||
fn sget(server: &Server, key: &str) -> Result<Option<String>, AgeWireError> {
|
|
||||||
let st = server.current_storage().map_err(|e| AgeWireError::Storage(e.0))?;
|
|
||||||
st.get(key).map_err(|e| AgeWireError::Storage(e.0))
|
|
||||||
}
|
|
||||||
fn sset(server: &Server, key: &str, val: &str) -> Result<(), AgeWireError> {
|
|
||||||
let st = server.current_storage().map_err(|e| AgeWireError::Storage(e.0))?;
|
|
||||||
st.set(key.to_string(), val.to_string()).map_err(|e| AgeWireError::Storage(e.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enc_pub_key_key(name: &str) -> String { format!("age:key:{name}") }
|
|
||||||
fn enc_priv_key_key(name: &str) -> String { format!("age:privkey:{name}") }
|
|
||||||
fn sign_pub_key_key(name: &str) -> String { format!("age:signpub:{name}") }
|
|
||||||
fn sign_priv_key_key(name: &str) -> String { format!("age:signpriv:{name}") }
|
|
||||||
|
|
||||||
// ---------- Command handlers (RESP Protocol) ----------
|
|
||||||
// Basic (stateless) ones kept for completeness
|
|
||||||
|
|
||||||
pub async fn cmd_age_genenc() -> Protocol {
|
|
||||||
let (recip, ident) = gen_enc_keypair();
|
|
||||||
Protocol::Array(vec![Protocol::BulkString(recip), Protocol::BulkString(ident)])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_gensign() -> Protocol {
|
|
||||||
let (verify, secret) = gen_sign_keypair();
|
|
||||||
Protocol::Array(vec![Protocol::BulkString(verify), Protocol::BulkString(secret)])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_encrypt(recipient: &str, message: &str) -> Protocol {
|
|
||||||
match encrypt_b64(recipient, message) {
|
|
||||||
Ok(b64) => Protocol::BulkString(b64),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_decrypt(identity: &str, ct_b64: &str) -> Protocol {
|
|
||||||
match decrypt_b64(identity, ct_b64) {
|
|
||||||
Ok(pt) => Protocol::BulkString(pt),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_sign(secret: &str, message: &str) -> Protocol {
|
|
||||||
match sign_b64(secret, message) {
|
|
||||||
Ok(b64sig) => Protocol::BulkString(b64sig),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_verify(verify_pub: &str, message: &str, sig_b64: &str) -> Protocol {
|
|
||||||
match verify_b64(verify_pub, message, sig_b64) {
|
|
||||||
Ok(true) => Protocol::SimpleString("1".to_string()),
|
|
||||||
Ok(false) => Protocol::SimpleString("0".to_string()),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- NEW: unified stateless generator (Ed25519 + derived X25519 raw) ----------
|
|
||||||
//
|
|
||||||
// Returns 4-tuple:
|
|
||||||
// [ verify_pub_b64 (32B), signpriv_b64 (32B), x25519_pub_b64 (32B), x25519_sec_b64 (32B) ]
|
|
||||||
// No persistence (stateless).
|
|
||||||
pub async fn cmd_age_genkey() -> Protocol {
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
let mut secret_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut secret_bytes);
|
|
||||||
|
|
||||||
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
|
||||||
let verifying_key = signing_key.verifying_key();
|
|
||||||
|
|
||||||
let verify_b64 = B64.encode(verifying_key.to_bytes());
|
|
||||||
let sign_b64 = B64.encode(signing_key.to_bytes());
|
|
||||||
|
|
||||||
let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&signing_key);
|
|
||||||
|
|
||||||
Protocol::Array(vec![
|
|
||||||
Protocol::BulkString(verify_b64),
|
|
||||||
Protocol::BulkString(sign_b64),
|
|
||||||
Protocol::BulkString(xpub_b64),
|
|
||||||
Protocol::BulkString(xsec_b64),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- NEW: Persistent, named-key commands ----------
|
|
||||||
|
|
||||||
pub async fn cmd_age_keygen(server: &Server, name: &str) -> Protocol {
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
// Generate Ed25519 keypair
|
|
||||||
let mut secret_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut secret_bytes);
|
|
||||||
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
|
||||||
let verifying_key = signing_key.verifying_key();
|
|
||||||
|
|
||||||
// Encode Ed25519 as base64 (32 bytes)
|
|
||||||
let verify_b64 = B64.encode(verifying_key.to_bytes());
|
|
||||||
let sign_b64 = B64.encode(signing_key.to_bytes());
|
|
||||||
|
|
||||||
// Derive X25519 raw (32-byte) keys and encode as base64
|
|
||||||
let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&signing_key);
|
|
||||||
|
|
||||||
// Decode to create age-formatted strings
|
|
||||||
let xpub_bytes = B64.decode(&xpub_b64).unwrap();
|
|
||||||
let xsec_bytes = B64.decode(&xsec_b64).unwrap();
|
|
||||||
let xpub_arr: [u8; 32] = xpub_bytes.as_slice().try_into().unwrap();
|
|
||||||
let xsec_arr: [u8; 32] = xsec_bytes.as_slice().try_into().unwrap();
|
|
||||||
let recip_str = format!("age1{}", B64.encode(xpub_arr));
|
|
||||||
let ident_str = format!("AGE-SECRET-KEY-1{}", B64.encode(xsec_arr));
|
|
||||||
|
|
||||||
// Persist Ed25519 and derived X25519 (key-managed mode)
|
|
||||||
if let Err(e) = sset(server, &sign_pub_key_key(name), &verify_b64) { return e.to_protocol(); }
|
|
||||||
if let Err(e) = sset(server, &sign_priv_key_key(name), &sign_b64) { return e.to_protocol(); }
|
|
||||||
if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); }
|
|
||||||
if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); }
|
|
||||||
|
|
||||||
// Return [recipient, identity] in age format
|
|
||||||
Protocol::Array(vec![
|
|
||||||
Protocol::BulkString(recip_str),
|
|
||||||
Protocol::BulkString(ident_str),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_signkeygen(server: &Server, name: &str) -> Protocol {
|
|
||||||
let (verify, secret) = gen_sign_keypair();
|
|
||||||
if let Err(e) = sset(server, &sign_pub_key_key(name), &verify) { return e.to_protocol(); }
|
|
||||||
if let Err(e) = sset(server, &sign_priv_key_key(name), &secret) { return e.to_protocol(); }
|
|
||||||
Protocol::Array(vec![Protocol::BulkString(verify), Protocol::BulkString(secret)])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_encrypt_name(server: &Server, name: &str, message: &str) -> Protocol {
|
|
||||||
// Load stored recipient (could be raw b64 32-byte or "age1..." from legacy)
|
|
||||||
let recip_or_b64 = match sget(server, &enc_pub_key_key(name)) {
|
|
||||||
Ok(Some(v)) => v,
|
|
||||||
Ok(None) => {
|
|
||||||
// Derive from stored Ed25519 if present, then persist
|
|
||||||
match sget(server, &sign_priv_key_key(name)) {
|
|
||||||
Ok(Some(sign_b64)) => {
|
|
||||||
let sk = match parse_ed25519_signing_key(&sign_b64) {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&sk);
|
|
||||||
if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); }
|
|
||||||
if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); }
|
|
||||||
xpub_b64
|
|
||||||
}
|
|
||||||
Ok(None) => return AgeWireError::NotFound("recipient (age:key:{name})").to_protocol(),
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if looks_like_age_format(&recip_or_b64) {
|
|
||||||
match encrypt_b64(&recip_or_b64, message) {
|
|
||||||
Ok(ct) => Protocol::BulkString(ct),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match encrypt_b64_with_x25519_raw(&recip_or_b64, message) {
|
|
||||||
Ok(ct) => Protocol::BulkString(ct),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_decrypt_name(server: &Server, name: &str, ct_b64: &str) -> Protocol {
|
|
||||||
// Load stored identity (could be raw b64 32-byte or "AGE-SECRET-KEY-1..." from legacy)
|
|
||||||
let ident_or_b64 = match sget(server, &enc_priv_key_key(name)) {
|
|
||||||
Ok(Some(v)) => v,
|
|
||||||
Ok(None) => {
|
|
||||||
// Derive from stored Ed25519 if present, then persist
|
|
||||||
match sget(server, &sign_priv_key_key(name)) {
|
|
||||||
Ok(Some(sign_b64)) => {
|
|
||||||
let sk = match parse_ed25519_signing_key(&sign_b64) {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&sk);
|
|
||||||
if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); }
|
|
||||||
if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); }
|
|
||||||
xsec_b64
|
|
||||||
}
|
|
||||||
Ok(None) => return AgeWireError::NotFound("identity (age:privkey:{name})").to_protocol(),
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if looks_like_age_format(&ident_or_b64) {
|
|
||||||
match decrypt_b64(&ident_or_b64, ct_b64) {
|
|
||||||
Ok(pt) => Protocol::BulkString(pt),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match decrypt_b64_with_x25519_raw(&ident_or_b64, ct_b64) {
|
|
||||||
Ok(pt) => Protocol::BulkString(pt),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_sign_name(server: &Server, name: &str, message: &str) -> Protocol {
|
|
||||||
let sec = match sget(server, &sign_priv_key_key(name)) {
|
|
||||||
Ok(Some(v)) => v,
|
|
||||||
Ok(None) => return AgeWireError::NotFound("signing secret (age:signpriv:{name})").to_protocol(),
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
match sign_b64(&sec, message) {
|
|
||||||
Ok(sig) => Protocol::BulkString(sig),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_verify_name(server: &Server, name: &str, message: &str, sig_b64: &str) -> Protocol {
|
|
||||||
let pubk = match sget(server, &sign_pub_key_key(name)) {
|
|
||||||
Ok(Some(v)) => v,
|
|
||||||
Ok(None) => return AgeWireError::NotFound("verify pubkey (age:signpub:{name})").to_protocol(),
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
match verify_b64(&pubk, message, sig_b64) {
|
|
||||||
Ok(true) => Protocol::SimpleString("1".to_string()),
|
|
||||||
Ok(false) => Protocol::SimpleString("0".to_string()),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_age_list(server: &Server) -> Protocol {
|
|
||||||
// Return a flat, deduplicated, sorted list of managed key names (no labels)
|
|
||||||
let st = match server.current_storage() { Ok(s) => s, Err(e) => return Protocol::err(&e.0) };
|
|
||||||
|
|
||||||
let pull = |pat: &str, prefix: &str| -> Result<Vec<String>, DBError> {
|
|
||||||
let keys = st.keys(pat)?;
|
|
||||||
let mut names: Vec<String> = keys
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|k| k.strip_prefix(prefix).map(|x| x.to_string()))
|
|
||||||
.collect();
|
|
||||||
names.sort();
|
|
||||||
Ok(names)
|
|
||||||
};
|
|
||||||
|
|
||||||
let encpub = match pull("age:key:*", "age:key:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
|
||||||
let encpriv = match pull("age:privkey:*", "age:privkey:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
|
||||||
let signpub = match pull("age:signpub:*", "age:signpub:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
|
||||||
let signpriv = match pull("age:signpriv:*", "age:signpriv:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) };
|
|
||||||
|
|
||||||
let mut set: HashSet<String> = HashSet::new();
|
|
||||||
for n in encpub.into_iter().chain(encpriv).chain(signpub).chain(signpriv) {
|
|
||||||
set.insert(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut names: Vec<String> = set.into_iter().collect();
|
|
||||||
names.sort();
|
|
||||||
|
|
||||||
Protocol::Array(names.into_iter().map(Protocol::BulkString).collect())
|
|
||||||
}
|
|
14
src/lib.rs
14
src/lib.rs
@@ -1,14 +0,0 @@
|
|||||||
pub mod age;
|
|
||||||
pub mod sym;
|
|
||||||
pub mod cmd;
|
|
||||||
pub mod crypto;
|
|
||||||
pub mod error;
|
|
||||||
pub mod options;
|
|
||||||
pub mod protocol;
|
|
||||||
pub mod rpc;
|
|
||||||
pub mod rpc_server;
|
|
||||||
pub mod server;
|
|
||||||
pub mod storage;
|
|
||||||
pub mod storage_trait;
|
|
||||||
pub mod storage_sled;
|
|
||||||
pub mod admin_meta;
|
|
439
src/rpc.rs
439
src/rpc.rs
@@ -1,439 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use jsonrpsee::{core::RpcResult, proc_macros::rpc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::server::Server;
|
|
||||||
use crate::options::DBOption;
|
|
||||||
use crate::admin_meta;
|
|
||||||
|
|
||||||
/// Database backend types
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum BackendType {
|
|
||||||
Redb,
|
|
||||||
Sled,
|
|
||||||
// Future: InMemory, Custom(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Database configuration
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DatabaseConfig {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub storage_path: Option<String>,
|
|
||||||
pub max_size: Option<u64>,
|
|
||||||
pub redis_version: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Database information returned by metadata queries
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DatabaseInfo {
|
|
||||||
pub id: u64,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub backend: BackendType,
|
|
||||||
pub encrypted: bool,
|
|
||||||
pub redis_version: Option<String>,
|
|
||||||
pub storage_path: Option<String>,
|
|
||||||
pub size_on_disk: Option<u64>,
|
|
||||||
pub key_count: Option<u64>,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub last_access: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access permissions for database keys
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub enum Permissions {
|
|
||||||
Read,
|
|
||||||
ReadWrite,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Access key information returned by RPC
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AccessKeyInfo {
|
|
||||||
pub hash: String,
|
|
||||||
pub permissions: Permissions,
|
|
||||||
pub created_at: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hash a plaintext key using SHA-256
|
|
||||||
pub fn hash_key(key: &str) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(key.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RPC trait for HeroDB management
|
|
||||||
#[rpc(server, client, namespace = "herodb")]
|
|
||||||
pub trait Rpc {
|
|
||||||
/// Create a new database with specified configuration
|
|
||||||
#[method(name = "createDatabase")]
|
|
||||||
async fn create_database(
|
|
||||||
&self,
|
|
||||||
backend: BackendType,
|
|
||||||
config: DatabaseConfig,
|
|
||||||
encryption_key: Option<String>,
|
|
||||||
) -> RpcResult<u64>;
|
|
||||||
|
|
||||||
/// Set encryption for an existing database (write-only key)
|
|
||||||
#[method(name = "setEncryption")]
|
|
||||||
async fn set_encryption(&self, db_id: u64, encryption_key: String) -> RpcResult<bool>;
|
|
||||||
|
|
||||||
/// List all managed databases
|
|
||||||
#[method(name = "listDatabases")]
|
|
||||||
async fn list_databases(&self) -> RpcResult<Vec<DatabaseInfo>>;
|
|
||||||
|
|
||||||
/// Get detailed information about a specific database
|
|
||||||
#[method(name = "getDatabaseInfo")]
|
|
||||||
async fn get_database_info(&self, db_id: u64) -> RpcResult<DatabaseInfo>;
|
|
||||||
|
|
||||||
/// Delete a database
|
|
||||||
#[method(name = "deleteDatabase")]
|
|
||||||
async fn delete_database(&self, db_id: u64) -> RpcResult<bool>;
|
|
||||||
|
|
||||||
/// Get server statistics
|
|
||||||
#[method(name = "getServerStats")]
|
|
||||||
async fn get_server_stats(&self) -> RpcResult<HashMap<String, serde_json::Value>>;
|
|
||||||
|
|
||||||
/// Add an access key to a database
|
|
||||||
#[method(name = "addAccessKey")]
|
|
||||||
async fn add_access_key(&self, db_id: u64, key: String, permissions: String) -> RpcResult<bool>;
|
|
||||||
|
|
||||||
/// Delete an access key from a database
|
|
||||||
#[method(name = "deleteAccessKey")]
|
|
||||||
async fn delete_access_key(&self, db_id: u64, key_hash: String) -> RpcResult<bool>;
|
|
||||||
|
|
||||||
/// List all access keys for a database
|
|
||||||
#[method(name = "listAccessKeys")]
|
|
||||||
async fn list_access_keys(&self, db_id: u64) -> RpcResult<Vec<AccessKeyInfo>>;
|
|
||||||
|
|
||||||
/// Set database public/private status
|
|
||||||
#[method(name = "setDatabasePublic")]
|
|
||||||
async fn set_database_public(&self, db_id: u64, public: bool) -> RpcResult<bool>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RPC Server implementation
|
|
||||||
pub struct RpcServerImpl {
|
|
||||||
/// Base directory for database files
|
|
||||||
base_dir: String,
|
|
||||||
/// Managed database servers
|
|
||||||
servers: Arc<RwLock<HashMap<u64, Arc<Server>>>>,
|
|
||||||
/// Default backend type
|
|
||||||
backend: crate::options::BackendType,
|
|
||||||
/// Admin secret used to encrypt DB 0 and authorize admin access
|
|
||||||
admin_secret: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpcServerImpl {
|
|
||||||
/// Create a new RPC server instance
|
|
||||||
pub fn new(base_dir: String, backend: crate::options::BackendType, admin_secret: String) -> Self {
|
|
||||||
Self {
|
|
||||||
base_dir,
|
|
||||||
servers: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
backend,
|
|
||||||
admin_secret,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or create a server instance for the given database ID
|
|
||||||
async fn get_or_create_server(&self, db_id: u64) -> Result<Arc<Server>, jsonrpsee::types::ErrorObjectOwned> {
|
|
||||||
// Check if server already exists
|
|
||||||
{
|
|
||||||
let servers = self.servers.read().await;
|
|
||||||
if let Some(server) = servers.get(&db_id) {
|
|
||||||
return Ok(server.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate existence via admin DB 0 (metadata), not filesystem presence
|
|
||||||
let exists = admin_meta::db_exists(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
if !exists {
|
|
||||||
return Err(jsonrpsee::types::ErrorObjectOwned::owned(
|
|
||||||
-32000,
|
|
||||||
format!("Database {} not found", db_id),
|
|
||||||
None::<()>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create server instance with default options
|
|
||||||
let db_option = DBOption {
|
|
||||||
dir: self.base_dir.clone(),
|
|
||||||
port: 0, // Not used for RPC-managed databases
|
|
||||||
debug: false,
|
|
||||||
encryption_key: None,
|
|
||||||
encrypt: false,
|
|
||||||
backend: self.backend.clone(),
|
|
||||||
admin_secret: self.admin_secret.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut server = Server::new(db_option).await;
|
|
||||||
|
|
||||||
// Set the selected database to the db_id
|
|
||||||
server.selected_db = db_id;
|
|
||||||
|
|
||||||
// Lazily open/create physical storage according to admin meta (per-db encryption)
|
|
||||||
let _ = server.current_storage();
|
|
||||||
|
|
||||||
// Store the server
|
|
||||||
let mut servers = self.servers.write().await;
|
|
||||||
servers.insert(db_id, Arc::new(server.clone()));
|
|
||||||
|
|
||||||
Ok(Arc::new(server))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discover existing database IDs from admin DB 0
|
|
||||||
async fn discover_databases(&self) -> Vec<u64> {
|
|
||||||
admin_meta::list_dbs(&self.base_dir, self.backend.clone(), &self.admin_secret)
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Build database file path for given server/db_id
|
|
||||||
fn db_file_path(&self, server: &Server, db_id: u64) -> std::path::PathBuf {
|
|
||||||
std::path::PathBuf::from(&server.option.dir).join(format!("{}.db", db_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively compute size on disk for the database path
|
|
||||||
fn compute_size_on_disk(&self, path: &std::path::Path) -> Option<u64> {
|
|
||||||
fn dir_size(p: &std::path::Path) -> u64 {
|
|
||||||
if p.is_file() {
|
|
||||||
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
|
|
||||||
} else if p.is_dir() {
|
|
||||||
let mut total = 0u64;
|
|
||||||
if let Ok(read) = std::fs::read_dir(p) {
|
|
||||||
for entry in read.flatten() {
|
|
||||||
total += dir_size(&entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(dir_size(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract created and last access times (secs) from a path, with fallbacks
|
|
||||||
fn get_file_times_secs(path: &std::path::Path) -> (u64, Option<u64>) {
|
|
||||||
let now = std::time::SystemTime::now();
|
|
||||||
let created = std::fs::metadata(path)
|
|
||||||
.and_then(|m| m.created().or_else(|_| m.modified()))
|
|
||||||
.unwrap_or(now)
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let last_access = std::fs::metadata(path)
|
|
||||||
.and_then(|m| m.accessed())
|
|
||||||
.ok()
|
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs()));
|
|
||||||
|
|
||||||
(created, last_access)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compose a DatabaseInfo by probing storage and filesystem, with admin meta for access key count
|
|
||||||
async fn build_database_info(&self, db_id: u64, server: &Server) -> DatabaseInfo {
|
|
||||||
// Probe storage to determine encryption state
|
|
||||||
let storage = server.current_storage().ok();
|
|
||||||
let encrypted = storage.as_ref().map(|s| s.is_encrypted()).unwrap_or(server.option.encrypt);
|
|
||||||
|
|
||||||
// Get actual key count from storage
|
|
||||||
let key_count = storage.as_ref()
|
|
||||||
.and_then(|s| s.dbsize().ok())
|
|
||||||
.map(|count| count as u64);
|
|
||||||
|
|
||||||
// Get database name from admin meta
|
|
||||||
let name = admin_meta::get_database_name(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id)
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
// Compute size on disk and timestamps from the DB file path
|
|
||||||
let db_path = self.db_file_path(server, db_id);
|
|
||||||
let size_on_disk = self.compute_size_on_disk(&db_path);
|
|
||||||
let (created_at, last_access) = Self::get_file_times_secs(&db_path);
|
|
||||||
|
|
||||||
let backend = match server.option.backend {
|
|
||||||
crate::options::BackendType::Redb => BackendType::Redb,
|
|
||||||
crate::options::BackendType::Sled => BackendType::Sled,
|
|
||||||
};
|
|
||||||
|
|
||||||
DatabaseInfo {
|
|
||||||
id: db_id,
|
|
||||||
name,
|
|
||||||
backend,
|
|
||||||
encrypted,
|
|
||||||
redis_version: Some("7.0".to_string()),
|
|
||||||
storage_path: Some(server.option.dir.clone()),
|
|
||||||
size_on_disk,
|
|
||||||
key_count,
|
|
||||||
created_at,
|
|
||||||
last_access,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[jsonrpsee::core::async_trait]
|
|
||||||
impl RpcServer for RpcServerImpl {
|
|
||||||
async fn create_database(
|
|
||||||
&self,
|
|
||||||
backend: BackendType,
|
|
||||||
config: DatabaseConfig,
|
|
||||||
encryption_key: Option<String>,
|
|
||||||
) -> RpcResult<u64> {
|
|
||||||
// Allocate new ID via admin DB 0
|
|
||||||
let db_id = admin_meta::allocate_next_id(&self.base_dir, self.backend.clone(), &self.admin_secret)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
|
|
||||||
// Persist per-db encryption key in admin DB 0 if provided
|
|
||||||
if let Some(ref key) = encryption_key {
|
|
||||||
admin_meta::set_enc_key(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, key)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist database name if provided
|
|
||||||
if let Some(ref name) = config.name {
|
|
||||||
admin_meta::set_database_name(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, name)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure base dir exists
|
|
||||||
if let Err(e) = std::fs::create_dir_all(&self.base_dir) {
|
|
||||||
return Err(jsonrpsee::types::ErrorObjectOwned::owned(-32000, format!("Failed to ensure base dir: {}", e), None::<()>));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create server instance using base_dir and admin secret
|
|
||||||
let option = DBOption {
|
|
||||||
dir: self.base_dir.clone(),
|
|
||||||
port: 0, // Not used for RPC-managed databases
|
|
||||||
debug: false,
|
|
||||||
encryption_key: None, // per-db key is stored in admin DB 0
|
|
||||||
encrypt: false, // encryption decided per-db at open time
|
|
||||||
backend: match backend {
|
|
||||||
BackendType::Redb => crate::options::BackendType::Redb,
|
|
||||||
BackendType::Sled => crate::options::BackendType::Sled,
|
|
||||||
},
|
|
||||||
admin_secret: self.admin_secret.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut server = Server::new(option).await;
|
|
||||||
server.selected_db = db_id;
|
|
||||||
|
|
||||||
// Initialize storage to create physical <id>.db with proper encryption from admin meta
|
|
||||||
let _ = server.current_storage();
|
|
||||||
|
|
||||||
// Store the server in cache
|
|
||||||
let mut servers = self.servers.write().await;
|
|
||||||
servers.insert(db_id, Arc::new(server));
|
|
||||||
|
|
||||||
Ok(db_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_encryption(&self, _db_id: u64, _encryption_key: String) -> RpcResult<bool> {
|
|
||||||
// For now, return false as encryption can only be set during creation
|
|
||||||
let _servers = self.servers.read().await;
|
|
||||||
// TODO: Implement encryption setting for existing databases
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_databases(&self) -> RpcResult<Vec<DatabaseInfo>> {
|
|
||||||
let db_ids = self.discover_databases().await;
|
|
||||||
let mut result = Vec::new();
|
|
||||||
|
|
||||||
for db_id in db_ids {
|
|
||||||
if let Ok(server) = self.get_or_create_server(db_id).await {
|
|
||||||
// Build accurate info from storage/meta/fs
|
|
||||||
let info = self.build_database_info(db_id, &server).await;
|
|
||||||
result.push(info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_database_info(&self, db_id: u64) -> RpcResult<DatabaseInfo> {
|
|
||||||
let server = self.get_or_create_server(db_id).await?;
|
|
||||||
// Build accurate info from storage/meta/fs
|
|
||||||
let info = self.build_database_info(db_id, &server).await;
|
|
||||||
Ok(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_database(&self, db_id: u64) -> RpcResult<bool> {
|
|
||||||
let mut servers = self.servers.write().await;
|
|
||||||
|
|
||||||
if let Some(_server) = servers.remove(&db_id) {
|
|
||||||
// Clean up database files
|
|
||||||
let db_path = std::path::PathBuf::from(&self.base_dir).join(format!("{}.db", db_id));
|
|
||||||
if db_path.exists() {
|
|
||||||
if db_path.is_dir() {
|
|
||||||
std::fs::remove_dir_all(&db_path).ok();
|
|
||||||
} else {
|
|
||||||
std::fs::remove_file(&db_path).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_server_stats(&self) -> RpcResult<HashMap<String, serde_json::Value>> {
|
|
||||||
let db_ids = self.discover_databases().await;
|
|
||||||
let mut stats = HashMap::new();
|
|
||||||
|
|
||||||
stats.insert("total_databases".to_string(), serde_json::json!(db_ids.len()));
|
|
||||||
stats.insert("uptime".to_string(), serde_json::json!(
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs()
|
|
||||||
));
|
|
||||||
|
|
||||||
Ok(stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_access_key(&self, db_id: u64, key: String, permissions: String) -> RpcResult<bool> {
|
|
||||||
let perms = match permissions.to_lowercase().as_str() {
|
|
||||||
"read" => Permissions::Read,
|
|
||||||
"readwrite" => Permissions::ReadWrite,
|
|
||||||
_ => return Err(jsonrpsee::types::ErrorObjectOwned::owned(
|
|
||||||
-32000,
|
|
||||||
"Invalid permissions: use 'read' or 'readwrite'",
|
|
||||||
None::<()>
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
|
|
||||||
admin_meta::add_access_key(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, &key, perms)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_access_key(&self, db_id: u64, key_hash: String) -> RpcResult<bool> {
|
|
||||||
let ok = admin_meta::delete_access_key(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, &key_hash)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
Ok(ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_access_keys(&self, db_id: u64) -> RpcResult<Vec<AccessKeyInfo>> {
|
|
||||||
let pairs = admin_meta::list_access_keys(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
let keys: Vec<AccessKeyInfo> = pairs.into_iter().map(|(hash, perm, ts)| AccessKeyInfo {
|
|
||||||
hash,
|
|
||||||
permissions: perm,
|
|
||||||
created_at: ts,
|
|
||||||
}).collect();
|
|
||||||
Ok(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_database_public(&self, db_id: u64, public: bool) -> RpcResult<bool> {
|
|
||||||
admin_meta::set_database_public(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, public)
|
|
||||||
.map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,49 +0,0 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
use jsonrpsee::server::{ServerBuilder, ServerHandle};
|
|
||||||
use jsonrpsee::RpcModule;
|
|
||||||
|
|
||||||
use crate::rpc::{RpcServer, RpcServerImpl};
|
|
||||||
|
|
||||||
/// Start the RPC server on the specified address
|
|
||||||
pub async fn start_rpc_server(addr: SocketAddr, base_dir: String, backend: crate::options::BackendType, admin_secret: String) -> Result<ServerHandle, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
// Create the RPC server implementation
|
|
||||||
let rpc_impl = RpcServerImpl::new(base_dir, backend, admin_secret);
|
|
||||||
|
|
||||||
// Create the RPC module
|
|
||||||
let mut module = RpcModule::new(());
|
|
||||||
module.merge(RpcServer::into_rpc(rpc_impl))?;
|
|
||||||
|
|
||||||
// Build the server with both HTTP and WebSocket support
|
|
||||||
let server = ServerBuilder::default()
|
|
||||||
.build(addr)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Start the server
|
|
||||||
let handle = server.start(module);
|
|
||||||
|
|
||||||
println!("RPC server started on {}", addr);
|
|
||||||
|
|
||||||
Ok(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_rpc_server_startup() {
|
|
||||||
let addr = "127.0.0.1:0".parse().unwrap(); // Use port 0 for auto-assignment
|
|
||||||
let base_dir = "/tmp/test_rpc".to_string();
|
|
||||||
let backend = crate::options::BackendType::Redb; // Default for test
|
|
||||||
|
|
||||||
let handle = start_rpc_server(addr, base_dir, backend, "test-admin".to_string()).await.unwrap();
|
|
||||||
|
|
||||||
// Give the server a moment to start
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
|
|
||||||
// Stop the server
|
|
||||||
handle.stop().unwrap();
|
|
||||||
handle.stopped().await;
|
|
||||||
}
|
|
||||||
}
|
|
123
src/sym.rs
123
src/sym.rs
@@ -1,123 +0,0 @@
|
|||||||
//! sym.rs — Stateless symmetric encryption (Phase 1)
|
|
||||||
//!
|
|
||||||
//! Commands implemented (RESP):
|
|
||||||
//! - SYM KEYGEN
|
|
||||||
//! - SYM ENCRYPT <key_b64> <message>
|
|
||||||
//! - SYM DECRYPT <key_b64> <ciphertext_b64>
|
|
||||||
//!
|
|
||||||
//! Notes:
|
|
||||||
//! - Raw key: exactly 32 bytes, provided as Base64 in commands.
|
|
||||||
//! - Cipher: XChaCha20-Poly1305 (AEAD) without AAD in Phase 1
|
|
||||||
//! - Ciphertext binary layout: [version:1][nonce:24][ciphertext||tag]
|
|
||||||
//! - Encoding for wire I/O: Base64
|
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
|
||||||
use chacha20poly1305::{
|
|
||||||
aead::{Aead, KeyInit, OsRng},
|
|
||||||
XChaCha20Poly1305, XNonce,
|
|
||||||
};
|
|
||||||
use rand::RngCore;
|
|
||||||
|
|
||||||
use crate::protocol::Protocol;
|
|
||||||
|
|
||||||
const VERSION: u8 = 1;
|
|
||||||
const NONCE_LEN: usize = 24;
|
|
||||||
const TAG_LEN: usize = 16;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum SymWireError {
|
|
||||||
InvalidKey,
|
|
||||||
BadEncoding,
|
|
||||||
BadFormat,
|
|
||||||
BadVersion(u8),
|
|
||||||
Crypto,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SymWireError {
|
|
||||||
fn to_protocol(self) -> Protocol {
|
|
||||||
match self {
|
|
||||||
SymWireError::InvalidKey => Protocol::err("ERR sym: invalid key"),
|
|
||||||
SymWireError::BadEncoding => Protocol::err("ERR sym: bad encoding"),
|
|
||||||
SymWireError::BadFormat => Protocol::err("ERR sym: bad format"),
|
|
||||||
SymWireError::BadVersion(v) => Protocol::err(&format!("ERR sym: unsupported version {}", v)),
|
|
||||||
SymWireError::Crypto => Protocol::err("ERR sym: auth failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_key_b64(s: &str) -> Result<chacha20poly1305::Key, SymWireError> {
|
|
||||||
let bytes = B64.decode(s.as_bytes()).map_err(|_| SymWireError::BadEncoding)?;
|
|
||||||
if bytes.len() != 32 {
|
|
||||||
return Err(SymWireError::InvalidKey);
|
|
||||||
}
|
|
||||||
Ok(chacha20poly1305::Key::from_slice(&bytes).to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encrypt_blob(key: &chacha20poly1305::Key, plaintext: &[u8]) -> Result<Vec<u8>, SymWireError> {
|
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
|
||||||
|
|
||||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = XNonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(1 + NONCE_LEN + plaintext.len() + TAG_LEN);
|
|
||||||
out.push(VERSION);
|
|
||||||
out.extend_from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let ct = cipher.encrypt(nonce, plaintext).map_err(|_| SymWireError::Crypto)?;
|
|
||||||
out.extend_from_slice(&ct);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_blob(key: &chacha20poly1305::Key, blob: &[u8]) -> Result<Vec<u8>, SymWireError> {
|
|
||||||
if blob.len() < 1 + NONCE_LEN + TAG_LEN {
|
|
||||||
return Err(SymWireError::BadFormat);
|
|
||||||
}
|
|
||||||
let ver = blob[0];
|
|
||||||
if ver != VERSION {
|
|
||||||
return Err(SymWireError::BadVersion(ver));
|
|
||||||
}
|
|
||||||
let nonce = XNonce::from_slice(&blob[1..1 + NONCE_LEN]);
|
|
||||||
let ct = &blob[1 + NONCE_LEN..];
|
|
||||||
|
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
|
||||||
cipher.decrypt(nonce, ct).map_err(|_| SymWireError::Crypto)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Command handlers (RESP) ----------
|
|
||||||
|
|
||||||
pub async fn cmd_sym_keygen() -> Protocol {
|
|
||||||
let mut key_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut key_bytes);
|
|
||||||
let key_b64 = B64.encode(key_bytes);
|
|
||||||
Protocol::BulkString(key_b64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_sym_encrypt(key_b64: &str, message: &str) -> Protocol {
|
|
||||||
let key = match decode_key_b64(key_b64) {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
match encrypt_blob(&key, message.as_bytes()) {
|
|
||||||
Ok(blob) => Protocol::BulkString(B64.encode(blob)),
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_sym_decrypt(key_b64: &str, ct_b64: &str) -> Protocol {
|
|
||||||
let key = match decode_key_b64(key_b64) {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => return e.to_protocol(),
|
|
||||||
};
|
|
||||||
let blob = match B64.decode(ct_b64.as_bytes()) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(_) => return SymWireError::BadEncoding.to_protocol(),
|
|
||||||
};
|
|
||||||
match decrypt_blob(&key, &blob) {
|
|
||||||
Ok(pt) => match String::from_utf8(pt) {
|
|
||||||
Ok(s) => Protocol::BulkString(s),
|
|
||||||
Err(_) => Protocol::err("ERR sym: invalid UTF-8 plaintext"),
|
|
||||||
},
|
|
||||||
Err(e) => e.to_protocol(),
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,85 +0,0 @@
|
|||||||
use herodb::rpc::{BackendType, DatabaseConfig};
|
|
||||||
use herodb::admin_meta;
|
|
||||||
use herodb::options::BackendType as OptionsBackendType;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_rpc_server_basic() {
|
|
||||||
// This test would require starting the RPC server in a separate thread
|
|
||||||
// For now, we'll just test that the types compile correctly
|
|
||||||
|
|
||||||
// Test serialization of types
|
|
||||||
let backend = BackendType::Redb;
|
|
||||||
let config = DatabaseConfig {
|
|
||||||
name: Some("test_db".to_string()),
|
|
||||||
storage_path: Some("/tmp/test".to_string()),
|
|
||||||
max_size: Some(1024 * 1024),
|
|
||||||
redis_version: Some("7.0".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let backend_json = serde_json::to_string(&backend).unwrap();
|
|
||||||
let config_json = serde_json::to_string(&config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(backend_json, "\"Redb\"");
|
|
||||||
assert!(config_json.contains("test_db"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_database_config_serialization() {
|
|
||||||
let config = DatabaseConfig {
|
|
||||||
name: Some("my_db".to_string()),
|
|
||||||
storage_path: None,
|
|
||||||
max_size: Some(1000000),
|
|
||||||
redis_version: Some("7.0".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_value(&config).unwrap();
|
|
||||||
assert_eq!(json["name"], "my_db");
|
|
||||||
assert_eq!(json["max_size"], 1000000);
|
|
||||||
assert_eq!(json["redis_version"], "7.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_backend_type_serialization() {
|
|
||||||
// Test that both Redb and Sled backends serialize correctly
|
|
||||||
let redb_backend = BackendType::Redb;
|
|
||||||
let sled_backend = BackendType::Sled;
|
|
||||||
|
|
||||||
let redb_json = serde_json::to_string(&redb_backend).unwrap();
|
|
||||||
let sled_json = serde_json::to_string(&sled_backend).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(redb_json, "\"Redb\"");
|
|
||||||
assert_eq!(sled_json, "\"Sled\"");
|
|
||||||
|
|
||||||
// Test deserialization
|
|
||||||
let redb_deserialized: BackendType = serde_json::from_str(&redb_json).unwrap();
|
|
||||||
let sled_deserialized: BackendType = serde_json::from_str(&sled_json).unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(redb_deserialized, BackendType::Redb));
|
|
||||||
assert!(matches!(sled_deserialized, BackendType::Sled));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_database_name_persistence() {
|
|
||||||
let base_dir = "/tmp/test_db_name_persistence";
|
|
||||||
let admin_secret = "test-admin-secret";
|
|
||||||
let backend = OptionsBackendType::Redb;
|
|
||||||
let db_id = 1;
|
|
||||||
let test_name = "test-database-name";
|
|
||||||
|
|
||||||
// Clean up any existing test data
|
|
||||||
let _ = std::fs::remove_dir_all(base_dir);
|
|
||||||
|
|
||||||
// Set the database name
|
|
||||||
admin_meta::set_database_name(base_dir, backend.clone(), admin_secret, db_id, test_name)
|
|
||||||
.expect("Failed to set database name");
|
|
||||||
|
|
||||||
// Retrieve the database name
|
|
||||||
let retrieved_name = admin_meta::get_database_name(base_dir, backend, admin_secret, db_id)
|
|
||||||
.expect("Failed to get database name");
|
|
||||||
|
|
||||||
// Verify the name matches
|
|
||||||
assert_eq!(retrieved_name, Some(test_name.to_string()));
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = std::fs::remove_dir_all(base_dir);
|
|
||||||
}
|
|
Reference in New Issue
Block a user