diff --git a/.github/workflows/release-rust-sdk.yml b/.github/workflows/release-rust-sdk.yml new file mode 100644 index 00000000..eefc74c3 --- /dev/null +++ b/.github/workflows/release-rust-sdk.yml @@ -0,0 +1,71 @@ +name: release-rust-sdk + +# Build/test gate for the Rust SDK on the release branches. Mirrors the shape of +# release-sdk.yml / release-python-sdk.yml, but crates.io publishing is +# intentionally DISABLED for now (WALM-48: publish is out of scope). + +on: + push: + branches: [main, staging, dev] + paths: + - "packages/rust-sdk/**" + - ".github/workflows/release-rust-sdk.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: packages/rust-sdk + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/rust-sdk/target + key: cargo-rust-sdk-${{ runner.os }}-${{ hashFiles('packages/rust-sdk/Cargo.lock') }} + restore-keys: cargo-rust-sdk-${{ runner.os }}- + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Build (release) + run: cargo build --release --verbose + + - name: Test + run: cargo test --verbose + + - name: Package (dry-run, no publish) + run: cargo package --allow-dirty --no-verify + + # ──────────────────────────────────────────────────────────────────── + # crates.io publishing is intentionally DISABLED (WALM-48). + # To enable later: + # 1. add a CARGO_REGISTRY_TOKEN repository secret, + # 2. gate on the stable branch (github.ref == 'refs/heads/main'), + # 3. bump the version in Cargo.toml, + # 4. remove `if: ${{ false }}`. + # ──────────────────────────────────────────────────────────────────── + - name: Publish to crates.io (disabled) + if: ${{ false }} + run: cargo publish --token "${{ secrets.CARGO_REGISTRY_TOKEN }}" diff --git a/.github/workflows/test-rust-sdk.yml b/.github/workflows/test-rust-sdk.yml new file mode 100644 index 00000000..a3624981 --- /dev/null +++ b/.github/workflows/test-rust-sdk.yml @@ -0,0 +1,57 @@ +name: test-rust-sdk + +on: + pull_request: + paths: + - "packages/rust-sdk/**" + - ".github/workflows/test-rust-sdk.yml" + push: + branches: [main, staging, dev] + paths: + - "packages/rust-sdk/**" + - ".github/workflows/test-rust-sdk.yml" + workflow_dispatch: + +concurrency: + group: test-rust-sdk-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + rust-sdk-checks: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: packages/rust-sdk + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/rust-sdk/target + key: cargo-rust-sdk-${{ runner.os }}-${{ hashFiles('packages/rust-sdk/Cargo.lock') }} + restore-keys: cargo-rust-sdk-${{ runner.os }}- + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Test + run: cargo test --verbose diff --git a/docs/docs.json b/docs/docs.json index 32cc46d5..5e7c1b45 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -120,6 +120,18 @@ } ] }, + { + "tab": "Rust SDK", + "groups": [ + { + "group": "Rust SDK", + "pages": [ + "rust-sdk/quick-start", + "rust-sdk/api-reference" + ] + } + ] + }, { "tab": "Relayer", "groups": [ diff --git a/docs/rust-sdk/api-reference.md b/docs/rust-sdk/api-reference.md new file mode 100644 index 00000000..9aac325f --- /dev/null +++ b/docs/rust-sdk/api-reference.md @@ -0,0 +1,100 @@ +--- +title: "API Reference" +description: "Builder options, async client methods, and error types for the Walrus Memory Rust SDK." +--- + +See also: + +- [Configuration](/reference/configuration) +- [Relayer API](/relayer/api-reference) + +All client methods are `async` and return `memwal::Result` (an alias for `Result`). + +## Constructing a client + +```rust +use memwal::{Env, WalrusMemory}; + +// Builder (recommended) +let client = WalrusMemory::builder(private_key, account_id) + .server_url("https://relayer.memory.walrus.xyz") // or .env(Env::Staging) + .namespace("demo") // optional, defaults to "default" + .build()?; + +// One-shot helper (prod defaults) +let client = WalrusMemory::create(private_key, account_id)?; +``` + +### `WalrusMemoryBuilder` + +| Method | Description | +|--------|-------------| +| `WalrusMemory::builder(key, account_id)` | Start a builder from a delegate private key + account ID | +| `.server_url(url)` | Explicit relayer URL (wins over `.env`) | +| `.env(Env)` | Environment preset — `Prod`, `Staging`, `Dev`, `Local` | +| `.namespace(ns)` | Default namespace for reads/writes | +| `.http_client(reqwest::Client)` | Supply a custom `reqwest` client (proxies, timeouts, pools) | +| `.build()` | Validate and construct `WalrusMemory` | + +## Client methods + +| Method | Description | +|--------|-------------| +| `remember(text, namespace?)` | Submit a memory; returns a `job_id` (async indexing) | +| `remember_and_wait(text, namespace?, opts)` | Submit and wait until indexed | +| `wait_for_remember_job(job_id, opts)` | Poll a job until `done` | +| `get_remember_status(job_id)` | One-shot job status | +| `recall(params)` | Semantic search — `RecallParams::new(query).limit(n).max_distance(d)` | +| `embed(text)` | Embed text to a vector (requires the relayer to expose `/api/embed`) | +| `analyze(text, opts)` | Extract facts and enqueue a remember job per fact | +| `analyze_and_wait(text, opts, wait_opts)` | Analyze and wait for all fact jobs | +| `ask(question, limit?, namespace?)` | Retrieval-augmented answer over memories | +| `restore(namespace, limit?)` | Rebuild the local index from Walrus | +| `recall_manual(opts)` | Search with a pre-computed vector (blob ids + distances) | +| `remember_manual(opts)` | Index a memory you've already embedded/encrypted/uploaded yourself | +| `remember_bulk(items)` / `remember_bulk_and_wait(items, opts)` | Batch up to 20 memories | +| `get_remember_bulk_status(job_ids)` / `wait_for_remember_jobs(...)` | Batch job status | +| `health()` / `version()` | Relayer health & version metadata (unauthenticated) | +| `compatibility()` | Verify the relayer's API version is supported (cached after first success) | +| `public_key_hex()` | The delegate public key (hex) | +| `server_url()` / `namespace()` | Inspect the effective configuration | +| `destroy(self)` | Zero the delegate key's seed material and drop the cached SEAL session | + +## Waiting on jobs + +`remember` indexes asynchronously and returns a `job_id`. Use the `_and_wait` +variants, or poll manually: + +```rust +use memwal::WaitOptions; + +let accepted = client.remember("note", None).await?; +let status = client + .wait_for_remember_job(&accepted.job_id, WaitOptions::default()) + .await?; +``` + +`WaitOptions` controls the poll interval and timeout (`WaitOptions::default()` is a +sensible starting point). + +## Errors + +Methods return `memwal::Error`, a `thiserror` enum: + +| Variant | Meaning | +|---------|---------| +| `InvalidKey` | The delegate key or account ID failed to parse | +| `InvalidUrl` | The configured server URL was malformed | +| `AuthRejected` | Relayer rejected the signature/SEAL session (HTTP 401) — usually an unregistered delegate key, clock skew, or a replayed nonce | +| `Incompatible` | Relayer requires a newer SDK (HTTP 426) | +| `Compatibility` | `compatibility()` couldn't reach `/version` or the relayer's API version isn't supported | +| `SealSession` | Building the SEAL session (ephemeral key, Sui signing, `/config` lookup) failed | +| `Server { status, .. }` | Relayer returned another non-success status | +| `JobFailed` / `JobNotFound` / `JobTimeout` | A remember/analyze job failed, was not found, or didn't finish before the wait timeout | +| `InvalidArgument` | A call was made with invalid arguments (empty query, empty vector, …) before any request was sent | +| `Transport` / `Json` | HTTP transport or (de)serialization failure | + +## Status / Notes + +- **`embed`** mirrors the TS/Python surface and calls `POST /api/embed`; some relayer deployments embed internally and do not expose this route (returns 404). +- **`remember_manual`**'s wire route (`POST /api/remember/manual`) mirrors `recall_manual`'s pattern but hasn't been confirmed against a live relayer yet — verify before relying on it in production. diff --git a/docs/rust-sdk/quick-start.md b/docs/rust-sdk/quick-start.md new file mode 100644 index 00000000..9e18af6b --- /dev/null +++ b/docs/rust-sdk/quick-start.md @@ -0,0 +1,108 @@ +--- +title: "Quick Start" +description: "Install the Walrus Memory Rust SDK and store your first memory in under a minute." +--- + +The Walrus Memory Rust SDK (`memwal` on crates.io) is a native client for Rust-based AI agents and server-side integrations. It mirrors the [TypeScript](/sdk/quick-start) and [Python](/python-sdk/quick-start) SDKs: same relayer, same Ed25519 + SEAL-session auth, same methods. + +The SDK talks to the Walrus Memory **relayer** over signed HTTPS. Embedding, SEAL encryption, Walrus upload/download, and vector search all happen server-side; the SDK signs each request with your Ed25519 delegate key and attaches a short-lived SEAL session for decrypt-needing calls. + +## Installation + +```toml +# Cargo.toml +[dependencies] +memwal = "0.1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +The client is async-first and runs on the [Tokio](https://tokio.rs) runtime. HTTP is handled by [`reqwest`](https://docs.rs/reqwest) (rustls TLS) and signing by [`ed25519-dalek`](https://docs.rs/ed25519-dalek). + +### Prerequisites + +- A delegate key and account ID, registered on-chain for your Walrus Memory account (generate/register one at [memory.walrus.xyz](https://memory.walrus.xyz)'s dashboard). +- A relayer URL matching where that key was registered — see Environment Presets below. A delegate key only works against the relayer it was registered on. + +## Quick Start + +```rust +use memwal::{WalrusMemory, RecallParams, WaitOptions}; + +#[tokio::main] +async fn main() -> memwal::Result<()> { + let client = WalrusMemory::builder( + std::env::var("WALRUS_MEMORY_PRIVATE_KEY").unwrap(), + std::env::var("WALRUS_MEMORY_ACCOUNT_ID").unwrap(), + ) + .server_url("https://relayer.memory.walrus.xyz") + .namespace("demo") + .build()?; + + client.health().await?; + + // Store a memory and wait for it to be indexed. + let stored = client + .remember_and_wait("User prefers dark mode and TypeScript.", None, WaitOptions::default()) + .await?; + println!("stored {}", stored.blob_id); + + // Recall it. + let hits = client + .recall(RecallParams::new("What are the user's preferences?").limit(5)) + .await?; + for m in hits.results { + println!("{:.3} {}", 1.0 - m.distance, m.text); + } + + // Ask a question over stored memories (RAG). + let answer = client.ask("Where does the user live?", Some(5), None).await?; + println!("{}", answer.answer); + Ok(()) +} +``` + +Run the bundled end-to-end example from the [package source](https://github.com/MystenLabs/MemWal/tree/dev/packages/rust-sdk): + +```bash +export WALRUS_MEMORY_PRIVATE_KEY=... WALRUS_MEMORY_ACCOUNT_ID=0x... +export WALRUS_MEMORY_ENV=dev # match wherever your key was registered +cargo run --example quick_start +``` + +## Environment Presets + +Instead of a full URL, select an environment with `.env(...)`. An explicit `.server_url(...)` always wins. + +| Env | Relayer URL | +|-----------------|-----------------------------------------------| +| `Env::Prod` | `https://relayer.memory.walrus.xyz` | +| `Env::Staging` | `https://relayer-staging.memory.walrus.xyz` | +| `Env::Dev` | `https://relayer.dev.memwal.ai` (legacy pre-rebrand domain) | +| `Env::Local` | `http://127.0.0.1:8000` | + +```rust +use memwal::{Env, WalrusMemory}; + +let client = WalrusMemory::builder(key, account_id) + .env(Env::Staging) + .build()?; +``` + +## Authentication + +Each request signs the canonical message + +```text +{timestamp}.{method}.{path}.{sha256(body)}.{nonce}.{account_id} +``` + +with the delegate Ed25519 key and sends these headers: +`x-public-key`, `x-signature`, `x-timestamp`, `x-nonce`, `x-account-id`. + +Decrypt-needing calls (`remember`, `recall`, `analyze`, `ask`, `restore`, bulk remember) additionally attach an `x-seal-session` header — a short-lived SEAL session built by signing an ephemeral session key as a Sui personal message with your delegate key. Your raw delegate private key is never sent over the wire. + +## Next Steps + +- [API Reference](/rust-sdk/api-reference) — full method list, builder options, and error types +- [Configuration](/reference/configuration) — environment variables and relayer settings +- [Relayer API](/relayer/api-reference) — the HTTP surface the SDK signs against diff --git a/packages/rust-sdk/.gitignore b/packages/rust-sdk/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/packages/rust-sdk/.gitignore @@ -0,0 +1 @@ +/target diff --git a/packages/rust-sdk/Cargo.lock b/packages/rust-sdk/Cargo.lock new file mode 100644 index 00000000..e8752da0 --- /dev/null +++ b/packages/rust-sdk/Cargo.lock @@ -0,0 +1,2013 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memwal" +version = "0.1.0" +dependencies = [ + "base64", + "blake2", + "chrono", + "ed25519-dalek", + "hex", + "percent-encoding", + "rand", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "uuid", + "wiremock", + "zeroize", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/rust-sdk/Cargo.toml b/packages/rust-sdk/Cargo.toml new file mode 100644 index 00000000..0dfcca61 --- /dev/null +++ b/packages/rust-sdk/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "memwal" +version = "0.1.0" +edition = "2021" +description = "Rust SDK for Walrus Memory — privacy-first AI memory with Ed25519-signed requests and SEAL session auth" +license = "Apache-2.0" +readme = "README.md" +homepage = "https://memory.walrus.xyz" +documentation = "https://docs.rs/memwal" +repository = "https://github.com/MystenLabs/MemWal" +keywords = ["walrus", "ai", "memory", "ed25519"] +categories = ["api-bindings", "asynchronous"] + +[dependencies] +# Async & HTTP +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +reqwest = { version = "0.12", features = ["json"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Crypto & Auth +ed25519-dalek = { version = "2", features = ["serde"] } +sha2 = "0.10" +hex = "0.4" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +base64 = "0.22" +blake2 = "0.10" +percent-encoding = "2" +rand = "0.8" +zeroize = "1" + +# Error Handling +thiserror = "1" + +[dev-dependencies] +wiremock = "0.6" + +[[example]] +name = "quick_start" diff --git a/packages/rust-sdk/README.md b/packages/rust-sdk/README.md new file mode 100644 index 00000000..2068abd4 --- /dev/null +++ b/packages/rust-sdk/README.md @@ -0,0 +1,150 @@ +# memwal — Walrus Memory Rust SDK + +Privacy-first AI memory for Rust agents and server-side integrations — [memory.walrus.xyz](https://memory.walrus.xyz). + +The SDK talks to the Walrus Memory **relayer** over signed HTTPS. Embedding, SEAL +encryption, Walrus upload/download, and vector search all happen server-side; the +SDK signs each request with your Ed25519 delegate key and attaches a short-lived +SEAL session for decrypt-needing calls. It mirrors the TypeScript and Python SDKs. + +## Installation + +```toml +# Cargo.toml +[dependencies] +memwal = "0.1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +### Prerequisites + +- A delegate key and account ID, registered on-chain for your Walrus Memory account + (generate/register one at [memory.walrus.xyz](https://memory.walrus.xyz)'s dashboard, + which calls `account::add_delegate_key`). +- A relayer URL matching where that key was registered — see Environment Presets below. + A delegate key only works against the relayer it was registered on. + +## Quick Start + +```rust +use memwal::{WalrusMemory, RecallParams, WaitOptions}; + +#[tokio::main] +async fn main() -> memwal::Result<()> { + let client = WalrusMemory::builder( + std::env::var("WALRUS_MEMORY_PRIVATE_KEY").unwrap(), + std::env::var("WALRUS_MEMORY_ACCOUNT_ID").unwrap(), + ) + .server_url("https://relayer.memory.walrus.xyz") + .namespace("demo") + .build()?; + + client.health().await?; + + // Store a memory and wait for it to be indexed. + let stored = client + .remember_and_wait("User prefers dark mode and TypeScript.", None, WaitOptions::default()) + .await?; + println!("stored {}", stored.blob_id); + + // Recall it. + let hits = client + .recall(RecallParams::new("What are the user's preferences?").limit(5)) + .await?; + for m in hits.results { + println!("{:.3} {}", 1.0 - m.distance, m.text); + } + + // Ask a question over stored memories (RAG). + let answer = client.ask("Where does the user live?", Some(5), None).await?; + println!("{}", answer.answer); + Ok(()) +} +``` + +Run the bundled end-to-end example: + +```bash +export WALRUS_MEMORY_PRIVATE_KEY=... WALRUS_MEMORY_ACCOUNT_ID=0x... +export WALRUS_MEMORY_ENV=dev # match wherever your key was registered +cargo run --example quick_start +``` + +## Environment Presets + +Instead of a full URL, select an environment with `.env(...)`. An explicit +`.server_url(...)` always wins. + +| Env | Relayer URL | +|-----------------|-----------------------------------------------| +| `Env::Prod` | `https://relayer.memory.walrus.xyz` | +| `Env::Staging` | `https://relayer-staging.memory.walrus.xyz` | +| `Env::Dev` | `https://relayer.dev.memwal.ai` (legacy pre-WALM-86 domain) | +| `Env::Local` | `http://127.0.0.1:8000` | + +```rust +use memwal::{Env, WalrusMemory}; +let client = WalrusMemory::builder(key, account_id).env(Env::Staging).build()?; +``` + +## API Reference + +`WalrusMemory::builder(key, account_id)` → `WalrusMemoryBuilder` → `.build() -> Result` +(`.server_url`, `.env`, `.namespace`, `.http_client` are optional). All client +methods are `async`. + +| Method | Description | +|--------|-------------| +| `remember(text, namespace?)` | Submit a memory; returns a `job_id` (async indexing) | +| `remember_and_wait(text, namespace?, opts)` | Submit and wait until indexed | +| `wait_for_remember_job(job_id, opts)` | Poll a job until `done` | +| `get_remember_status(job_id)` | One-shot job status | +| `recall(params)` | Semantic search (`RecallParams::new(query).limit(n).max_distance(d)`) | +| `embed(text)` | Embed text to a vector (requires the relayer to expose `/api/embed`) | +| `analyze(text, opts)` | Extract facts and enqueue a remember job per fact | +| `analyze_and_wait(text, opts, opts2)` | Analyze and wait for all fact jobs | +| `ask(question, limit?, namespace?)` | Retrieval-augmented answer over memories | +| `restore(namespace, limit?)` | Rebuild the local index from Walrus | +| `recall_manual(opts)` | Search with a pre-computed vector (blob ids + distances) | +| `remember_manual(opts)` | Index a memory you've already embedded/encrypted/uploaded yourself | +| `remember_bulk(items)` / `remember_bulk_and_wait(items, opts)` | Batch up to 20 memories | +| `get_remember_bulk_status(job_ids)` / `wait_for_remember_jobs(...)` | Batch job status | +| `health()` / `version()` | Relayer health & version metadata (unauthenticated) | +| `compatibility()` | Verify the relayer's API version is supported (cached after first success) | +| `public_key_hex()` | The delegate public key (hex) | +| `destroy(self)` | Zero the delegate key's seed material and drop the cached SEAL session | + +Errors are returned as [`memwal::Error`] (`AuthRejected`, `Server { status, .. }`, +`JobFailed`, `JobTimeout`, `InvalidKey`, `Compatibility`, `SealSession`, …). + +## Authentication + +Each request signs the canonical message + +```text +{timestamp}.{method}.{path}.{sha256(body)}.{nonce}.{account_id} +``` + +with the delegate Ed25519 key and sends: +`x-public-key`, `x-signature`, `x-timestamp`, `x-nonce`, `x-account-id`. + +Decrypt-needing calls (`remember`, `recall`, `analyze`, `ask`, `restore`, bulk +remember) additionally attach an `x-seal-session` header: a short-lived +SEAL session built by signing an ephemeral session key as a Sui personal +message with your delegate key, cached for ~10 minutes. The raw delegate +private key is never sent over the wire — this matches the TS/Python SDKs +and the relayer's current auth contract (the legacy `x-delegate-key` header +has been removed server-side). + +## Status / Notes + +- **`embed`** mirrors the TS/Python surface and calls `POST /api/embed`; some + relayer deployments embed internally and do not expose this route (returns 404). +- **`remember_manual`**'s wire route (`POST /api/remember/manual`) mirrors + `recall_manual`'s pattern but hasn't been confirmed against a live relayer — + verify the exact field names/route before relying on it in production. +- Publishing to crates.io is not enabled yet. + +## License + +Apache-2.0 diff --git a/packages/rust-sdk/examples/quick_start.rs b/packages/rust-sdk/examples/quick_start.rs new file mode 100644 index 00000000..87e3eaf4 --- /dev/null +++ b/packages/rust-sdk/examples/quick_start.rs @@ -0,0 +1,93 @@ +//! End-to-end smoke test against a live relayer. +//! +//! ```bash +//! export WALRUS_MEMORY_PRIVATE_KEY=<64-hex ed25519 delegate key> +//! export WALRUS_MEMORY_ACCOUNT_ID=0x<...> +//! export WALRUS_MEMORY_ENV=prod # prod | staging | dev | local (optional, default: prod) +//! export WALRUS_MEMORY_SERVER_URL=... # optional explicit override, wins over WALRUS_MEMORY_ENV +//! export WALRUS_MEMORY_NAMESPACE=demo # optional +//! cargo run --example quick_start +//! ``` +//! +//! Env presets: `prod` → relayer.memory.walrus.xyz, `staging` → +//! relayer-staging.memory.walrus.xyz, `dev` → relayer.dev.memwal.ai (legacy +//! pre-WALM-86 domain), `local` → http://127.0.0.1:8000. A delegate key only +//! works against the relayer it was registered on — pick the matching env +//! rather than relying on the default. + +use std::env; + +use memwal::{Env, RecallParams, WaitOptions, WalrusMemory}; + +fn parse_env(raw: &str) -> Option { + match raw.to_ascii_lowercase().as_str() { + "prod" => Some(Env::Prod), + "staging" => Some(Env::Staging), + "dev" => Some(Env::Dev), + "local" => Some(Env::Local), + _ => None, + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let key = env::var("WALRUS_MEMORY_PRIVATE_KEY").expect("set WALRUS_MEMORY_PRIVATE_KEY"); + let account_id = env::var("WALRUS_MEMORY_ACCOUNT_ID").expect("set WALRUS_MEMORY_ACCOUNT_ID"); + let namespace = env::var("WALRUS_MEMORY_NAMESPACE").unwrap_or_else(|_| "demo".to_string()); + + let mut builder = WalrusMemory::builder(key, account_id).namespace(namespace); + builder = match env::var("WALRUS_MEMORY_SERVER_URL") { + Ok(url) => builder.server_url(url), + Err(_) => { + let env_name = env::var("WALRUS_MEMORY_ENV").unwrap_or_else(|_| "prod".to_string()); + let preset = parse_env(&env_name).unwrap_or_else(|| { + panic!("unknown WALRUS_MEMORY_ENV {env_name:?} (expected prod|staging|dev|local)") + }); + println!( + "no WALRUS_MEMORY_SERVER_URL set — using WALRUS_MEMORY_ENV={env_name} ({})", + preset.server_url() + ); + builder.env(preset) + } + }; + let client = builder.build()?; + + println!("delegate public key: {}", client.public_key_hex()); + println!("relayer: {}", client.server_url()); + + let health = client.health().await?; + println!("relayer health: {}", health.status); + + println!("\ncompatibility check …"); + match client.compatibility().await { + Ok(()) => println!(" OK"), + Err(e) => println!(" {e}"), + } + + println!("\nremember + wait …"); + let stored = client + .remember_and_wait( + "I live in Hanoi and prefer dark mode and TypeScript.", + None, + WaitOptions::default(), + ) + .await?; + println!(" stored blob {} (owner {})", stored.blob_id, stored.owner); + + println!("\nrecall …"); + let hits = client + .recall(RecallParams::new("What are the user's preferences?").limit(5)) + .await?; + println!(" {} result(s):", hits.total); + for m in &hits.results { + println!(" relevance {:.3} {}", 1.0 - m.distance, m.text); + } + + println!("\nask …"); + let answer = client + .ask("Where does the user live?", Some(5), None) + .await?; + println!(" {}", answer.answer); + + Ok(()) +} diff --git a/packages/rust-sdk/src/client.rs b/packages/rust-sdk/src/client.rs new file mode 100644 index 00000000..c7e088ca --- /dev/null +++ b/packages/rust-sdk/src/client.rs @@ -0,0 +1,912 @@ +//! The async [`WalrusMemory`] client and its builder. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use reqwest::{Client, Method}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::error::{Error, Result}; +use crate::signing::{ + blake2b_256, canonical_message, encode_suiprivkey, encode_uleb128, sha256_hex, Signer, +}; +use crate::types::*; + +const DEFAULT_SERVER_URL: &str = "https://relayer.memory.walrus.xyz"; +const DEFAULT_NAMESPACE: &str = "default"; +const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 60; +/// SEAL session lifetime; cached sessions are refreshed 30s before expiry. +const SEAL_SESSION_TTL_MIN: i64 = 10; + +/// Builder for [`WalrusMemory`]. Obtain one via [`WalrusMemory::builder`]. +#[derive(Debug, Clone)] +pub struct WalrusMemoryBuilder { + key: String, + account_id: String, + server_url: Option, + namespace: Option, + env: Option, + http: Option, +} + +impl WalrusMemoryBuilder { + /// Override the relayer base URL (takes precedence over [`Self::env`]). + pub fn server_url(mut self, url: impl Into) -> Self { + self.server_url = Some(url.into()); + self + } + + /// Select a relayer environment preset (used only when `server_url` is unset). + pub fn env(mut self, env: Env) -> Self { + self.env = Some(env); + self + } + + /// Set the default namespace (defaults to `"default"`). + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = Some(ns.into()); + self + } + + /// Provide a pre-configured `reqwest::Client` (e.g. with a proxy or custom + /// timeout). If omitted, a client with a 60s timeout is created. + pub fn http_client(mut self, client: Client) -> Self { + self.http = Some(client); + self + } + + /// Validate the key and construct the client. + pub fn build(self) -> Result { + let signer = Signer::from_hex_seed(&self.key)?; + + let server_url = self + .server_url + .or_else(|| self.env.map(|e| e.server_url().to_string())) + .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string()); + let server_url = normalize_server_url(&server_url)?; + + let namespace = self + .namespace + .unwrap_or_else(|| DEFAULT_NAMESPACE.to_string()); + + let http = match self.http { + Some(c) => c, + None => Client::builder() + .timeout(Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS)) + .build()?, + }; + + Ok(WalrusMemory { + http, + signer, + account_id: self.account_id, + server_url, + namespace, + session_cache: Arc::new(Mutex::new(None)), + compatibility_checked: Arc::new(Mutex::new(false)), + }) + } +} + +/// Strip a single trailing slash and reject obviously malformed URLs. +fn normalize_server_url(url: &str) -> Result { + let trimmed = url.trim().trim_end_matches('/'); + if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { + return Err(Error::InvalidUrl(format!( + "server url must start with http:// or https:// (got {url:?})" + ))); + } + Ok(trimmed.to_string()) +} + +/// True when `server_url` is a known mainnet relayer domain (current +/// `memory.walrus.xyz` or the legacy pre-WALM-86 `memwal.ai`), in which case +/// [`WalrusMemory::build_seal_session`] can skip the `/config` round trip and +/// [`WalrusMemory::compatibility`] never needs to re-check. +fn is_known_mainnet_relayer(server_url: &str) -> bool { + server_url.contains("relayer.memory.walrus.xyz") || server_url.contains("relayer.memwal.ai") +} + +#[derive(Debug, Clone)] +struct CachedSession { + session_header_value: String, + expires_at: chrono::DateTime, +} + +/// Async client for the Walrus Memory relayer. +/// +/// Cloning is cheap: the `reqwest::Client`, delegate key, and cached SEAL +/// session are all reference-counted, so every clone shares the same session +/// cache and can be handed to independent tasks. +#[derive(Debug, Clone)] +pub struct WalrusMemory { + http: Client, + signer: Signer, + account_id: String, + server_url: String, + namespace: String, + session_cache: Arc>>, + compatibility_checked: Arc>, +} + +impl WalrusMemory { + /// Start building a client from a hex delegate key and account id. + pub fn builder(key: impl Into, account_id: impl Into) -> WalrusMemoryBuilder { + WalrusMemoryBuilder { + key: key.into(), + account_id: account_id.into(), + server_url: None, + namespace: None, + env: None, + http: None, + } + } + + /// Convenience constructor using all defaults + /// (`server_url = https://relayer.memory.walrus.xyz`, `namespace = "default"`). + pub fn create(key: impl Into, account_id: impl Into) -> Result { + Self::builder(key, account_id).build() + } + + /// The configured relayer base URL (trailing slash stripped). + pub fn server_url(&self) -> &str { + &self.server_url + } + + /// The default namespace. + pub fn namespace(&self) -> &str { + &self.namespace + } + + /// Hex-encoded delegate public key. + pub fn public_key_hex(&self) -> &str { + self.signer.public_key_hex() + } + + /// Zero the delegate key's seed material and drop the cached SEAL + /// session. The client must not be used after calling this — it exists + /// for callers holding key material in long-lived processes who want to + /// scrub it from memory as soon as it's no longer needed (heap-dump + /// hardening), mirroring the TypeScript SDK's `destroy()`. + pub fn destroy(mut self) { + self.signer.zeroize(); + *self.session_cache.lock().unwrap() = None; + } + + fn ns<'a>(&'a self, ns: Option<&'a str>) -> &'a str { + ns.unwrap_or(&self.namespace) + } + + // ── core signed-request plumbing ─────────────────────────────────────── + + /// Send a signed request and return `(status, body_bytes)`. Only transport + /// failures produce an `Err`; HTTP status interpretation is the caller's job. + async fn send_raw( + &self, + method: Method, + path: &str, + body: Option<&Value>, + include_seal_session: bool, + ) -> Result<(u16, Vec)> { + let is_get = method == Method::GET; + let body_str = if is_get { + String::new() + } else { + serde_json::to_string(&body.cloned().unwrap_or_else(|| json!({})))? + }; + let body_sha = sha256_hex(body_str.as_bytes()); + let timestamp = chrono::Utc::now().timestamp().to_string(); + let nonce = Uuid::new_v4().to_string(); + let message = canonical_message( + ×tamp, + method.as_str(), + path, + &body_sha, + &nonce, + &self.account_id, + ); + let signature = self.signer.sign_hex(message.as_bytes()); + + let url = format!("{}{}", self.server_url, path); + let mut req = self + .http + .request(method, &url) + .header("x-public-key", self.signer.public_key_hex()) + .header("x-signature", signature) + .header("x-timestamp", timestamp) + .header("x-nonce", nonce) + .header("x-account-id", &self.account_id); + + if !is_get { + req = req + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body_str); + } + if include_seal_session { + let session = self.build_seal_session().await?; + req = req.header("x-seal-session", session); + } + + let resp = req.send().await?; + let status = resp.status().as_u16(); + let bytes = resp.bytes().await?.to_vec(); + Ok((status, bytes)) + } + + /// Signed request that parses the body into `T` when the status is accepted. + async fn request( + &self, + method: Method, + path: &str, + body: Option, + accepted: &[u16], + include_seal_session: bool, + ) -> Result { + let (status, bytes) = self + .send_raw(method, path, body.as_ref(), include_seal_session) + .await?; + decode(status, &bytes, accepted) + } + + // ── remember ─────────────────────────────────────────────────────────── + + /// Submit one memory. Returns immediately with a `job_id`; embedding, + /// encryption, Walrus upload, and indexing continue in the background. + pub async fn remember(&self, text: &str, namespace: Option<&str>) -> Result { + let body = json!({ "text": text, "namespace": self.ns(namespace) }); + self.request(Method::POST, "/api/remember", Some(body), &[200, 202], true) + .await + } + + /// Fetch the current status of a remember job. + pub async fn get_remember_status(&self, job_id: &str) -> Result { + let encoded = + percent_encoding::utf8_percent_encode(job_id, percent_encoding::NON_ALPHANUMERIC); + let path = format!("/api/remember/{encoded}"); + let (status, bytes) = self.send_raw(Method::GET, &path, None, true).await?; + match status { + 200 => decode(status, &bytes, &[200]), + 404 => Ok(RememberJobStatus { + job_id: job_id.to_string(), + status: "not_found".to_string(), + owner: None, + namespace: None, + blob_id: None, + error: None, + }), + _ => Err(error_from_body(status, &bytes)), + } + } + + /// Poll a remember job until it reaches `done` (or fails / times out). + pub async fn wait_for_remember_job( + &self, + job_id: &str, + opts: WaitOptions, + ) -> Result { + let start = Instant::now(); + let mut attempt: u32 = 0; + loop { + let st = self.get_remember_status(job_id).await?; + match st.status.as_str() { + "done" => { + return Ok(RememberResult { + id: st.job_id.clone(), + job_id: Some(st.job_id), + blob_id: st.blob_id.unwrap_or_default(), + owner: st.owner.unwrap_or_default(), + namespace: st.namespace.unwrap_or_else(|| self.namespace.clone()), + }); + } + "failed" => { + return Err(Error::JobFailed { + job_id: job_id.to_string(), + message: st.error.unwrap_or_else(|| "job failed".to_string()), + }); + } + "not_found" => { + return Err(Error::JobNotFound { + job_id: job_id.to_string(), + }); + } + _ => {} // pending | running | uploaded → keep polling + } + if start.elapsed() >= Duration::from_millis(opts.timeout_ms) { + return Err(Error::JobTimeout { + job_id: job_id.to_string(), + timeout_ms: opts.timeout_ms, + }); + } + tokio::time::sleep(backoff(opts.poll_interval_ms, attempt)).await; + attempt = attempt.saturating_add(1); + } + } + + /// Submit a memory and wait for it to finish. + pub async fn remember_and_wait( + &self, + text: &str, + namespace: Option<&str>, + opts: WaitOptions, + ) -> Result { + let accepted = self.remember(text, namespace).await?; + self.wait_for_remember_job(&accepted.job_id, opts).await + } + + // ── recall / embed / analyze / ask / restore ─────────────────────────── + + /// Semantic search over `owner + namespace`. + pub async fn recall(&self, params: impl Into) -> Result { + let params = params.into(); + if params.query.trim().is_empty() { + return Err(Error::InvalidArgument("recall query is empty".into())); + } + let mut body = json!({ + "query": params.query, + "limit": params.limit.unwrap_or(10), + "namespace": params.namespace.as_deref().unwrap_or(&self.namespace), + }); + if let Some(w) = ¶ms.scoring_weights { + body["scoring_weights"] = serde_json::to_value(w)?; + } + let mut result: RecallResult = self + .request(Method::POST, "/api/recall", Some(body), &[200], true) + .await?; + // Client-side max_distance filter (mirrors the TS/Python SDKs). + if let Some(md) = params.max_distance { + result.results.retain(|m| m.distance < md); + result.total = result.results.len() as u64; + } + Ok(result) + } + + /// Generate an embedding vector for `text` without storing anything. + /// + /// Note: this calls `POST /api/embed`, which mirrors the TS/Python SDKs. + /// Some relayer deployments embed internally and do not expose this route; + /// against those it returns a 404 [`Error::Server`]. + pub async fn embed(&self, text: &str) -> Result { + let body = json!({ "text": text }); + // No decryption needed — nothing is stored — so no SEAL session. + self.request(Method::POST, "/api/embed", Some(body), &[200], false) + .await + } + + /// Extract memorable facts from `text` and enqueue a remember job per fact. + pub async fn analyze(&self, text: &str, opts: AnalyzeOptions) -> Result { + let mut body = json!({ + "text": text, + "namespace": opts.namespace.as_deref().unwrap_or(&self.namespace), + }); + if let Some(ts) = opts.occurred_at { + body["occurred_at"] = json!(ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); + } + self.request(Method::POST, "/api/analyze", Some(body), &[200, 202], true) + .await + } + + /// Analyze, then wait for every extracted-fact remember job to finish. + pub async fn analyze_and_wait( + &self, + text: &str, + opts: AnalyzeOptions, + wait: WaitOptions, + ) -> Result { + let ns = opts + .namespace + .clone() + .unwrap_or_else(|| self.namespace.clone()); + let analyzed = self.analyze(text, opts).await?; + let namespaces = vec![ns; analyzed.job_ids.len()]; + let bulk = self + .wait_for_remember_jobs(&analyzed.job_ids, &namespaces, wait) + .await?; + Ok(AnalyzeWaitResult { + facts: analyzed.facts, + owner: analyzed.owner, + results: bulk.results, + total: bulk.total, + succeeded: bulk.succeeded, + failed: bulk.failed, + }) + } + + /// Retrieval-augmented question answering over stored memories. + pub async fn ask( + &self, + question: &str, + limit: Option, + namespace: Option<&str>, + ) -> Result { + if question.trim().is_empty() { + return Err(Error::InvalidArgument("ask question is empty".into())); + } + let body = json!({ + "question": question, + "limit": limit.unwrap_or(5), + "namespace": self.ns(namespace), + }); + self.request(Method::POST, "/api/ask", Some(body), &[200], true) + .await + } + + /// Rebuild the local index for a namespace from on-chain Walrus blobs. + pub async fn restore(&self, namespace: &str, limit: Option) -> Result { + if namespace.trim().is_empty() { + return Err(Error::InvalidArgument("restore namespace is empty".into())); + } + let body = json!({ "namespace": namespace, "limit": limit.unwrap_or(10) }); + self.request(Method::POST, "/api/restore", Some(body), &[200], true) + .await + } + + // ── manual ────────────────────────────────────────────────────────────── + + /// Search with a pre-computed query vector. Returns blob ids + distances + /// only (no server-side decryption, so no SEAL session is requested). + pub async fn recall_manual(&self, opts: RecallManualOptions) -> Result { + if opts.vector.is_empty() { + return Err(Error::InvalidArgument( + "recall_manual vector is empty".into(), + )); + } + let mut body = json!({ + "vector": opts.vector, + "limit": opts.limit.unwrap_or(10), + "namespace": opts.namespace.as_deref().unwrap_or(&self.namespace), + }); + if let Some(w) = &opts.scoring_weights { + body["scoring_weights"] = serde_json::to_value(w)?; + } + self.request( + Method::POST, + "/api/recall/manual", + Some(body), + &[200], + false, + ) + .await + } + + /// Index a memory the caller has already embedded, SEAL-encrypted, and + /// uploaded to Walrus themselves. No server-side decryption happens, so + /// no SEAL session is requested. + pub async fn remember_manual( + &self, + opts: RememberManualOptions, + ) -> Result { + if opts.blob_id.trim().is_empty() { + return Err(Error::InvalidArgument( + "remember_manual blob_id is empty".into(), + )); + } + if opts.vector.is_empty() { + return Err(Error::InvalidArgument( + "remember_manual vector is empty".into(), + )); + } + let body = json!({ + "blob_id": opts.blob_id, + "vector": opts.vector, + "namespace": opts.namespace.as_deref().unwrap_or(&self.namespace), + }); + self.request( + Method::POST, + "/api/remember/manual", + Some(body), + &[200, 201], + false, + ) + .await + } + + // ── bulk ────────────────────────────────────────────────────────────── + + /// Submit up to 20 memories in one request. + pub async fn remember_bulk(&self, items: &[RememberBulkItem]) -> Result { + if items.is_empty() { + return Err(Error::InvalidArgument( + "remember_bulk items is empty".into(), + )); + } + let wire: Vec = items + .iter() + .map(|i| json!({ "text": i.text, "namespace": i.namespace.as_deref().unwrap_or(&self.namespace) })) + .collect(); + let body = json!({ "items": wire }); + self.request( + Method::POST, + "/api/remember/bulk", + Some(body), + &[200, 202], + true, + ) + .await + } + + /// Fetch the status of a batch of remember jobs. + pub async fn get_remember_bulk_status( + &self, + job_ids: &[String], + ) -> Result { + if job_ids.is_empty() { + return Err(Error::InvalidArgument("job_ids is empty".into())); + } + let body = json!({ "job_ids": job_ids }); + self.request( + Method::POST, + "/api/remember/bulk/status", + Some(body), + &[200], + true, + ) + .await + } + + /// Poll a batch of remember jobs until all reach a terminal state. + /// `namespaces` is index-aligned with `job_ids` (falls back to the client + /// namespace when shorter/empty). Output order matches `job_ids`. + pub async fn wait_for_remember_jobs( + &self, + job_ids: &[String], + namespaces: &[String], + opts: WaitOptions, + ) -> Result { + if job_ids.is_empty() { + return Ok(RememberBulkResult { + results: vec![], + total: 0, + succeeded: 0, + failed: 0, + }); + } + let ns_for = |i: usize| -> String { + namespaces + .get(i) + .cloned() + .unwrap_or_else(|| self.namespace.clone()) + }; + + let start = Instant::now(); + let mut attempt: u32 = 0; + loop { + let status = self.get_remember_bulk_status(job_ids).await?; + let by_id: std::collections::HashMap<&str, &RememberBulkStatusItem> = status + .results + .iter() + .map(|r| (r.job_id.as_str(), r)) + .collect(); + let all_terminal = job_ids.iter().all(|id| { + matches!( + by_id.get(id.as_str()).map(|r| r.status.as_str()), + Some("done") | Some("failed") | Some("not_found") + ) + }); + let timed_out = start.elapsed() >= Duration::from_millis(opts.timeout_ms); + + if all_terminal || timed_out { + let mut results = Vec::with_capacity(job_ids.len()); + let (mut succeeded, mut failed) = (0u64, 0u64); + for (i, id) in job_ids.iter().enumerate() { + let item = by_id.get(id.as_str()); + let (status, blob_id, error) = match item.map(|r| r.status.as_str()) { + Some("done") => { + succeeded += 1; + ("done", item.and_then(|r| r.blob_id.clone()), None) + } + Some("failed") | Some("not_found") => { + failed += 1; + ("failed", None, item.and_then(|r| r.error.clone())) + } + _ => { + failed += 1; + ("timeout", None, None) + } + }; + results.push(RememberBulkItemResult { + id: id.clone(), + blob_id, + status: status.to_string(), + namespace: ns_for(i), + error, + }); + } + return Ok(RememberBulkResult { + total: results.len() as u64, + succeeded, + failed, + results, + }); + } + + tokio::time::sleep(backoff(opts.poll_interval_ms, attempt)).await; + attempt = attempt.saturating_add(1); + } + } + + /// Submit a bulk batch and wait for all jobs to finish. + pub async fn remember_bulk_and_wait( + &self, + items: &[RememberBulkItem], + opts: WaitOptions, + ) -> Result { + let accepted = self.remember_bulk(items).await?; + let namespaces: Vec = items + .iter() + .map(|i| { + i.namespace + .clone() + .unwrap_or_else(|| self.namespace.clone()) + }) + .collect(); + self.wait_for_remember_jobs(&accepted.job_ids, &namespaces, opts) + .await + } + + // ── unauthenticated ──────────────────────────────────────────────────── + + /// Relayer health (no authentication). + pub async fn health(&self) -> Result { + let url = format!("{}/health", self.server_url); + let (status, bytes) = self.unsigned_get(&url).await?; + decode(status, &bytes, &[200]) + } + + /// Relayer version + compatibility metadata (no authentication). + pub async fn version(&self) -> Result { + let url = format!("{}/version", self.server_url); + let (status, bytes) = self.unsigned_get(&url).await?; + decode(status, &bytes, &[200]) + } + + async fn unsigned_get(&self, url: &str) -> Result<(u16, Vec)> { + let resp = self.http.get(url).send().await?; + let status = resp.status().as_u16(); + let bytes = resp.bytes().await?.to_vec(); + Ok((status, bytes)) + } + + /// Check that the relayer's advertised API version is one this SDK + /// supports. The check result is cached for the client's lifetime (the + /// API version can't change mid-process), and skipped entirely against a + /// known mainnet relayer domain. + pub async fn compatibility(&self) -> Result<()> { + if is_known_mainnet_relayer(&self.server_url) { + return Ok(()); + } + if *self.compatibility_checked.lock().unwrap() { + return Ok(()); + } + let info = self + .version() + .await + .map_err(|e| Error::Compatibility(format!("failed to query /version: {e}")))?; + if !info.api_version.starts_with("1.") { + return Err(Error::Compatibility(format!( + "incompatible relayer API version: {}", + info.api_version + ))); + } + *self.compatibility_checked.lock().unwrap() = true; + Ok(()) + } + + // ── SEAL session ──────────────────────────────────────────────────────── + + /// Build (or return the cached) SEAL session header value: a base64 JSON + /// envelope containing an ephemeral Ed25519 session key, signed by the + /// delegate key as a Sui "personal message". The relayer uses this to + /// derive SEAL decrypt keys without ever seeing the delegate private key + /// itself. Cached for `SEAL_SESSION_TTL_MIN` minutes minus a 30s margin. + async fn build_seal_session(&self) -> Result { + use base64::Engine as _; + + { + let cache = self.session_cache.lock().unwrap(); + if let Some(session) = cache.as_ref() { + if session.expires_at > chrono::Utc::now() { + return Ok(session.session_header_value.clone()); + } + } + } + + let package_id = if is_known_mainnet_relayer(&self.server_url) { + "0x2::memwal".to_string() + } else { + #[derive(serde::Deserialize)] + struct ConfigResp { + #[serde(rename = "packageId")] + package_id: String, + } + let url = format!("{}/config", self.server_url); + let resp = self.http.get(&url).send().await?; + if !resp.status().is_success() { + return Err(Error::SealSession(format!( + "failed to fetch relayer /config: {}", + resp.status() + ))); + } + resp.json::().await?.package_id + }; + + // Ephemeral Ed25519 session keypair. + let ephemeral_signing_key = { + use rand::RngCore; + let mut secret_bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret_bytes); + ed25519_dalek::SigningKey::from_bytes(&secret_bytes) + }; + let session_public_key_b64 = base64::prelude::BASE64_STANDARD + .encode(ephemeral_signing_key.verifying_key().to_bytes()); + + let now = chrono::Utc::now(); + let creation_time_utc = now.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + let message = format!( + "Accessing keys of package {package_id} for {SEAL_SESSION_TTL_MIN} mins from {creation_time_utc}, session key {session_public_key_b64}" + ); + + // Sui `PersonalMessage` intent-signing: 3-byte intent prefix + uleb128 + // length + message bytes, blake2b-256 hashed then Ed25519-signed. + let message_bytes = message.as_bytes(); + let uleb128_len = encode_uleb128(message_bytes.len()); + let mut intent_message = Vec::with_capacity(3 + uleb128_len.len() + message_bytes.len()); + intent_message.extend_from_slice(&[0x03, 0x00, 0x00]); + intent_message.extend_from_slice(&uleb128_len); + intent_message.extend_from_slice(message_bytes); + let digest = blake2b_256(&intent_message); + + let signature = self.signer.sign(&digest); + let mut serialized_sig = Vec::with_capacity(1 + 64 + 32); + serialized_sig.push(0x00); // Ed25519 signature-scheme flag + serialized_sig.extend_from_slice(&signature.to_bytes()); + serialized_sig.extend_from_slice(&self.signer.verifying_key_bytes()); + let personal_message_signature = base64::prelude::BASE64_STANDARD.encode(serialized_sig); + + // Delegate's Sui address, derived from the main verifying key. + let mut address_input = Vec::with_capacity(33); + address_input.push(0x00); + address_input.extend_from_slice(&self.signer.verifying_key_bytes()); + let address = format!("0x{}", hex::encode(blake2b_256(&address_input))); + + let session_key = encode_suiprivkey(&ephemeral_signing_key.to_bytes()); + let creation_time_ms = now.timestamp_millis(); + let payload = serde_json::json!({ + "address": address, + "packageId": package_id, + "mvrName": null, + "creationTimeMs": creation_time_ms, + "ttlMin": SEAL_SESSION_TTL_MIN, + "personalMessageSignature": personal_message_signature, + "sessionKey": session_key, + }); + let session_header_value = + base64::prelude::BASE64_STANDARD.encode(serde_json::to_string(&payload)?); + + let expires_at = now + chrono::Duration::seconds(SEAL_SESSION_TTL_MIN * 60 - 30); + *self.session_cache.lock().unwrap() = Some(CachedSession { + session_header_value: session_header_value.clone(), + expires_at, + }); + + Ok(session_header_value) + } +} + +/// Jitterless exponential backoff: `min(10s, base * 1.5^min(attempt, 6))`. +fn backoff(poll_interval_ms: u64, attempt: u32) -> Duration { + let base = poll_interval_ms.max(100) as f64; + let factor = 1.5_f64.powi(attempt.min(6) as i32); + let delay = (base * factor).min(10_000.0); + Duration::from_millis(delay as u64) +} + +/// Decode an accepted response or map a non-accepted status to an [`Error`]. +fn decode(status: u16, bytes: &[u8], accepted: &[u16]) -> Result { + if accepted.contains(&status) { + Ok(serde_json::from_slice(bytes)?) + } else { + Err(error_from_body(status, bytes)) + } +} + +/// Build a typed [`Error`] from a non-success response body (`{"error": ...}`). +fn error_from_body(status: u16, bytes: &[u8]) -> Error { + let parsed: Option = serde_json::from_slice(bytes).ok(); + let message = parsed + .as_ref() + .and_then(|v| { + v.get("error") + .or_else(|| v.get("message")) + .and_then(|m| m.as_str()) + }) + .map(truncate) + .unwrap_or_else(|| truncate(&String::from_utf8_lossy(bytes))); + let code = parsed + .as_ref() + .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from)); + + match status { + 401 => Error::AuthRejected { + message: if message.is_empty() { + "delegate key not authorized for this account, clock skew (>5min), or replayed nonce".into() + } else { + message + }, + }, + 426 => Error::Incompatible { message }, + _ => Error::Server { + status, + code, + message, + }, + } +} + +fn truncate(s: &str) -> String { + let cleaned: String = s.chars().filter(|c| !c.is_control()).collect(); + if cleaned.chars().count() > 300 { + let head: String = cleaned.chars().take(300).collect(); + format!("{head}…") + } else { + cleaned + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + fn test_client() -> WalrusMemory { + WalrusMemory::builder(SEED, "0xacct") + .server_url("https://relayer.memory.walrus.xyz") + .namespace("demo") + .build() + .unwrap() + } + + #[test] + fn is_known_mainnet_relayer_matches_current_and_legacy_domain() { + assert!(is_known_mainnet_relayer( + "https://relayer.memory.walrus.xyz" + )); + assert!(is_known_mainnet_relayer("https://relayer.memwal.ai")); + assert!(!is_known_mainnet_relayer("https://relayer.dev.memwal.ai")); + assert!(!is_known_mainnet_relayer("http://127.0.0.1:8000")); + } + + #[tokio::test] + async fn build_seal_session_produces_decodable_envelope() { + use base64::Engine as _; + + let client = test_client(); + let session_b64 = client.build_seal_session().await.unwrap(); + let decoded = base64::prelude::BASE64_STANDARD + .decode(&session_b64) + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + + assert!(payload["address"].is_string()); + assert_eq!(payload["packageId"], "0x2::memwal"); + assert_eq!(payload["ttlMin"], SEAL_SESSION_TTL_MIN); + assert!(payload["personalMessageSignature"].is_string()); + assert!(payload["sessionKey"] + .as_str() + .unwrap() + .starts_with("suiprivkey1")); + } + + #[tokio::test] + async fn build_seal_session_is_cached() { + let client = test_client(); + let a = client.build_seal_session().await.unwrap(); + let b = client.build_seal_session().await.unwrap(); + assert_eq!( + a, b, + "second call within TTL should reuse the cached session" + ); + } +} diff --git a/packages/rust-sdk/src/error.rs b/packages/rust-sdk/src/error.rs new file mode 100644 index 00000000..8204a126 --- /dev/null +++ b/packages/rust-sdk/src/error.rs @@ -0,0 +1,74 @@ +//! Error types for the Walrus Memory SDK. + +use thiserror::Error; + +/// Result alias used throughout the crate. +pub type Result = std::result::Result; + +/// Errors returned by the [`crate::WalrusMemory`] client. +#[derive(Debug, Error)] +pub enum Error { + /// The supplied delegate key was not a valid 32-byte Ed25519 seed. + #[error("invalid delegate key: {0}")] + InvalidKey(String), + + /// The configured server URL could not be used to build a request. + #[error("invalid server url: {0}")] + InvalidUrl(String), + + /// A transport-level failure (DNS, TLS, connection, timeout). + #[error("http transport error: {0}")] + Transport(#[from] reqwest::Error), + + /// Failed to (de)serialize a request or response body. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + /// The relayer rejected the signed request (HTTP 401). + /// + /// Usually means the delegate key is not registered as a delegate of the + /// account on-chain, the clock is skewed (>5 min), or the nonce was reused. + #[error("authentication rejected by relayer (401): {message}")] + AuthRejected { message: String }, + + /// The relayer requires a newer SDK (HTTP 426). + #[error("relayer requires a newer SDK (426): {message}")] + Incompatible { message: String }, + + /// The relayer's `/version` endpoint could not be reached or parsed, or + /// reported an API version this SDK doesn't support. + #[error("compatibility check failed: {0}")] + Compatibility(String), + + /// Building the SEAL session (ephemeral key, Sui personal-message + /// signing, or relayer `/config` lookup) failed. + #[error("seal session error: {0}")] + SealSession(String), + + /// The relayer returned a non-success status with an `{ "error": ... }` body. + #[error("relayer error (status {status}{}): {message}", code.as_deref().map(|c| format!(", code {c}")).unwrap_or_default())] + Server { + /// HTTP status code. + status: u16, + /// Optional machine-readable code parsed from the error body. + code: Option, + /// Human-readable message parsed from the error body (truncated). + message: String, + }, + + /// A polled remember job finished in the `failed` state. + #[error("remember job {job_id} failed: {message}")] + JobFailed { job_id: String, message: String }, + + /// A polled remember job was not found (HTTP 404 / `not_found`). + #[error("remember job {job_id} not found")] + JobNotFound { job_id: String }, + + /// Polling for a remember job exceeded the configured timeout. + #[error("remember job {job_id} timed out after {timeout_ms} ms")] + JobTimeout { job_id: String, timeout_ms: u64 }, + + /// A call was made with invalid arguments before any request was sent. + #[error("invalid argument: {0}")] + InvalidArgument(String), +} diff --git a/packages/rust-sdk/src/lib.rs b/packages/rust-sdk/src/lib.rs new file mode 100644 index 00000000..0f2d83e5 --- /dev/null +++ b/packages/rust-sdk/src/lib.rs @@ -0,0 +1,42 @@ +//! # Walrus Memory Rust SDK +//! +//! A native, async Rust client for [Walrus Memory](https://memory.walrus.xyz) — a +//! privacy-first AI memory layer. Memories are embedded, SEAL-encrypted, and +//! stored on Walrus by the relayer; ownership is enforced on-chain on Sui. +//! +//! Every authenticated request is Ed25519-signed with your delegate key, and +//! decrypt-requiring endpoints additionally attach a short-lived SEAL session +//! (built by signing a Sui personal message with the same delegate key) — +//! your raw private key is never sent over the wire. +//! +//! ## Quick start +//! +//! ```no_run +//! use memwal::{RecallParams, WaitOptions, WalrusMemory}; +//! +//! # async fn run() -> memwal::Result<()> { +//! let client = WalrusMemory::builder( +//! "", +//! "", +//! ) +//! .server_url("https://relayer.memory.walrus.xyz") +//! .namespace("demo") +//! .build()?; +//! +//! client.remember_and_wait("I live in Hanoi.", None, WaitOptions::default()).await?; +//! let hits = client.recall(RecallParams::new("Where do I live?").limit(5)).await?; +//! for m in hits.results { +//! println!("{}", m.text); +//! } +//! # Ok(()) +//! # } +//! ``` + +mod client; +mod error; +mod signing; +mod types; + +pub use client::{WalrusMemory, WalrusMemoryBuilder}; +pub use error::{Error, Result}; +pub use types::*; diff --git a/packages/rust-sdk/src/signing.rs b/packages/rust-sdk/src/signing.rs new file mode 100644 index 00000000..b1af3156 --- /dev/null +++ b/packages/rust-sdk/src/signing.rs @@ -0,0 +1,370 @@ +//! Ed25519 request signing for the Walrus Memory relayer, plus the +//! low-level crypto/encoding helpers used to build a SEAL session. +//! +//! Every authenticated request signs the canonical message +//! +//! ```text +//! {timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id} +//! ``` +//! +//! with the delegate Ed25519 private key. The relayer recomputes the same +//! string from the request headers + received body bytes and verifies the +//! signature against the on-chain delegate-key set for the account. +//! +//! Separately, decrypt-requiring endpoints (`recall`, `remember`, `analyze`, +//! `ask`) also need an `x-seal-session` header: a short-lived SEAL session +//! built by signing a Sui "personal message" with the same delegate key. See +//! [`crate::WalrusMemory::build_seal_session`] for that flow; the pure +//! encoding helpers it depends on (blake2b, uleb128, bech32) live here. + +use ed25519_dalek::{Signer as _, SigningKey}; +use sha2::{Digest, Sha256}; + +use crate::error::{Error, Result}; + +/// Lowercase hex SHA-256 of `bytes`. The GET body hash is `sha256_hex(b"")`. +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +/// Build the canonical message that gets Ed25519-signed. +/// +/// `method` must be upper-case (`GET`/`POST`); `path` is the request path +/// (no scheme/host) including any query string the caller embedded. +pub fn canonical_message( + timestamp: &str, + method: &str, + path: &str, + body_sha256: &str, + nonce: &str, + account_id: &str, +) -> String { + format!("{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}") +} + +/// Holds a delegate Ed25519 key pair derived from a 32-byte seed. +#[derive(Clone)] +pub struct Signer { + signing_key: SigningKey, + seed_hex: String, + public_key_hex: String, +} + +impl Signer { + /// Build a signer from a hex-encoded 32-byte Ed25519 seed. + /// + /// Accepts an optional `0x` prefix and any letter case. + pub fn from_hex_seed(key: &str) -> Result { + let trimmed = key.trim().trim_start_matches("0x"); + let bytes = + hex::decode(trimmed).map_err(|e| Error::InvalidKey(format!("not valid hex: {e}")))?; + let seed: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| Error::InvalidKey(format!("expected 32 bytes, got {}", bytes.len())))?; + let signing_key = SigningKey::from_bytes(&seed); + let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes()); + Ok(Self { + signing_key, + seed_hex: hex::encode(seed), + public_key_hex, + }) + } + + /// Hex-encoded 32-byte public key. + pub fn public_key_hex(&self) -> &str { + &self.public_key_hex + } + + /// Hex-encoded 64-byte Ed25519 signature over `message`. + pub fn sign_hex(&self, message: &[u8]) -> String { + hex::encode(self.signing_key.sign(message).to_bytes()) + } + + /// Raw Ed25519 signature over `message` (used for Sui personal-message + /// signing, which needs the raw 64 bytes rather than hex). + pub fn sign(&self, message: &[u8]) -> ed25519_dalek::Signature { + self.signing_key.sign(message) + } + + /// The underlying verifying (public) key bytes. + pub fn verifying_key_bytes(&self) -> [u8; 32] { + self.signing_key.verifying_key().to_bytes() + } + + /// Zero the key seed material in place. After calling this, the signer + /// must not be used again — further signing attempts will produce + /// garbage signatures rather than panicking. + pub fn zeroize(&mut self) { + use zeroize::Zeroize; + self.seed_hex.zeroize(); + // ed25519_dalek::SigningKey has no public zeroize hook; overwrite the + // hex-encoded copy (the only owned representation we control) and + // drop our reference so the key can be reclaimed. + self.signing_key = SigningKey::from_bytes(&[0u8; 32]); + } +} + +impl std::fmt::Debug for Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never leak key material in Debug output. + f.debug_struct("Signer") + .field("public_key_hex", &self.public_key_hex) + .field("seed_hex", &"") + .finish() + } +} + +// ── SEAL session encoding helpers ────────────────────────────────────────── + +/// Blake2b-256 digest, used for the Sui personal-message hash and address +/// derivation. +pub fn blake2b_256(data: &[u8]) -> [u8; 32] { + use blake2::digest::{Update, VariableOutput}; + use blake2::Blake2bVar; + let mut hasher = Blake2bVar::new(32).expect("32 bytes output size is valid"); + hasher.update(data); + let mut output = [0u8; 32]; + hasher + .finalize_variable(&mut output) + .expect("digest output"); + output +} + +/// ULEB128-encode `value` (used for the Sui `PersonalMessage` length prefix). +pub fn encode_uleb128(mut value: usize) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut byte = (value & 0x7F) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + break; + } + } + bytes +} + +fn bech32_polymod(values: &[u8]) -> u32 { + let mut generator: u32 = 1; + for &value in values { + let top = generator >> 25; + generator = ((generator & 0x1ffffff) << 5) ^ (value as u32); + for (i, &g) in [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + .iter() + .enumerate() + { + if (top >> i) & 1 != 0 { + generator ^= g; + } + } + } + generator +} + +fn bech32_hrp_expand(hrp: &str) -> Vec { + let mut v = Vec::new(); + for c in hrp.bytes() { + v.push(c >> 5); + } + v.push(0); + for c in hrp.bytes() { + v.push(c & 31); + } + v +} + +fn bech32_create_checksum(hrp: &str, data: &[u8]) -> Vec { + let mut combined = bech32_hrp_expand(hrp); + combined.extend_from_slice(data); + combined.extend_from_slice(&[0, 0, 0, 0, 0, 0]); + let polymod = bech32_polymod(&combined) ^ 1; + let mut checksum = Vec::with_capacity(6); + for i in 0..6 { + checksum.push(((polymod >> (5 * (5 - i))) & 31) as u8); + } + checksum +} + +fn convert_bits( + data: &[u8], + from: u32, + to: u32, + pad: bool, +) -> std::result::Result, &'static str> { + let mut acc: u32 = 0; + let mut bits: u32 = 0; + let mut ret = Vec::new(); + let maxv: u32 = (1 << to) - 1; + let max_acc: u32 = (1 << (from + to - 1)) - 1; + for &value in data { + let v = value as u32; + if (v >> from) != 0 { + return Err("Invalid value"); + } + acc = ((acc << from) | v) & max_acc; + bits += from; + while bits >= to { + bits -= to; + ret.push(((acc >> bits) & maxv) as u8); + } + } + if pad { + if bits > 0 { + ret.push(((acc << (to - bits)) & maxv) as u8); + } + } else if bits >= from || ((acc << (to - bits)) & maxv) != 0 { + return Err("Invalid padding"); + } + Ok(ret) +} + +/// Bech32-encode a 32-byte Ed25519 seed as a Sui `suiprivkey1...` string. +pub fn encode_suiprivkey(seed: &[u8; 32]) -> String { + let mut payload = Vec::with_capacity(33); + payload.push(0x00); + payload.extend_from_slice(seed); + + let data_5bit = convert_bits(&payload, 8, 5, true).unwrap(); + let checksum = bech32_create_checksum("suiprivkey", &data_5bit); + + let mut combined = data_5bit; + combined.extend_from_slice(&checksum); + + let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + let encoded: String = combined + .iter() + .map(|&b| charset.chars().nth(b as usize).unwrap()) + .collect(); + + format!("suiprivkey1{encoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + // 32 bytes of 0x01. + const SEED: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + #[test] + fn sha256_of_empty_matches_known_vector() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn from_hex_seed_accepts_0x_prefix_and_any_case() { + let a = Signer::from_hex_seed(SEED).unwrap(); + let b = Signer::from_hex_seed(&format!("0x{}", SEED.to_uppercase())).unwrap(); + assert_eq!(a.public_key_hex(), b.public_key_hex()); + assert_eq!(a.public_key_hex().len(), 64); + } + + #[test] + fn from_hex_seed_rejects_bad_input() { + assert!(Signer::from_hex_seed("zz").is_err()); // not hex + assert!(Signer::from_hex_seed("0102").is_err()); // wrong length + } + + #[test] + fn signature_is_deterministic_and_verifies() { + let signer = Signer::from_hex_seed(SEED).unwrap(); + let msg = canonical_message( + "1700000000", + "POST", + "/api/remember", + &sha256_hex(b"{\"text\":\"hi\"}"), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "0xacct", + ); + let sig1 = signer.sign_hex(msg.as_bytes()); + let sig2 = signer.sign_hex(msg.as_bytes()); + assert_eq!(sig1, sig2, "ed25519 signing must be deterministic"); + assert_eq!(sig1.len(), 128, "64-byte signature as hex"); + + let pk: [u8; 32] = hex::decode(signer.public_key_hex()) + .unwrap() + .try_into() + .unwrap(); + let vk = VerifyingKey::from_bytes(&pk).unwrap(); + let sig_bytes: [u8; 64] = hex::decode(&sig1).unwrap().try_into().unwrap(); + let sig = Signature::from_bytes(&sig_bytes); + assert!(vk.verify(msg.as_bytes(), &sig).is_ok()); + + let tampered = canonical_message( + "1700000001", // changed timestamp + "POST", + "/api/remember", + &sha256_hex(b"{\"text\":\"hi\"}"), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "0xacct", + ); + assert!(vk.verify(tampered.as_bytes(), &sig).is_err()); + } + + #[test] + fn canonical_message_is_six_dot_separated_fields() { + let m = canonical_message( + "1700000000", + "POST", + "/api/recall", + "abc", + "nonce", + "0xacct", + ); + assert_eq!(m, "1700000000.POST./api/recall.abc.nonce.0xacct"); + assert_eq!(m.matches('.').count(), 5); + } + + #[test] + fn zeroize_clears_seed_hex() { + let mut signer = Signer::from_hex_seed(SEED).unwrap(); + signer.zeroize(); + assert_eq!(signer.seed_hex, ""); + } + + #[test] + fn uleb128_encoding() { + assert_eq!(encode_uleb128(0), vec![0]); + assert_eq!(encode_uleb128(127), vec![127]); + assert_eq!(encode_uleb128(128), vec![128, 1]); + assert_eq!(encode_uleb128(145), vec![0x91, 0x01]); + } + + #[test] + fn sui_address_derivation_shape() { + let signer = Signer::from_hex_seed(SEED).unwrap(); + let mut address_input = Vec::new(); + address_input.push(0x00); + address_input.extend_from_slice(&signer.verifying_key_bytes()); + let address_hash = blake2b_256(&address_input); + let address = format!("0x{}", hex::encode(address_hash)); + + assert!(address.starts_with("0x")); + assert_eq!(address.len(), 66); + } + + #[test] + fn suiprivkey_bech32_encoding() { + let seed: [u8; 32] = [1; 32]; + let encoded = encode_suiprivkey(&seed); + assert!(encoded.starts_with("suiprivkey1")); + + let data_part = &encoded["suiprivkey1".len()..]; + for c in data_part.chars() { + assert!( + "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c), + "character {c} not in bech32 charset" + ); + } + } +} diff --git a/packages/rust-sdk/src/types.rs b/packages/rust-sdk/src/types.rs new file mode 100644 index 00000000..468033c6 --- /dev/null +++ b/packages/rust-sdk/src/types.rs @@ -0,0 +1,479 @@ +//! Request options and response types mirroring the TypeScript / Python SDKs. +//! +//! Wire JSON field names are `snake_case` for all `/api/*` bodies; the +//! `/version` metadata uses `camelCase` (handled via `serde(rename_all)`). + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Known relayer environments, used by [`crate::WalrusMemoryBuilder::env`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Env { + /// `https://relayer.memory.walrus.xyz` + Prod, + /// `https://relayer-staging.memory.walrus.xyz` + Staging, + /// `https://relayer.dev.memwal.ai` (legacy dev domain, pre-WALM-86 rebrand) + Dev, + /// `http://127.0.0.1:8000` + Local, +} + +impl Env { + /// The relayer base URL for this environment. + pub fn server_url(self) -> &'static str { + match self { + Env::Prod => "https://relayer.memory.walrus.xyz", + Env::Staging => "https://relayer-staging.memory.walrus.xyz", + Env::Dev => "https://relayer.dev.memwal.ai", + Env::Local => "http://127.0.0.1:8000", + } + } +} + +/// Polling configuration for `*_and_wait` / `wait_for_*` helpers. +#[derive(Debug, Clone, Copy)] +pub struct WaitOptions { + /// Base delay between status polls (jittered exponential backoff is applied). + pub poll_interval_ms: u64, + /// Give up after this many milliseconds. + pub timeout_ms: u64, +} + +impl Default for WaitOptions { + fn default() -> Self { + Self { + poll_interval_ms: 1500, + timeout_ms: 60_000, + } + } +} + +impl WaitOptions { + /// Default poll cadence with a longer timeout (used for bulk/analyze waits). + pub fn bulk() -> Self { + Self { + poll_interval_ms: 1500, + timeout_ms: 120_000, + } + } +} + +/// Optional hybrid-ranking weights for `recall` / `recall_manual` / `ask`. +/// +/// Omitting the whole object yields plain cosine-distance ordering. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ScoringWeights { + #[serde(skip_serializing_if = "Option::is_none")] + pub semantic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recency_half_life_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub importance: Option, +} + +/// Parameters for [`crate::WalrusMemory::recall`]. +#[derive(Debug, Clone)] +pub struct RecallParams { + /// Natural-language query (required). + pub query: String, + /// Max results (default 10, server-capped to 100). + pub limit: Option, + /// Namespace override (defaults to the client namespace). + pub namespace: Option, + /// Client-side filter: drop results whose `distance >= max_distance`. + pub max_distance: Option, + /// Optional hybrid-ranking weights. + pub scoring_weights: Option, +} + +impl RecallParams { + /// Start from just a query string. + pub fn new(query: impl Into) -> Self { + Self { + query: query.into(), + limit: None, + namespace: None, + max_distance: None, + scoring_weights: None, + } + } + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = Some(ns.into()); + self + } + pub fn max_distance(mut self, d: f64) -> Self { + self.max_distance = Some(d); + self + } + pub fn scoring_weights(mut self, w: ScoringWeights) -> Self { + self.scoring_weights = Some(w); + self + } +} + +impl> From for RecallParams { + fn from(query: S) -> Self { + RecallParams::new(query) + } +} + +/// Options for [`crate::WalrusMemory::analyze`]. +#[derive(Debug, Clone, Default)] +pub struct AnalyzeOptions { + /// Namespace override (defaults to the client namespace). + pub namespace: Option, + /// Valid-time anchor for relative dates inside the text (serialized RFC-3339 UTC). + pub occurred_at: Option>, +} + +impl AnalyzeOptions { + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = Some(ns.into()); + self + } + pub fn occurred_at(mut self, ts: DateTime) -> Self { + self.occurred_at = Some(ts); + self + } +} + +/// One item for [`crate::WalrusMemory::remember_bulk`]. +#[derive(Debug, Clone)] +pub struct RememberBulkItem { + pub text: String, + pub namespace: Option, +} + +impl RememberBulkItem { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + namespace: None, + } + } + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = Some(ns.into()); + self + } +} + +/// Options for [`crate::WalrusMemory::recall_manual`]. +#[derive(Debug, Clone)] +pub struct RecallManualOptions { + /// Pre-computed query embedding vector. + pub vector: Vec, + pub limit: Option, + pub namespace: Option, + pub scoring_weights: Option, +} + +impl RecallManualOptions { + pub fn new(vector: Vec) -> Self { + Self { + vector, + limit: None, + namespace: None, + scoring_weights: None, + } + } + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = Some(ns.into()); + self + } + pub fn scoring_weights(mut self, w: ScoringWeights) -> Self { + self.scoring_weights = Some(w); + self + } +} + +/// Options for [`crate::WalrusMemory::remember_manual`]: caller has already +/// embedded + SEAL-encrypted + uploaded the memory to Walrus themselves, and +/// only needs the relayer to index the resulting blob. +#[derive(Debug, Clone)] +pub struct RememberManualOptions { + /// The Walrus blob id where the SEAL-encrypted memory was already stored. + pub blob_id: String, + /// Pre-computed embedding vector for the memory. + pub vector: Vec, + pub namespace: Option, +} + +impl RememberManualOptions { + pub fn new(blob_id: impl Into, vector: Vec) -> Self { + Self { + blob_id: blob_id.into(), + vector, + namespace: None, + } + } + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = Some(ns.into()); + self + } +} + +// ── Response types (snake_case wire) ────────────────────────────────────── + +/// Returned by `remember` / `remember_bulk` accept calls. +#[derive(Debug, Clone, Deserialize)] +pub struct RememberAccepted { + pub job_id: String, + pub status: String, +} + +/// Raw `GET /api/remember/{job_id}` status body. +#[derive(Debug, Clone, Deserialize)] +pub struct RememberJobStatus { + pub job_id: String, + pub status: String, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub namespace: Option, + #[serde(default)] + pub blob_id: Option, + #[serde(default)] + pub error: Option, +} + +/// Completed remember result (synthesized once a job reaches `done`). +#[derive(Debug, Clone, Deserialize)] +pub struct RememberResult { + #[serde(default)] + pub id: String, + #[serde(default)] + pub job_id: Option, + pub blob_id: String, + pub owner: String, + pub namespace: String, +} + +/// One recalled memory. +#[derive(Debug, Clone, Deserialize)] +pub struct RecallMemory { + pub blob_id: String, + pub text: String, + pub distance: f64, + #[serde(default)] + pub score: Option, +} + +/// Result of [`crate::WalrusMemory::recall`]. +#[derive(Debug, Clone, Deserialize)] +pub struct RecallResult { + pub results: Vec, + pub total: u64, + #[serde(default)] + pub dropped_count: Option, +} + +/// Result of [`crate::WalrusMemory::embed`]. +#[derive(Debug, Clone, Deserialize)] +pub struct EmbedResult { + pub vector: Vec, +} + +/// One fact extracted by `analyze`. +#[derive(Debug, Clone, Deserialize)] +pub struct AnalyzedFact { + pub text: String, + pub id: String, + #[serde(default)] + pub job_id: Option, + #[serde(default)] + pub blob_id: Option, +} + +/// Result of [`crate::WalrusMemory::analyze`]. +#[derive(Debug, Clone, Deserialize)] +pub struct AnalyzeResult { + pub job_ids: Vec, + pub facts: Vec, + pub fact_count: u64, + pub status: String, + pub owner: String, +} + +/// One memory used to answer an `ask` query. +#[derive(Debug, Clone, Deserialize)] +pub struct AskMemory { + pub blob_id: String, + pub text: String, + pub distance: f64, + #[serde(default)] + pub score: Option, +} + +/// Result of [`crate::WalrusMemory::ask`]. +#[derive(Debug, Clone, Deserialize)] +pub struct AskResult { + pub answer: String, + pub memories_used: u64, + pub memories: Vec, +} + +/// Result of [`crate::WalrusMemory::restore`]. +#[derive(Debug, Clone, Deserialize)] +pub struct RestoreResult { + pub restored: u64, + pub skipped: u64, + pub total: u64, + pub namespace: String, + pub owner: String, +} + +/// Result of [`crate::WalrusMemory::health`]. Captures the common fields plus any +/// additional relayer metadata in `extra`. +#[derive(Debug, Clone, Deserialize)] +pub struct HealthResult { + pub status: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub mode: Option, + /// Any remaining fields (relayerVersion, apiVersion, featureFlags, …). + #[serde(flatten)] + pub extra: HashMap, +} + +/// Minimum supported SDK versions advertised by the relayer. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MinSupportedSdk { + pub typescript: String, + pub python: String, + pub mcp: String, +} + +/// One deprecation notice from `GET /version`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeprecationNotice { + pub surface: String, + pub deprecated_since: String, + pub removal_api_version: String, + pub guidance: String, +} + +/// Build metadata from `GET /version`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildInfo { + #[serde(default)] + pub commit: Option, + #[serde(default)] + pub build_timestamp: Option, +} + +/// `GET /version` relayer compatibility metadata. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionInfo { + pub relayer_version: String, + pub api_version: String, + pub min_supported_sdk: MinSupportedSdk, + #[serde(default)] + pub feature_flags: HashMap, + #[serde(default)] + pub deprecations: Vec, + #[serde(default)] + pub build: BuildInfo, +} + +// ── Bulk ────────────────────────────────────────────────────────────────── + +/// Returned by `remember_bulk`. +#[derive(Debug, Clone, Deserialize)] +pub struct RememberBulkAccepted { + pub job_ids: Vec, + pub total: u64, + pub status: String, +} + +/// One item of a bulk status response. +#[derive(Debug, Clone, Deserialize)] +pub struct RememberBulkStatusItem { + pub job_id: String, + pub status: String, + #[serde(default)] + pub blob_id: Option, + #[serde(default)] + pub error: Option, +} + +/// Raw `POST /api/remember/bulk/status` response. +#[derive(Debug, Clone, Deserialize)] +pub struct RememberBulkStatusResult { + pub results: Vec, +} + +/// One resolved bulk item after waiting. +#[derive(Debug, Clone)] +pub struct RememberBulkItemResult { + pub id: String, + pub blob_id: Option, + /// `done` | `failed` | `timeout` + pub status: String, + pub namespace: String, + pub error: Option, +} + +/// Resolved bulk result after waiting for all jobs. +#[derive(Debug, Clone)] +pub struct RememberBulkResult { + pub results: Vec, + pub total: u64, + pub succeeded: u64, + pub failed: u64, +} + +/// Result of [`crate::WalrusMemory::analyze_and_wait`]: extracted facts plus the +/// resolved status of every per-fact remember job. +#[derive(Debug, Clone)] +pub struct AnalyzeWaitResult { + pub facts: Vec, + pub owner: String, + pub results: Vec, + pub total: u64, + pub succeeded: u64, + pub failed: u64, +} + +/// One hit from `recall_manual` (no decrypted text). +#[derive(Debug, Clone, Deserialize)] +pub struct RecallManualHit { + pub blob_id: String, + pub distance: f64, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub importance: Option, +} + +/// Result of [`crate::WalrusMemory::recall_manual`]. +#[derive(Debug, Clone, Deserialize)] +pub struct RecallManualResult { + pub results: Vec, + pub total: u64, +} + +/// Result of [`crate::WalrusMemory::remember_manual`]. +#[derive(Debug, Clone, Deserialize)] +pub struct RememberManualResult { + pub blob_id: String, + pub owner: String, + pub namespace: String, +} diff --git a/packages/rust-sdk/tests/integration.rs b/packages/rust-sdk/tests/integration.rs new file mode 100644 index 00000000..1fa615ca --- /dev/null +++ b/packages/rust-sdk/tests/integration.rs @@ -0,0 +1,251 @@ +//! Integration tests against a mock relayer (no network / credentials needed). + +use memwal::{Error, RecallManualOptions, RecallParams, RememberManualOptions, WalrusMemory}; +use serde_json::json; +use wiremock::matchers::{header_exists, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +// 32 bytes of 0x01 — a valid Ed25519 seed, not a real credential. +const SEED: &str = "0101010101010101010101010101010101010101010101010101010101010101"; +const ACCOUNT: &str = "0xabc"; + +fn client(uri: String) -> WalrusMemory { + WalrusMemory::builder(SEED, ACCOUNT) + .server_url(uri) + .namespace("demo") + .build() + .expect("client builds") +} + +/// Start a mock relayer with `/config` mounted, needed by any endpoint that +/// requests a SEAL session (`build_seal_session` fetches it for any +/// non-mainnet server URL, which every wiremock URI is). +async fn mock_relayer() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/config")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "packageId": "0xtest::memwal" })), + ) + .mount(&server) + .await; + server +} + +#[tokio::test] +async fn remember_sends_signed_headers_and_parses_accepted() { + let server = mock_relayer().await; + Mock::given(method("POST")) + .and(path("/api/remember")) + .and(header_exists("x-public-key")) + .and(header_exists("x-signature")) + .and(header_exists("x-timestamp")) + .and(header_exists("x-nonce")) + .and(header_exists("x-account-id")) + .and(header_exists("x-seal-session")) + .respond_with( + ResponseTemplate::new(202) + .set_body_json(json!({"job_id": "job-1", "status": "running"})), + ) + .mount(&server) + .await; + + let res = client(server.uri()).remember("hello", None).await.unwrap(); + assert_eq!(res.job_id, "job-1"); + assert_eq!(res.status, "running"); +} + +#[tokio::test] +async fn recall_parses_and_applies_client_side_max_distance() { + let server = mock_relayer().await; + Mock::given(method("POST")) + .and(path("/api/recall")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [ + {"blob_id": "a", "text": "near", "distance": 0.1}, + {"blob_id": "b", "text": "mid", "distance": 0.5}, + {"blob_id": "c", "text": "far", "distance": 0.9} + ], + "total": 3 + }))) + .mount(&server) + .await; + + let c = client(server.uri()); + let all = c.recall(RecallParams::new("q")).await.unwrap(); + assert_eq!(all.total, 3); + assert_eq!(all.results.len(), 3); + + let filtered = c + .recall(RecallParams::new("q").max_distance(0.6)) + .await + .unwrap(); + assert_eq!(filtered.total, 2); + assert!(filtered.results.iter().all(|m| m.distance < 0.6)); +} + +#[tokio::test] +async fn get_remember_status_404_becomes_not_found() { + let server = mock_relayer().await; + Mock::given(method("GET")) + .and(path("/api/remember/missing")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "no such job"}))) + .mount(&server) + .await; + + let st = client(server.uri()) + .get_remember_status("missing") + .await + .unwrap(); + assert_eq!(st.status, "not_found"); + assert_eq!(st.job_id, "missing"); +} + +#[tokio::test] +async fn health_is_unsigned_and_parses() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": "ok", "version": "0.1.0", "apiVersion": "1.0.0" + }))) + .mount(&server) + .await; + + let h = client(server.uri()).health().await.unwrap(); + assert_eq!(h.status, "ok"); + assert_eq!(h.version, "0.1.0"); + assert_eq!( + h.extra.get("apiVersion").and_then(|v| v.as_str()), + Some("1.0.0") + ); +} + +#[tokio::test] +async fn compatibility_accepts_1_x_api_version() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "relayerVersion": "0.1.0", + "apiVersion": "1.2.0", + "minSupportedSdk": {"typescript": "0.1.0", "python": "0.1.0", "mcp": "0.1.0"} + }))) + .mount(&server) + .await; + + client(server.uri()).compatibility().await.unwrap(); +} + +#[tokio::test] +async fn compatibility_rejects_incompatible_api_version() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "relayerVersion": "0.1.0", + "apiVersion": "2.0.0", + "minSupportedSdk": {"typescript": "0.1.0", "python": "0.1.0", "mcp": "0.1.0"} + }))) + .mount(&server) + .await; + + let err = client(server.uri()).compatibility().await.unwrap_err(); + assert!(matches!(err, Error::Compatibility(_)), "got {err:?}"); +} + +#[tokio::test] +async fn server_error_maps_to_error_server() { + let server = mock_relayer().await; + Mock::given(method("POST")) + .and(path("/api/ask")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"}))) + .mount(&server) + .await; + + let err = client(server.uri()) + .ask("why?", None, None) + .await + .unwrap_err(); + match err { + Error::Server { + status, message, .. + } => { + assert_eq!(status, 500); + assert_eq!(message, "boom"); + } + other => panic!("expected Error::Server, got {other:?}"), + } +} + +#[tokio::test] +async fn unauthorized_maps_to_auth_rejected() { + let server = mock_relayer().await; + Mock::given(method("POST")) + .and(path("/api/recall")) + .respond_with(ResponseTemplate::new(401).set_body_string("")) + .mount(&server) + .await; + + let err = client(server.uri()) + .recall(RecallParams::new("q")) + .await + .unwrap_err(); + assert!(matches!(err, Error::AuthRejected { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn recall_manual_parses_hits() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/recall/manual")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"blob_id": "a", "distance": 0.2, "importance": 0.5}], + "total": 1 + }))) + .mount(&server) + .await; + + let res = client(server.uri()) + .recall_manual(RecallManualOptions::new(vec![0.1, 0.2, 0.3])) + .await + .unwrap(); + assert_eq!(res.total, 1); + assert_eq!(res.results[0].blob_id, "a"); +} + +#[tokio::test] +async fn remember_manual_parses_result_and_sends_no_seal_session() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/remember/manual")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "blob_id": "blob-1", "owner": "0xowner", "namespace": "demo" + }))) + .mount(&server) + .await; + + let res = client(server.uri()) + .remember_manual(RememberManualOptions::new("blob-1", vec![0.1, 0.2])) + .await + .unwrap(); + assert_eq!(res.blob_id, "blob-1"); + assert_eq!(res.owner, "0xowner"); +} + +#[test] +fn invalid_key_fails_to_build() { + let err = WalrusMemory::builder("not-hex", ACCOUNT) + .build() + .unwrap_err(); + assert!(matches!(err, Error::InvalidKey(_)), "got {err:?}"); +} + +#[test] +fn rejects_non_http_server_url() { + let err = WalrusMemory::builder(SEED, ACCOUNT) + .server_url("ftp://example.com") + .build() + .unwrap_err(); + assert!(matches!(err, Error::InvalidUrl(_)), "got {err:?}"); +}