diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88db22b..0224a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,22 +6,6 @@ on: pull_request: jobs: - go-fmt-vet-build: - name: cabi (gofmt, go vet, go test, build) - runs-on: ubuntu-latest - defaults: - run: - working-directory: cabi - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: cabi/go.mod - - run: test -z "$(gofmt -l .)" - - run: go vet ./... - - run: go test ./... - - run: go build -buildmode=c-shared -o libmacula.so . - php-test: name: php (lint, composer validate, phpunit against in-process stations) runs-on: ubuntu-latest @@ -29,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: cabi/go.mod + go-version: "1.27" - uses: shivammathur/setup-php@v2 with: php-version: "8.3" @@ -40,8 +24,11 @@ jobs: php -l "$f" done - run: composer install --no-interaction --prefer-dist - # libmacula.so and build/teststation, two in-process macula 12 - # stations the offline suite runs against. tests/live dials the real - # fleet and never runs here: `composer test:live`, by hand. + # The released libmacula for abi/MACULA_GO_REF, checked against its + # SHA256SUMS and attestation, and macula-go's teststation at that tag: + # two in-process stations the offline suite runs against. tests/live + # dials the real fleet and never runs here: `composer test:live`, by hand. - run: composer build + env: + GH_TOKEN: ${{ github.token }} - run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 2a0e794..cc81ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,3 @@ /composer.lock /.phpunit.cache/ /build/ -/cabi/libmacula.so -/cabi/libmacula.h diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5eb8d..7726f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ to v0.3.4 are described in their annotated git tag messages. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-10-05 + +### Changed + +- macula-php loads macula-go's **released** libmacula, the tag in + `abi/MACULA_GO_REF`, instead of its own `cabi/` over macula-go v0.12.0, + which is gone. `composer build` downloads the library for this platform, + checks it against the release's `SHA256SUMS` and build attestation, and + refuses a release whose `macula.h` is not `abi/macula.h`; `Binding` refuses + a library of another ABI version. The test suite runs macula-go's own + teststation at the same tag. This brings the macula 13 wire: handshake v5 + bound to the TLS session, and macula 13 advertisements that name a + provider's KEM key, which v0.12.0 refused as malformed ("no trusted + provider"). +- Errors cross as libmacula's JSON errors: `MaculaException::$kind` is the + library's error kind ("timeout", "not_found", "no_provider", "refused", ...), + and `ProviderError`, `RelayError`, `NotSharedError` and + `ContentUnavailableError` are thrown for their kinds as before. +- `NodeKey::loadOrCreate` is libmacula's: a key file that exists but cannot be + read is an error, never a reason to make a new key. + +### Added + +- End-to-end sealing: `Pool::call` and `Pool::openStream` take + `confidential: Confidentiality::Preferred|Required`, sealed whenever the + provider's advertisement names a KEM key; `Required` fails with a + `ConfidentialityError` (`reason`, `named`, `found`) rather than go in the + clear. `Pool::serve` and `Pool::serveStream` take `confidential:` too (`Off` + as well), and `Pool::connect` takes `kemAdvertise:` to name this node's KEM + key in its advertisements. A served `Request` has `sealed`. + ## [0.6.0] - 2026-09-26 ### Added diff --git a/README.md b/README.md index 084238d..668831f 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,17 @@ --- -> **Status, 2026-09-26:** on the **macula 12** wire (post-quantum: ML-DSA-87 -> identities, as the ML-DSA-87 + RSA-PSS-4096 composite in pq_hybrid, the -> fleet's profile; ML-KEM hybrid key exchange; signed requests), over macula-go -> v0.12.0's pool. Calls and streams by direct dial, serving (under an org or in -> a node's own namespace), publish/subscribe, the DHT and node-served content -> are tested against in-process macula 12 stations on every `composer test`. -> UCAN-gated calls are not here yet; see [Not yet -> implemented](#not-yet-implemented). Releases before 0.5.0 speak the retired -> 10.x wire and cannot reach the current fleet. +> **Status, 2026-10-05:** over macula-go's **released** libmacula (the tag in +> `abi/MACULA_GO_REF`, ABI 1), so on the macula 13 wire: handshake v5 bound to +> the TLS session, end-to-end sealed calls and streams to a provider that +> advertises a KEM key (`Confidentiality`), post-quantum identities (ML-DSA-87, +> as the ML-DSA-87 + RSA-PSS-4096 composite in pq_hybrid, the fleet's profile) +> and ML-KEM hybrid key exchange. Calls and streams by direct dial, serving +> (under an org or in a node's own namespace), publish/subscribe, the DHT and +> node-served content are tested against in-process stations on every +> `composer test`. UCAN-gated calls are not in the PHP API yet; see [Not yet +> implemented](#not-yet-implemented). Releases before 0.7.0 bind macula-go +> v0.12.0 and cannot call a provider that advertises a KEM key. ## What is this? @@ -35,15 +37,15 @@ A PHP SDK for the Macula mesh: a node's key, a pool of links to stations it pins by node_id, calls and streams that reach a provider by direct dial, serving procedures, publish/subscribe, node-served content and the DHT. It is an FFI binding over [macula-go](https://github.com/macula-io/macula-go): the Go -SDK is compiled into one shared library, `libmacula.so`, which PHP loads with -`ext-ffi`, rather than a third implementation of QUIC, TLS 1.3 with a hybrid +SDK's released shared library, libmacula, which PHP loads with `ext-ffi`, +rather than a third implementation of QUIC, TLS 1.3 with a hybrid post-quantum key exchange, deterministic CBOR and signed frames. ## Quick start ```bash composer require macula-io/macula-php -cd vendor/macula-io/macula-php && composer build # compiles cabi/ into libmacula.so +cd vendor/macula-io/macula-php && composer build # fetches and checks libmacula ``` A node needs a station to link to, **pinned by its node_id**, and the key of @@ -125,7 +127,8 @@ compatibility layer. |---|---|---|---| | Node keys (`NodeKey`) | ✅ | ✅ | `pq_hybrid` (the fleet's) or `pq_pure`; key files readable by the owner only; `sign` and `verify`, pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.7.0 | | Pool of station links (`Pool::connect`) | ✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed | -| Calls by direct dial (`call`, `providers`) | ✅ | ✅ | Errors arrive as `ProviderError` / `RelayError` | +| Calls by direct dial (`call`, `providers`) | ✅ | ✅ | Errors arrive as `ProviderError` / `RelayError`; every other failure is a `MaculaException` whose `kind` is the library's error kind | +| End-to-end sealing (`confidential:`, `kemAdvertise:`) | ✅ | ✅ | A call or stream is sealed whenever the provider's advertisement names a KEM key; `Confidentiality::Required` fails with `ConfidentialityError` rather than go in the clear; a served `Request` says whether it came `sealed` | | A node's own namespace (`ownProcedure`) | ✅ | ✅ | `~/`: served and called with no org and no realm key; the node's signature authorizes it | | Streams (`openStream`, `serveStream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path | | Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links | @@ -168,38 +171,40 @@ bytes, and come back as hex. ## Architecture ``` -src/ (PHP API) ── src/Binding.php (ext-ffi) ── cabi/ (Go, libmacula.so) ── macula-go pool +src/ (PHP API) ── src/Binding.php (ext-ffi) ── libmacula (macula-go's C ABI) ── macula-go pool ``` -`cabi/` exports C functions over macula-go's `pool` (and `stationlink` -streams). Every Go value crosses as a `runtime/cgo.Handle`; payloads cross as -JSON. Every call that does network I/O blocks the calling PHP thread. PHP's FFI -cannot take a callback on a Go thread, so what arrives on its own (a -subscription's events, a served procedure's calls, a streaming procedure's -sessions) waits in a bounded inbox in `cabi/inbox.go` until a `*_next` -function takes it, with a wait of its own. +libmacula is macula-go's C ABI (`abi/macula.h`, its contract in macula-go's +`cabi/CONTRACT.md`), the same library macula-py, Macula .NET and macula-ts +load. `composer build` downloads it for the tag in `abi/MACULA_GO_REF`, checks +it against the release's `SHA256SUMS` and build attestation, and refuses a +release whose `macula.h` is not `abi/macula.h`; `Binding` refuses a library of +another ABI version. Payloads cross as JSON. Every call that does network I/O +blocks the calling PHP thread. What arrives on its own (a subscription's +events, a served procedure's calls, a streaming procedure's sessions) waits in +a bounded inbox in the library until a `*_next` function takes it. ## Not yet implemented -- **UCAN-gated calls and serving.** macula 12 uses post-quantum UCANs - (macula-go#2). Calls carry no token yet, and a gated procedure cannot be - served. +- **UCAN-gated calls and serving.** libmacula has them + (`macula_pool_call_opts` with a "ucan", `macula_pool_serve_opts` with a + policy); the PHP API does not expose them yet. +- **The seal report** (`report`, `macula_stream_report`): not in the PHP API. ## Testing ```bash composer install -composer build # libmacula.so and build/teststation +composer build # libmacula into build/native, and build/teststation composer test # the offline suite composer test:live # one live station, see below ``` -`composer test` runs `tests/PoolTest.php` against `cabi/cmd/teststation`, a -helper that runs two in-process macula 12 stations (macula-go's `teststation`) -sharing a DHT, with a test realm that admits the test's provider nodes. It +`composer test` runs `tests/PoolTest.php` against macula-go's teststation at +the same tag, which runs two in-process stations sharing a DHT, with a test realm that admits the test's provider nodes. It exercises keys, calls by direct dial and their errors, providers, server and client streams and a provider that aborts one (and that no stream is left -unreleased), pubsub, node-served content and the DHT, through the real library. +unreleased), a sealed call and a required call refused in the clear, pubsub, node-served content and the DHT, through the real library. A provider a test calls runs in a PHP process of its own (`tests/fixtures/provider.php`). No network is needed. The suite needs PHP ≥ 8.3 (PHPUnit 12's floor); the library itself runs on PHP ≥ 8.1. @@ -222,8 +227,8 @@ key generated for the run and never saved, it reads the DHT, calls - PHP ≥ 8.1 with `ext-ffi`. `ext-ffi` is not always enabled in distro PHP builds: check `php -m | grep FFI`; PHP is built with it by `--with-ffi`. -- Go ≥ 1.26 and a C compiler (cgo), to build `cabi/`. `composer build` is the - only Go command you run; you never write Go. +- `gh` with a `GH_TOKEN` and `sha256sum`, for `composer build` to fetch and + check libmacula. Go 1.27, only for the test suite's teststation. - Composer. ## Sibling SDKs @@ -232,7 +237,7 @@ key generated for the run and never saved, it reads the DHT, calls |---|---| | [macula](https://github.com/macula-io/macula) | The reference SDK (Erlang/OTP) | | [macula-go](https://github.com/macula-io/macula-go) | Go port, and what this SDK binds | -| [macula-ts](https://github.com/macula-io/macula-ts) | FFI binding over macula-go, for Node.js; this SDK's `cabi/` follows its pool model | +| [macula-ts](https://github.com/macula-io/macula-ts) | FFI binding over the same libmacula, for Node.js | | [macula-rust](https://github.com/macula-io/macula-rust) | Native reimplementation (quinn, pure Rust) | | [macula-station](https://github.com/macula-io/macula-station) | The station: DHT, SWIM, routing, peering | | [macula-realm](https://github.com/macula-io/macula-realm) | Managed-realm identity + certificate authority | diff --git a/abi/MACULA_GO_REF b/abi/MACULA_GO_REF new file mode 100644 index 0000000..2ae4251 --- /dev/null +++ b/abi/MACULA_GO_REF @@ -0,0 +1 @@ +v0.22.0 dc23a8e80401c74c20f74a934c4bdf847dd393c6 diff --git a/abi/macula.h b/abi/macula.h new file mode 100644 index 0000000..743d4c0 --- /dev/null +++ b/abi/macula.h @@ -0,0 +1,303 @@ +/* + * macula.h: the C ABI over macula-go's macula 12 API, shared by every + * binding that is not written in Go (.NET, Python, PHP, TypeScript). + * + * Built from macula-go's own module: + * go build -buildmode=c-shared -o libmacula.so ./cabi (or .dylib / .dll) + * go build -buildmode=c-archive -o libmacula.a ./cabi + * + * This header is the contract; cabi/CONTRACT.md says what every call means. + * cabi's tests fail if the exported functions and this header disagree. + * A change to an existing declaration changes MACULA_ABI_VERSION; a new + * function does not, and says since which macula-go version it exists. + */ +#ifndef MACULA_H +#define MACULA_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MACULA_ABI_VERSION 1 + +/* An opaque handle to a Go value: a key, pool, subscription, served + * procedure, pending call, stream, or cancel token. 0 is never a handle. */ +typedef uintptr_t macula_handle; + +/* Stream modes. */ +#define MACULA_STREAM_SERVER 0 +#define MACULA_STREAM_CLIENT 1 +#define MACULA_STREAM_BIDI 2 + +/* ---- The library ------------------------------------------------------ */ + +/* MACULA_ABI_VERSION of the library loaded, to compare with this header's. */ +int32_t macula_abi_version(void); + +/* Free a string or byte buffer the library returned. NULL is a no-op. */ +void macula_free_string(char *s); +void macula_free_bytes(uint8_t *b); + +/* ---- Cancellation ------------------------------------------------------ */ + +/* A cancel token. Pass it to any number of blocking calls (0 = none); + * macula_cancel ends every one of them that is still running with the error + * kind "cancelled", and every later call given it at once. It is safe from + * any thread, any number of times. Free it once no call uses it any more; + * a call already running holds what it needs, so freeing early is safe. */ +macula_handle macula_cancel_new(void); +void macula_cancel(macula_handle cancel); +void macula_cancel_free(macula_handle cancel); + +/* ---- Node keys --------------------------------------------------------- */ + +/* profile is "pq_hybrid" or "pq_pure"; NULL is "pq_hybrid". */ +macula_handle macula_key_generate(const char *profile, macula_handle cancel, char **err_out); +macula_handle macula_key_load(const char *path, const char *profile, char **err_out); +/* Load the key at path, or generate one and save it there (owner-only). */ +macula_handle macula_key_load_or_create(const char *path, const char *profile, macula_handle cancel, char **err_out); +void macula_key_save(macula_handle key, const char *path, char **err_out); +void macula_key_node_id(macula_handle key, uint8_t out_node_id[32], char **err_out); +uint8_t *macula_key_public_key(macula_handle key, size_t *out_len, char **err_out); +char *macula_key_profile(macula_handle key, char **err_out); +uint8_t *macula_key_sign(macula_handle key, const uint8_t *data, size_t data_len, size_t *out_len, char **err_out); +/* 1 when signature is valid over data for public_key (as carried), else 0. */ +int32_t macula_verify(const uint8_t *data, size_t data_len, const uint8_t *signature, size_t signature_len, + const uint8_t *public_key, size_t public_key_len, const char *profile, char **err_out); +void macula_key_free(macula_handle key); + +/* ---- Signed objects (macula_signed_object) ---------------------------- */ +/* Since macula-go v0.22.0. */ + +/* Verifies object, the CBOR bytes of a signed object that carries its key + * ({key, tbs, signature}), under label and profile (NULL is "pq_hybrid"), as + * macula_signed_object:verify/3: {"node_id", "key", "tbs", "fields"}, node_id + * as hex, key and tbs as {"$bytes"}, fields the tbs map. label is + * NUL-terminated. One that does not verify is + * "unverified" with "reason" malformed, signature_invalid or alg_mismatch. */ +char *macula_signed_object_verify(const char *label, const uint8_t *object, size_t object_len, const char *profile, + char **err_out); + +/* ---- Device request proofs (realm proof v2, macula-realm#29) ----------- */ +/* Since macula-go v0.14.0. */ + +#define MACULA_REQUEST_HTTP 0 /* an HTTP body, under the realm's JSON rule */ +#define MACULA_REQUEST_MESH 1 /* a mesh payload, as it goes on the wire */ + +/* A v2 proof that key made request_json (a JSON object, its "proof" left + * out) for procedure in realm, now, with a fresh nonce: + * {"v":2,"timestamp","nonce","signature"}. A request with a "caller" field is + * invalid_argument (since macula-go v0.17.0): the caller is the signer. */ +char *macula_key_device_request_proof(macula_handle key, const uint8_t realm[32], const char *procedure, + const char *request_json, int32_t rule, char **err_out); +/* The exact bytes such a proof signs, for a given timestamp and nonce: what a + * binding checks the realm's vector with. */ +uint8_t *macula_device_request_message(const uint8_t *public_key, size_t public_key_len, const uint8_t realm[32], + const char *procedure, int64_t timestamp_ms, const uint8_t nonce[16], + const char *request_json, int32_t rule, size_t *out_len, char **err_out); + +/* ---- Ownership proofs (v2, mcl-om#7) ----------------------------------- */ +/* Since macula-go v0.16.0. */ + +/* payload_json (a JSON object) with an "asserted_by" block by which key's + * node authorises its other fields for procedure in realm, now, with a fresh + * nonce; an asserted_by already there is replaced. A payload carrying + * "caller" is refused (invalid_argument): a station replaces it with the + * caller it authenticated. Send the result as the payload. */ +char *macula_key_ownership_proof(macula_handle key, const uint8_t realm[32], const char *procedure, + const char *payload_json, char **err_out); +/* The exact bytes such a proof signs, for a given identity (node_id), + * timestamp and nonce, over fields_json: a payload, of which the fields are + * all but "asserted_by" and a text "caller", as a verifier reads a delivered + * payload. Unlike macula_key_ownership_proof it does not refuse a "caller": + * it mirrors the verifier, not the signer. What a binding checks mcl_om's + * vector with. */ +uint8_t *macula_ownership_proof_message(const uint8_t identity[32], const uint8_t realm[32], const char *procedure, + int64_t timestamp_ms, const uint8_t nonce[16], const char *fields_json, + size_t *out_len, char **err_out); + +/* ---- UCANs (macula 12, D7) --------------------------------------------- */ +/* Since macula-go v0.17.0. */ + +/* key's token for the node audience_node_id, granting caps_json (a JSON array + * of {"with","can"}, each "with" an MRI) until exp_s (Unix seconds). + * options_json (NULL for none): {"nbf","nnc","fct","prf"}, prf a list of at + * most one parent's proof id. key is an identity key. */ +char *macula_ucan_create(macula_handle key, const uint8_t audience_node_id[32], const char *caps_json, int64_t exp_s, + const char *options_json, char **err_out); +/* The proof id a child's "prf" names a token by: lowercase hex SHA-384 of its + * text. */ +char *macula_ucan_proof_id(const char *token, char **err_out); + +/* ---- Pool -------------------------------------------------------------- */ + +/* seeds_json: [{"host","port","node_id"}]. options_json (NULL for defaults): + * {"realm_trust": {"": ""}, "replication_factor", + * "max_seeds", "max_direct_links", "respawn_delay_ms", "timeout_ms", + * "kem_advertise"}. kem_advertise (0 or 1, since macula-go v0.18.0) names + * this node's KEM key in its confidential procedures' advertisements: enable + * it only once every station runs macula 12.11 or later. */ +macula_handle macula_pool_connect(macula_handle key, const char *seeds_json, const char *options_json, + macula_handle cancel, char **err_out); +/* Closes every link, subscription and served procedure; frees the handle. */ +void macula_pool_close(macula_handle pool); +void macula_pool_node_id(macula_handle pool, uint8_t out_node_id[32], char **err_out); +/* [{"station","host","port","direct","up"}] */ +char *macula_pool_status(macula_handle pool, char **err_out); +/* The pool's next event: {"kind":"link","station","direct","up","error"} or + * {"kind":"issuer_error","error"}; see "Inboxes" in CONTRACT.md. */ +char *macula_pool_events_next(macula_handle pool, int64_t timeout_ms, macula_handle cancel, int32_t *closed, + char **err_out); + +/* ---- Calls ------------------------------------------------------------- */ + +/* provider_node_id NULL: any provider the realm trusts. Returns the result. */ +char *macula_pool_call(macula_handle pool, const uint8_t realm[32], const char *procedure, const char *payload_json, + const uint8_t *provider_node_id, int64_t timeout_ms, macula_handle cancel, char **err_out); +/* macula_pool_call presenting a UCAN (NULL: none) and its chain's proofs, + * proofs_json a JSON array of tokens (NULL: none). A gated provider that + * refuses it answers a provider error of code "unauthorized", or + * "malformed_frame" for a proof no token in the chain names. Since macula-go + * v0.17.0. */ +char *macula_pool_call_with(macula_handle pool, const uint8_t realm[32], const char *procedure, + const char *payload_json, const uint8_t *provider_node_id, const char *ucan, + const char *proofs_json, int64_t timeout_ms, macula_handle cancel, char **err_out); +/* A call with its options as JSON (NULL for none): {"provider": "", "ucan", "proofs": [...], "confidential": "preferred"|"required"}. + * It is sealed whenever the provider's advertisement names a KEM key; "off" is + * refused (invalid_argument): only an advertisement naming no key is called in + * the clear. A call that cannot be kept confidential fails with the kind + * "confidentiality". Since macula-go v0.18.0. "report": 1 (0 is the default) + * asks for the call's seal report: the reply is then {"result": , + * "sealed": 0|1, "provider": "", "seal_key_id": "<16 hex>"}, + * "seal_key_id" only when sealed. Since macula-go v0.19.0. */ +char *macula_pool_call_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, const char *payload_json, + const char *options_json, int64_t timeout_ms, macula_handle cancel, char **err_out); +/* [{"node","station"}], freshest first. */ +char *macula_pool_providers(macula_handle pool, const uint8_t realm[32], const char *procedure, int64_t timeout_ms, + macula_handle cancel, char **err_out); + +/* ---- Publish / subscribe ----------------------------------------------- */ + +/* ttl_ms 0: the default (10 minutes). */ +void macula_pool_publish(macula_handle pool, const uint8_t realm[32], const char *topic, const char *payload_json, + int64_t ttl_ms, char **err_out); +macula_handle macula_pool_subscribe(macula_handle pool, const uint8_t realm[32], const char *topic, char **err_out); +/* {"publisher","realm","topic","seq","published_at","payload","delivered_via"} */ +char *macula_subscription_next(macula_handle subscription, int64_t timeout_ms, macula_handle cancel, + int32_t *closed, char **err_out); +/* Events the pool dropped because the inbox was full. */ +uint64_t macula_subscription_dropped(macula_handle subscription, char **err_out); +void macula_subscription_stop(macula_handle subscription); + +/* ---- Serving ----------------------------------------------------------- */ + +/* procedure is "~/" (the node's own namespace) or + * "/" under an org that delegated it to this node. */ +macula_handle macula_pool_serve(macula_handle pool, const uint8_t realm[32], const char *procedure, char **err_out); +macula_handle macula_pool_serve_stream(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, char **err_out); +/* Serve a procedure only to callers whose UCAN policy_json accepts: + * {"kind":"ucan_required","issuer":""} (a chain rooted at that + * node's identity key) or {"kind":"realm_member_required","key_id":"", + * "can"} (rooted at that realm key, granting that can). Refused calls and + * opens never reach the inbox. Since macula-go v0.17.0. */ +macula_handle macula_pool_serve_gated(macula_handle pool, const uint8_t realm[32], const char *procedure, + const char *policy_json, char **err_out); +macula_handle macula_pool_serve_stream_gated(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, const char *policy_json, char **err_out); +/* Serve with options as JSON (NULL for none): {"policy": , + * "confidential": "preferred"|"required"|"off"}. Since macula-go v0.18.0. */ +macula_handle macula_pool_serve_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, + const char *options_json, char **err_out); +macula_handle macula_pool_serve_stream_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, const char *options_json, char **err_out); +/* The next call (*out_item: a pending call) or stream session (*out_item: a + * stream), and its request {"caller","realm","procedure","payload","deadline_ms", + * "sealed"} ("sealed" 0 or 1, since macula-go v0.18.0). */ +char *macula_served_next(macula_handle served, int64_t timeout_ms, macula_handle cancel, macula_handle *out_item, + int32_t *closed, char **err_out); +/* Answer a pending call, once: with a result, or with an error the caller + * receives as a provider error of code "handler_error" and detail message. + * An answer after the call's deadline is "answered". The handle ends with its + * first answer, or when its procedure stops; it is never freed by the caller. */ +void macula_pending_reply(macula_handle pending, const char *result_json, char **err_out); +void macula_pending_error(macula_handle pending, const char *message, char **err_out); +/* Withdraws the procedure everywhere; frees the handle. */ +void macula_served_stop(macula_handle served, char **err_out); + +/* ---- Streams ----------------------------------------------------------- */ + +/* deadline_ms: how far ahead the open's signed deadline lies (30 s when 0), + * which bounds the provider's admission, not the stream's life; timeout_ms: + * to open it. */ +macula_handle macula_pool_open_stream(macula_handle pool, const uint8_t realm[32], const char *procedure, int32_t mode, + const char *payload_json, const uint8_t *provider_node_id, int64_t deadline_ms, + int64_t timeout_ms, macula_handle cancel, char **err_out); +/* macula_pool_open_stream presenting a UCAN and its chain's proofs, as + * macula_pool_call_with does; a gated provider refuses one with a stream + * error. Since macula-go v0.17.0. */ +macula_handle macula_pool_open_stream_with(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, const char *payload_json, const uint8_t *provider_node_id, + const char *ucan, const char *proofs_json, int64_t deadline_ms, + int64_t timeout_ms, macula_handle cancel, char **err_out); +/* macula_pool_open_stream with options as JSON, macula_pool_call_opts' + * set. Since macula-go v0.18.0. */ +macula_handle macula_pool_open_stream_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, const char *payload_json, const char *options_json, + int64_t deadline_ms, int64_t timeout_ms, macula_handle cancel, + char **err_out); +char *macula_stream_request(macula_handle stream, char **err_out); +/* A caller stream's seal report, {"sealed": 0|1, "provider": "", + * "seal_key_id": "<16 hex>"} ("seal_key_id" only when sealed), once it has + * settled on the provider's first data or reply opened under the stream's key + * (on a clear stream, its first data, reply or end). Before that, and on a + * stream that ended first, it fails with the kind "not_settled"; on a served + * stream with "not_a_caller". Since macula-go v0.19.0. */ +char *macula_stream_report(macula_handle stream, char **err_out); +void macula_stream_send_bytes(macula_handle stream, const uint8_t *data, size_t data_len, char **err_out); +void macula_stream_send_json(macula_handle stream, const char *value_json, char **err_out); +void macula_stream_close_send(macula_handle stream, char **err_out); +void macula_stream_reply(macula_handle stream, const char *payload_json, char **err_out); +void macula_stream_abort(macula_handle stream, const char *code, const char *message, char **err_out); +void macula_stream_close(macula_handle stream, char **err_out); +/* {"kind":"data","encoding","body"} | {"kind":"end","role"} | + * {"kind":"reply","payload"} | {"kind":"eof"} | + * {"kind":"error","code","message","relay"}; timeout_ms 0 waits for ever. */ +char *macula_stream_recv(macula_handle stream, int64_t timeout_ms, macula_handle cancel, char **err_out); +/* Aborts the stream first when it has not ended. */ +void macula_stream_free(macula_handle stream); + +/* ---- Content (node-served, D27) ---------------------------------------- */ + +void macula_pool_share_content(macula_handle pool, const uint8_t realm[32], const uint8_t *data, size_t data_len, + const char *name, int64_t timeout_ms, macula_handle cancel, uint8_t out_mcid[50], + char **err_out); +void macula_pool_unshare_content(macula_handle pool, const uint8_t realm[32], const uint8_t mcid[50], + int64_t timeout_ms, macula_handle cancel, char **err_out); +/* options_json (NULL for defaults): {"max_bytes","max_chunks","parallel","chunk_timeout_ms"}. */ +uint8_t *macula_pool_get_content(macula_handle pool, const uint8_t realm[32], const uint8_t mcid[50], + const char *options_json, int64_t timeout_ms, macula_handle cancel, size_t *out_len, + char **err_out); + +/* ---- DHT records ------------------------------------------------------- */ + +/* {"type","key_id","created_at","expires_at","payload","wire"} */ +char *macula_pool_find_record(macula_handle pool, const uint8_t key[32], int64_t timeout_ms, macula_handle cancel, + char **err_out); +/* {"records": [...], "dropped"} */ +char *macula_pool_find_records(macula_handle pool, const uint8_t key[32], int64_t timeout_ms, macula_handle cancel, + char **err_out); +char *macula_pool_find_records_by_type(macula_handle pool, int32_t record_type, int64_t timeout_ms, + macula_handle cancel, char **err_out); +void macula_pool_put_record(macula_handle pool, const uint8_t *wire, size_t wire_len, int64_t timeout_ms, + macula_handle cancel, char **err_out); + +#ifdef __cplusplus +} +#endif + +#endif /* MACULA_H */ diff --git a/cabi/.gitignore b/cabi/.gitignore deleted file mode 100644 index acce6c6..0000000 --- a/cabi/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -libmacula.so -libmacula.h -testc/smoke -/cabi diff --git a/cabi/cmd/teststation/main.go b/cabi/cmd/teststation/main.go deleted file mode 100644 index 22c08b1..0000000 --- a/cabi/cmd/teststation/main.go +++ /dev/null @@ -1,94 +0,0 @@ -// Command teststation runs in-process macula 12 stations for the PHP -// tests: two stations sharing one DHT, and a test realm with one org. It -// prints one JSON line, {stations: [{host, port, node_id}], realm_id, -// realm_key, org}, then reads commands on stdin until it closes: -// -// admit the org delegates its procedures to that node -// relayed how many streams the stations relay now -// -// answering each with one line. It exits when stdin closes. -package main - -import ( - "bufio" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/macula-io/macula-go/profile" - "github.com/macula-io/macula-go/teststation" -) - -// helperT is teststation.T for a process rather than a test: failures go to -// stderr, a fatal one ends the process, and cleanups run at exit. -type helperT struct{ cleanups []func() } - -func (t *helperT) Helper() {} -func (t *helperT) Errorf(format string, args ...any) { - fmt.Fprintf(os.Stderr, "teststation: "+format+"\n", args...) -} -func (t *helperT) Fatalf(format string, args ...any) { - t.Errorf(format, args...) - t.run() - os.Exit(1) -} -func (t *helperT) Cleanup(f func()) { t.cleanups = append(t.cleanups, f) } -func (t *helperT) run() { - for i := len(t.cleanups) - 1; i >= 0; i-- { - t.cleanups[i]() - } -} - -func main() { - t := &helperT{} - defer t.run() - p := profile.PQPure - if len(os.Args) > 1 { - parsed, err := profile.Parse(os.Args[1]) - if err != nil { - t.Fatalf("%v", err) - } - p = parsed - } - stations := []*teststation.Station{teststation.Start(t, p, "php a"), teststation.Start(t, p, "php b")} - teststation.ShareDHT(stations...) - realm := teststation.NewRealm(t, p, "macula-php tests", "mcl-php") - type station struct { - Host string `json:"host"` - Port uint16 `json:"port"` - NodeID string `json:"node_id"` - } - out := struct { - Stations []station `json:"stations"` - RealmID string `json:"realm_id"` - RealmKey string `json:"realm_key"` - Org string `json:"org"` - }{RealmID: hex.EncodeToString(realm.ID[:]), RealmKey: hex.EncodeToString(realm.RealmKey()), Org: realm.Org} - for _, s := range stations { - out.Stations = append(out.Stations, station{Host: s.Host, Port: s.Port, NodeID: hex.EncodeToString(s.NodeID[:])}) - } - line, _ := json.Marshal(out) - fmt.Println(string(line)) - in := bufio.NewScanner(os.Stdin) - for in.Scan() { - fields := strings.Fields(in.Text()) - switch { - case len(fields) == 2 && fields[0] == "admit": - raw, err := hex.DecodeString(fields[1]) - if err != nil || len(raw) != 32 { - fmt.Println("error bad node_id") - continue - } - var node [32]byte - copy(node[:], raw) - realm.Admit(t, stations[0], node) - fmt.Println("admitted", fields[1]) - case len(fields) == 1 && fields[0] == "relayed": - fmt.Println("relayed", stations[0].Relayed()+stations[1].Relayed()) - default: - fmt.Println("error unknown command") - } - } -} diff --git a/cabi/content.go b/cabi/content.go deleted file mode 100644 index 51c631a..0000000 --- a/cabi/content.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -/* -#include -#include -*/ -import "C" - -import ( - "errors" - "time" - - "github.com/macula-io/macula-go/manifest" - "github.com/macula-io/macula-go/pool" -) - -// Node-served content (macula 12.6.0, D27), over macula-go's pool: a node -// shares content on its own ~/content_v1 and announces it; a fetch -// finds the announcements, dials each sharer and checks everything against -// the content id. - -// mcidOf is a 50-byte content id, or false. -func mcidOf(p *C.uchar, n C.size_t) (manifest.Mcid, bool) { - var mcid manifest.Mcid - if int(n) != len(mcid) { - return mcid, false - } - copy(mcid[:], goBytes(p, n)) - return mcid, true -} - -var errNotAContentID = errors.New("macula-php/cabi: a content id is 50 bytes") - -// macula_pool_share_content keeps data, serves it in realm and announces it, -// and returns its content id (50 bytes, in *outLen). -// -//export macula_pool_share_content -func macula_pool_share_content(h C.uintptr_t, realm32 *C.uchar, data *C.uchar, dataLen C.size_t, name *C.char, - timeoutMs C.int64_t, outLen *C.size_t, errOut **C.char) *C.uchar { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - realm, _ := id32(realm32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - mcid, err := p.ShareContent(ctx, realm, goBytes(data, dataLen), C.GoString(name)) - if err != nil { - setErr(errOut, err) - return nil - } - return cBytes(mcid[:], outLen) -} - -// macula_pool_unshare_content stops sharing a content id in realm and -// withdraws its announcement. -// -//export macula_pool_unshare_content -func macula_pool_unshare_content(h C.uintptr_t, realm32 *C.uchar, mcidPtr *C.uchar, mcidLen C.size_t, timeoutMs C.int64_t, errOut **C.char) { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - mcid, ok := mcidOf(mcidPtr, mcidLen) - if !ok { - setErr(errOut, errNotAContentID) - return - } - realm, _ := id32(realm32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - setErr(errOut, p.UnshareContent(ctx, realm, mcid)) -} - -// macula_pool_get_content fetches a content id in realm and returns its bytes -// (in *outLen), within the bounds given (0 for each default). Content nobody -// shares is the error "not_shared"; content every sharer failed to give is -// "unavailable:" followed by each failure. -// -//export macula_pool_get_content -func macula_pool_get_content(h C.uintptr_t, realm32 *C.uchar, mcidPtr *C.uchar, mcidLen C.size_t, maxBytes C.uint64_t, - maxChunks C.int, parallel C.int, chunkTimeoutMs C.int64_t, timeoutMs C.int64_t, outLen *C.size_t, errOut **C.char) *C.uchar { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - mcid, ok := mcidOf(mcidPtr, mcidLen) - if !ok { - setErr(errOut, errNotAContentID) - return nil - } - realm, _ := id32(realm32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - data, err := p.GetContent(ctx, realm, mcid, pool.ContentOptions{MaxBytes: uint64(maxBytes), MaxChunks: int(maxChunks), - Parallel: int(parallel), ChunkTimeout: time.Duration(chunkTimeoutMs) * time.Millisecond}) - switch { - case errors.Is(err, pool.ErrContentUnavailable): - setErr(errOut, errors.New("unavailable:"+err.Error())) - return nil - case errors.Is(err, pool.ErrNotShared): - setErr(errOut, errors.New("not_shared")) - return nil - case err != nil: - setErr(errOut, err) - return nil - } - return cBytes(data, outLen) -} diff --git a/cabi/go.mod b/cabi/go.mod deleted file mode 100644 index d2baa76..0000000 --- a/cabi/go.mod +++ /dev/null @@ -1,13 +0,0 @@ -module github.com/macula-io/macula-php/cabi - -go 1.27.0 - -require github.com/macula-io/macula-go v0.12.0 - -require ( - github.com/google/uuid v1.6.0 // indirect - github.com/quic-go/quic-go v0.62.0 // indirect - golang.org/x/crypto v0.56.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sys v0.47.0 // indirect -) diff --git a/cabi/go.sum b/cabi/go.sum deleted file mode 100644 index 6371f9d..0000000 --- a/cabi/go.sum +++ /dev/null @@ -1,20 +0,0 @@ -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/macula-io/macula-go v0.12.0 h1:zn3ppxBZSb1QZy+Qsfstwq2IPWHVNLg6wtEYhOMk3+Y= -github.com/macula-io/macula-go v0.12.0/go.mod h1:mRMXty8IIDeuUgaaU8bAVWde6g94REj7GMeDBVr3mMM= -github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= -github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= -github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/cabi/inbox.go b/cabi/inbox.go deleted file mode 100644 index f6df6ab..0000000 --- a/cabi/inbox.go +++ /dev/null @@ -1,113 +0,0 @@ -package main - -// #include -import "C" - -import ( - "context" - "sync" - "time" -) - -// PHP cannot take a callback on a Go thread: PHP's FFI runs a C callback on -// the thread that calls it, and the engine is not safe to enter from another. -// So where macula-go hands something over asynchronously (a subscription's -// events, a served procedure's calls, a served stream's sessions), it goes -// into an inbox owned by the handle, and PHP takes it with a *_next call that -// waits at most a given time. - -// inboxItem is one thing handed over: its JSON, and the handle it comes with -// (a pending call or a stream), or 0. -type inboxItem struct { - handle uintptr - json string -} - -// inboxState is what a next call found. -type inboxState int - -const ( - inboxItemReady inboxState = iota // an item was taken - inboxTimeout // nothing arrived within the wait - inboxClosed // the inbox ended and holds nothing more -) - -// inbox is a bounded queue with an end. A pusher waits while it is full, until -// its own context ends or the inbox closes; a closed inbox is drained before -// next reports that it ended. -type inbox struct { - items chan inboxItem - done chan struct{} - once sync.Once -} - -func newInbox(capacity int) *inbox { - return &inbox{items: make(chan inboxItem, capacity), done: make(chan struct{})} -} - -// push hands item over, and reports false when it could not: the inbox closed, -// or ctx ended while the inbox was full. -func (q *inbox) push(ctx context.Context, item inboxItem) bool { - select { - case <-q.done: - return false - default: - } - select { - case q.items <- item: - return true - case <-q.done: - return false - case <-ctx.Done(): - return false - } -} - -// next takes the next item, waiting at most wait. A closed inbox still hands -// over what it holds first. -func (q *inbox) next(wait time.Duration) (inboxItem, inboxState) { - select { - case item := <-q.items: - return item, inboxItemReady - default: - } - timer := time.NewTimer(wait) - defer timer.Stop() - select { - case item := <-q.items: - return item, inboxItemReady - case <-q.done: - select { - case item := <-q.items: - return item, inboxItemReady - default: - return inboxItem{}, inboxClosed - } - case <-timer.C: - return inboxItem{}, inboxTimeout - } -} - -// close ends the inbox; it is safe to call more than once. -func (q *inbox) close() { q.once.Do(func() { close(q.done) }) } - -// inboxCapacity is how many events, calls or sessions wait for PHP to take -// them before a pusher waits: a served call then waits until its deadline. -const inboxCapacity = 256 - -// handedOver turns what next found into the C result: the item's JSON (and its -// handle through handleOut, when given), or NULL with *closed telling an end -// from a timeout. -func handedOver(item inboxItem, state inboxState, handleOut *C.uintptr_t, closed *C.int) *C.char { - *closed = 0 - switch state { - case inboxItemReady: - if handleOut != nil { - *handleOut = C.uintptr_t(item.handle) - } - return C.CString(item.json) - case inboxClosed: - *closed = 1 - } - return nil -} diff --git a/cabi/inbox_test.go b/cabi/inbox_test.go deleted file mode 100644 index bf50abf..0000000 --- a/cabi/inbox_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "context" - "testing" - "time" -) - -// An item pushed is the next one taken, with its handle. -func TestAnInboxHandsOverWhatWasPushed(t *testing.T) { - q := newInbox(4) - if !q.push(context.Background(), inboxItem{handle: 7, json: `{"n":1}`}) { - t.Fatal("push refused on an open inbox") - } - item, state := q.next(100 * time.Millisecond) - if state != inboxItemReady || item.handle != 7 || item.json != `{"n":1}` { - t.Fatalf("next = %+v, %v", item, state) - } -} - -// Nothing pushed within the wait is a timeout, not an end. -func TestAnEmptyInboxTimesOut(t *testing.T) { - q := newInbox(4) - start := time.Now() - if _, state := q.next(50 * time.Millisecond); state != inboxTimeout { - t.Fatalf("state = %v, want timeout", state) - } - if time.Since(start) < 40*time.Millisecond { - t.Fatal("next returned before its wait was up") - } -} - -// A closed inbox still hands over what it holds, then says it has ended. -func TestAClosedInboxDrainsThenEnds(t *testing.T) { - q := newInbox(4) - q.push(context.Background(), inboxItem{json: "a"}) - q.close() - if item, state := q.next(time.Second); state != inboxItemReady || item.json != "a" { - t.Fatalf("first next after close = %+v, %v", item, state) - } - if _, state := q.next(time.Second); state != inboxClosed { - t.Fatalf("state = %v, want closed", state) - } - if q.push(context.Background(), inboxItem{json: "b"}) { - t.Fatal("push accepted on a closed inbox") - } -} - -// A full inbox makes the pusher wait, and a pusher whose context ends gives up: -// a served call nobody takes is answered at its deadline, not held forever. -func TestAPushIntoAFullInboxGivesUpWithItsContext(t *testing.T) { - q := newInbox(1) - q.push(context.Background(), inboxItem{json: "fills it"}) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - if q.push(ctx, inboxItem{json: "waits"}) { - t.Fatal("push into a full inbox succeeded") - } -} - -// Closing wakes a pusher waiting on a full inbox. -func TestClosingReleasesAWaitingPusher(t *testing.T) { - q := newInbox(1) - q.push(context.Background(), inboxItem{json: "fills it"}) - done := make(chan bool) - go func() { done <- q.push(context.Background(), inboxItem{json: "waits"}) }() - time.Sleep(20 * time.Millisecond) - q.close() - select { - case ok := <-done: - if ok { - t.Fatal("a push released by close reported success") - } - case <-time.After(time.Second): - t.Fatal("close did not release the waiting pusher") - } -} diff --git a/cabi/main.go b/cabi/main.go deleted file mode 100644 index 6c1ad4f..0000000 --- a/cabi/main.go +++ /dev/null @@ -1,247 +0,0 @@ -// Package main is macula-php's C ABI over macula-go's macula 12 API: node -// keys, a pool of station links, calls and streams by direct dial, serving, -// publish/subscribe, content and the DHT. It is built as a shared library -// (-buildmode=c-shared, libmacula.so) that src/Binding.php loads with PHP's FFI. -// -// Handles: every Go value that crosses the boundary (a key, a pool, a -// subscription, a served procedure, a pending call, a stream) is a -// runtime/cgo.Handle, an opaque uintptr the PHP side holds and gives -// back, and frees with the matching _free or _stop when done. A handle this -// process never issued, or already freed, is refused as invalid rather than -// panicking (cgo.Handle.Value panics on one). -// -// Errors: a function that can fail takes a char** err_out. On failure it -// mallocs a C string into *err_out and returns a zero value; the caller frees -// it with macula_free_string. On success *err_out is untouched. -// -// Payloads cross as JSON text, converted to and from cbor.Value by -// wirevalue.go: no booleans, and bytes as {"$bytes": ""} going in and -// either "0x..." hex or the tagged form coming out, as the caller asks. -// -// Every function that does network I/O blocks the calling PHP thread. PHP -// takes no callback on a Go thread, so what arrives on its own (events, served -// calls and sessions) waits in an inbox that a *_next call drains (inbox.go). -package main - -/* -#include -#include -*/ -import "C" - -import ( - "errors" - "runtime/cgo" - "unsafe" - - "github.com/macula-io/macula-go/identity" - "github.com/macula-io/macula-go/profile" -) - -var errInvalidHandle = errors.New("macula-php/cabi: invalid handle") - -func setErr(errOut **C.char, err error) { - if errOut == nil || err == nil { - return - } - *errOut = C.CString(err.Error()) -} - -// valueOf resolves a handle to a value of type T, or ok false for a handle -// this process never issued, one already freed, or one of another type. -func valueOf[T any](h C.uintptr_t) (v T, ok bool) { - defer func() { - if recover() != nil { - ok = false - } - }() - if h == 0 { - return v, false - } - v, ok = cgo.Handle(h).Value().(T) - return v, ok -} - -func newHandle(v any) C.uintptr_t { return C.uintptr_t(cgo.NewHandle(v)) } - -// release frees a handle, ignoring one already freed or never issued. -func release(h C.uintptr_t) { - defer func() { _ = recover() }() - if h != 0 { - cgo.Handle(h).Delete() - } -} - -func goBytes(p *C.uchar, n C.size_t) []byte { - if p == nil || n == 0 { - return nil - } - return C.GoBytes(unsafe.Pointer(p), C.int(n)) -} - -func id32(p *C.uchar) ([32]byte, bool) { - var out [32]byte - if p == nil { - return out, false - } - copy(out[:], C.GoBytes(unsafe.Pointer(p), 32)) - return out, true -} - -// cBytes mallocs a copy of b for the caller, who frees it with -// macula_free_bytes, and stores its length in *outLen. -func cBytes(b []byte, outLen *C.size_t) *C.uchar { - *outLen = C.size_t(len(b)) - if len(b) == 0 { - return nil - } - return (*C.uchar)(C.CBytes(b)) -} - -//export macula_free_string -func macula_free_string(s *C.char) { C.free(unsafe.Pointer(s)) } - -//export macula_free_bytes -func macula_free_bytes(b *C.uchar) { C.free(unsafe.Pointer(b)) } - -func parseProfile(name *C.char) (profile.Profile, error) { - if name == nil { - return profile.PQHybrid, nil - } - return profile.Parse(C.GoString(name)) -} - -// macula_key_generate makes a node identity key in profile ("pq_hybrid" or -// "pq_pure", pq_hybrid when NULL) whose node_id solves the admission puzzle. -// It takes a second or so. -// -//export macula_key_generate -func macula_key_generate(profileName *C.char, errOut **C.char) C.uintptr_t { - p, err := parseProfile(profileName) - if err != nil { - setErr(errOut, err) - return 0 - } - key, err := identity.GenerateIdentityKey(p, identity.PuzzleDifficulty) - if err != nil { - setErr(errOut, err) - return 0 - } - return newHandle(key) -} - -// macula_key_load reads the identity key file at path in profile. A file the -// user's group or others can read, or that holds a key of another purpose or -// profile, is refused. -// -//export macula_key_load -func macula_key_load(path, profileName *C.char, errOut **C.char) C.uintptr_t { - p, err := parseProfile(profileName) - if err != nil { - setErr(errOut, err) - return 0 - } - key, err := identity.LoadKey(C.GoString(path), identity.PurposeIdentity, p) - if err != nil { - setErr(errOut, err) - return 0 - } - return newHandle(key) -} - -// macula_key_save writes the key to path, readable by its owner only. -// -//export macula_key_save -func macula_key_save(h C.uintptr_t, path *C.char, errOut **C.char) { - key, ok := valueOf[*identity.NodeKey](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - setErr(errOut, key.Save(C.GoString(path))) -} - -// macula_key_node_id writes the key's 32-byte node_id to out32. -// -//export macula_key_node_id -func macula_key_node_id(h C.uintptr_t, out32 *C.uchar, errOut **C.char) { - key, ok := valueOf[*identity.NodeKey](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - id, err := key.NodeID() - if err != nil { - setErr(errOut, err) - return - } - copy(unsafe.Slice((*byte)(unsafe.Pointer(out32)), 32), id[:]) -} - -// macula_key_public_key is the key as carried on the wire. -// -//export macula_key_public_key -func macula_key_public_key(h C.uintptr_t, outLen *C.size_t, errOut **C.char) *C.uchar { - key, ok := valueOf[*identity.NodeKey](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - return cBytes(key.PublicKey(), outLen) -} - -// macula_key_profile is the key's profile name, freed with -// macula_free_string. -// -//export macula_key_profile -func macula_key_profile(h C.uintptr_t, errOut **C.char) *C.char { - key, ok := valueOf[*identity.NodeKey](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - return C.CString(string(key.Profile())) -} - -// macula_key_sign signs data with the key, as it is given. -// -//export macula_key_sign -func macula_key_sign(h C.uintptr_t, data *C.uchar, dataLen C.size_t, outLen *C.size_t, errOut **C.char) *C.uchar { - key, ok := valueOf[*identity.NodeKey](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - signature, err := key.Sign(goBytes(data, dataLen)) - if err != nil { - setErr(errOut, err) - return nil - } - return cBytes(signature, outLen) -} - -// macula_verify reports whether signature is valid over data for a public key -// as carried, under profile ("pq_hybrid" or "pq_pure", pq_hybrid when NULL): -// 1 when it is, 0 when it is not or anything is malformed. A profile it does -// not know is an error. -// -//export macula_verify -func macula_verify(data *C.uchar, dataLen C.size_t, signature *C.uchar, signatureLen C.size_t, - publicKey *C.uchar, publicKeyLen C.size_t, profileName *C.char, errOut **C.char) C.int { - p, err := parseProfile(profileName) - if err != nil { - setErr(errOut, err) - return 0 - } - if identity.Verify(goBytes(data, dataLen), goBytes(signature, signatureLen), goBytes(publicKey, publicKeyLen), p) { - return 1 - } - return 0 -} - -// macula_key_free frees the key's handle. -// -//export macula_key_free -func macula_key_free(h C.uintptr_t) { release(h) } - -func main() {} diff --git a/cabi/pool.go b/cabi/pool.go deleted file mode 100644 index 41431b3..0000000 --- a/cabi/pool.go +++ /dev/null @@ -1,478 +0,0 @@ -package main - -/* -#include -#include -*/ -import "C" - -import ( - "context" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - "unsafe" - - "github.com/macula-io/macula-go/cbor" - "github.com/macula-io/macula-go/identity" - "github.com/macula-io/macula-go/pool" - "github.com/macula-io/macula-go/record" - "github.com/macula-io/macula-go/stationlink" -) - -// connectSeed is one seed as the PHP side gives it. -type connectSeed struct { - Host string `json:"host"` - Port uint16 `json:"port"` - NodeID string `json:"node_id"` -} - -// connectOptions are the pool options the PHP side gives. Realm keys -// are hex, keyed by the realm id in hex. -type connectOptions struct { - RealmTrust map[string]string `json:"realm_trust"` - ReplicationFactor int `json:"replication_factor"` - MaxDirectLinks int `json:"max_direct_links"` - RespawnDelayMs int64 `json:"respawn_delay_ms"` - TimeoutMs int64 `json:"timeout_ms"` -} - -func hexID(name, text string) ([32]byte, error) { - var out [32]byte - raw, err := hex.DecodeString(text) - if err != nil || len(raw) != 32 { - return out, fmt.Errorf("macula-php/cabi: %s must be 64 hex characters", name) - } - copy(out[:], raw) - return out, nil -} - -// macula_pool_connect connects a pool of the key's node to seeds (a JSON list -// of {host, port, node_id}) with options (JSON, see connectOptions), and -// returns once one link is up, or fails within timeout_ms (30 s by default). -// -//export macula_pool_connect -func macula_pool_connect(keyHandle C.uintptr_t, seedsJSON, optsJSON *C.char, errOut **C.char) C.uintptr_t { - key, ok := valueOf[*identity.NodeKey](keyHandle) - if !ok { - setErr(errOut, errInvalidHandle) - return 0 - } - var seeds []connectSeed - if err := json.Unmarshal([]byte(C.GoString(seedsJSON)), &seeds); err != nil { - setErr(errOut, fmt.Errorf("macula-php/cabi: seeds: %w", err)) - return 0 - } - var opts connectOptions - if optsJSON != nil && C.GoString(optsJSON) != "" { - if err := json.Unmarshal([]byte(C.GoString(optsJSON)), &opts); err != nil { - setErr(errOut, fmt.Errorf("macula-php/cabi: options: %w", err)) - return 0 - } - } - poolSeeds := make([]pool.Seed, len(seeds)) - for i, s := range seeds { - id, err := hexID("a seed's node_id", s.NodeID) - if err != nil { - setErr(errOut, err) - return 0 - } - poolSeeds[i] = pool.Seed{Host: s.Host, Port: s.Port, NodeID: id} - } - trust := map[[32]byte][]byte{} - for realmHex, keyHex := range opts.RealmTrust { - realm, err := hexID("a realm id", realmHex) - if err != nil { - setErr(errOut, err) - return 0 - } - realmKey, err := hex.DecodeString(keyHex) - if err != nil { - setErr(errOut, fmt.Errorf("macula-php/cabi: realm key: %w", err)) - return 0 - } - trust[realm] = realmKey - } - timeout := 30 * time.Second - if opts.TimeoutMs > 0 { - timeout = time.Duration(opts.TimeoutMs) * time.Millisecond - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - p, err := pool.Connect(ctx, poolSeeds, pool.Opts{IdentityKey: key, RealmTrust: trust, - ReplicationFactor: opts.ReplicationFactor, MaxDirectLinks: opts.MaxDirectLinks, - RespawnDelay: time.Duration(opts.RespawnDelayMs) * time.Millisecond}) - if err != nil { - setErr(errOut, err) - return 0 - } - return newHandle(p) -} - -// macula_pool_close closes every link and subscription, and frees the handle. -// -//export macula_pool_close -func macula_pool_close(h C.uintptr_t) { - if p, ok := valueOf[*pool.Pool](h); ok { - _ = p.Close() - } - release(h) -} - -// macula_pool_node_id writes the pool's node_id to out32. -// -//export macula_pool_node_id -func macula_pool_node_id(h C.uintptr_t, out32 *C.uchar, errOut **C.char) { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - id := p.NodeID() - copy(unsafe.Slice((*byte)(unsafe.Pointer(out32)), 32), id[:]) -} - -// macula_pool_status is the pool's links as JSON: [{station, host, port, -// direct, up}]. -// -//export macula_pool_status -func macula_pool_status(h C.uintptr_t, errOut **C.char) *C.char { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - type link struct { - Station string `json:"station"` - Host string `json:"host"` - Port uint16 `json:"port"` - Direct bool `json:"direct"` - Up bool `json:"up"` - } - var links []link - for _, s := range p.Status() { - links = append(links, link{Station: hex.EncodeToString(s.Station[:]), Host: s.Host, Port: s.Port, Direct: s.Direct, Up: s.Up}) - } - out, _ := json.Marshal(links) - return C.CString(string(out)) -} - -func withTimeout(timeoutMs C.int64_t) (context.Context, context.CancelFunc) { - if timeoutMs <= 0 { - return context.WithCancel(context.Background()) - } - return context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) -} - -func encodeJSON(v cbor.Value, mode C.int, errOut **C.char) *C.char { - m, err := parseBytesOutput(int(mode)) - if err != nil { - setErr(errOut, err) - return nil - } - out, err := json.Marshal(cborToJSON(v, m)) - if err != nil { - setErr(errOut, err) - return nil - } - return C.CString(string(out)) -} - -// callError renders a call's failure so the PHP side can tell its -// kinds apart: "provider_error::", "relay_error:", or the -// error's text. -func callError(err error) error { - var provider *stationlink.ProviderError - var relay *stationlink.RelayError - switch { - case errors.As(err, &provider): - detail := "" - if provider.Detail != nil { - detail = *provider.Detail - } - return fmt.Errorf("provider_error:%s:%s", provider.Code, detail) - case errors.As(err, &relay): - return fmt.Errorf("relay_error:%s", relay.Code) - } - return err -} - -// macula_pool_call calls procedure in realm at a provider, any trusted one when -// provider32 is NULL, by direct dial, and returns its result as JSON. -// -//export macula_pool_call -func macula_pool_call(h C.uintptr_t, realm32 *C.uchar, procedure, payloadJSON *C.char, provider32 *C.uchar, - timeoutMs C.int64_t, bytesMode C.int, errOut **C.char) *C.char { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - realm, _ := id32(realm32) - provider, _ := id32(provider32) - payload, err := jsonToCbor(C.GoString(payloadJSON)) - if err != nil { - setErr(errOut, err) - return nil - } - result, err := p.Call(context.Background(), pool.Call{Realm: realm, Procedure: C.GoString(procedure), - Provider: provider, Payload: payload, Timeout: time.Duration(timeoutMs) * time.Millisecond}) - if err != nil { - setErr(errOut, callError(err)) - return nil - } - return encodeJSON(result, bytesMode, errOut) -} - -// macula_pool_providers is JSON of the procedure's trusted providers: -// [{node, station}], freshest first. -// -//export macula_pool_providers -func macula_pool_providers(h C.uintptr_t, realm32 *C.uchar, procedure *C.char, timeoutMs C.int64_t, errOut **C.char) *C.char { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - realm, _ := id32(realm32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - providers, err := p.Providers(ctx, realm, C.GoString(procedure)) - if err != nil { - setErr(errOut, err) - return nil - } - type entry struct { - Node string `json:"node"` - Station string `json:"station"` - } - out := make([]entry, len(providers)) - for i, pr := range providers { - out[i] = entry{Node: hex.EncodeToString(pr.Node[:]), Station: hex.EncodeToString(pr.Station[:])} - } - text, _ := json.Marshal(out) - return C.CString(string(text)) -} - -// macula_pool_publish publishes payload on topic in realm, living ttl_ms (the -// default, 10 minutes, when 0). -// -//export macula_pool_publish -func macula_pool_publish(h C.uintptr_t, realm32 *C.uchar, topic, payloadJSON *C.char, ttlMs C.int64_t, errOut **C.char) { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - realm, _ := id32(realm32) - payload, err := jsonToCbor(C.GoString(payloadJSON)) - if err != nil { - setErr(errOut, err) - return - } - publication := stationlink.Publication{Realm: realm, Topic: C.GoString(topic), Payload: payload} - if ttlMs > 0 { - ttl := uint64(ttlMs) - publication.TTLMs = &ttl - } - setErr(errOut, p.Publish(publication)) -} - -// subscription is a subscription with the inbox its events wait in. -type subscription struct { - sub *pool.Subscription - box *inbox -} - -// macula_pool_subscribe subscribes to topic in realm. Its events wait in an -// inbox, each as JSON {publisher, realm, topic, seq, published_at, payload, -// delivered_via}, until macula_subscription_next takes them. The returned -// handle is given back to macula_subscription_next and macula_subscription_stop. -// -//export macula_pool_subscribe -func macula_pool_subscribe(h C.uintptr_t, realm32 *C.uchar, topic *C.char, bytesMode C.int, - errOut **C.char) C.uintptr_t { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return 0 - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return 0 - } - realm, _ := id32(realm32) - sub, err := p.Subscribe(realm, C.GoString(topic)) - if err != nil { - setErr(errOut, err) - return 0 - } - s := &subscription{sub: sub, box: newInbox(inboxCapacity)} - go func() { - defer s.box.close() - for event := range sub.Events() { - text, err := json.Marshal(map[string]any{ - "publisher": hex.EncodeToString(event.Publisher[:]), "realm": hex.EncodeToString(event.Realm[:]), - "topic": event.Topic, "seq": event.Seq, "published_at": event.PublishedAt, - "payload": cborToJSON(event.Payload, mode), "delivered_via": event.DeliveredVia, - }) - if err != nil { - continue - } - if !s.box.push(context.Background(), inboxItem{json: string(text)}) { - return - } - } - }() - return newHandle(s) -} - -// macula_subscription_next takes the subscription's next event, waiting at -// most timeout_ms. It returns the event's JSON; or NULL with *closed set when -// the subscription has ended and holds no more; or NULL with *closed unset -// when nothing arrived in time. -// -//export macula_subscription_next -func macula_subscription_next(h C.uintptr_t, timeoutMs C.int, closed *C.int, errOut **C.char) *C.char { - s, ok := valueOf[*subscription](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - item, state := s.box.next(time.Duration(timeoutMs) * time.Millisecond) - return handedOver(item, state, nil, closed) -} - -// macula_subscription_stop ends the subscription and frees its handle. -// -//export macula_subscription_stop -func macula_subscription_stop(h C.uintptr_t) { - if s, ok := valueOf[*subscription](h); ok { - _ = s.sub.Unsubscribe() - s.box.close() - } - release(h) -} - -// recordJSON renders a verified record: its type, signer's key id, times, -// payload, and wire bytes (tagged). -func recordJSON(v record.Verified, mode bytesOutput) map[string]any { - r := v.Record() - wire, _ := record.Encode(r) - return map[string]any{ - "type": uint8(r.Type), "key_id": hex.EncodeToString(r.KeyID[:]), "created_at": r.CreatedAt, - "expires_at": r.ExpiresAt, "payload": cborToJSON(r.Payload, mode), "wire": cborToJSON(cbor.Bytes(wire), bytesTagged), - } -} - -func recordsJSON(found []record.Verified, dropped int, mode bytesOutput) *C.char { - out := make([]map[string]any, len(found)) - for i, v := range found { - out[i] = recordJSON(v, mode) - } - text, _ := json.Marshal(map[string]any{"records": out, "dropped": dropped}) - return C.CString(string(text)) -} - -// macula_pool_find_record is the verified record under key32 as JSON (see -// recordJSON), or the error "not_found". -// -//export macula_pool_find_record -func macula_pool_find_record(h C.uintptr_t, key32 *C.uchar, timeoutMs C.int64_t, bytesMode C.int, errOut **C.char) *C.char { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return nil - } - key, _ := id32(key32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - found, err := p.FindRecord(ctx, key) - if errors.Is(err, stationlink.ErrRecordNotFound) { - setErr(errOut, errors.New("not_found")) - return nil - } - if err != nil { - setErr(errOut, err) - return nil - } - text, _ := json.Marshal(recordJSON(found, mode)) - return C.CString(string(text)) -} - -// macula_pool_find_records is every verified record under key32, and how many -// did not verify, as JSON {records, dropped}. -// -//export macula_pool_find_records -func macula_pool_find_records(h C.uintptr_t, key32 *C.uchar, timeoutMs C.int64_t, bytesMode C.int, errOut **C.char) *C.char { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return nil - } - key, _ := id32(key32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - found, dropped, err := p.FindRecords(ctx, key) - if err != nil { - setErr(errOut, err) - return nil - } - return recordsJSON(found, dropped, mode) -} - -// macula_pool_find_records_by_type is every verified record of the type the -// station holds, as JSON {records, dropped}. -// -//export macula_pool_find_records_by_type -func macula_pool_find_records_by_type(h C.uintptr_t, recordType C.int, timeoutMs C.int64_t, bytesMode C.int, errOut **C.char) *C.char { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return nil - } - if recordType < 1 || recordType > 255 { - setErr(errOut, errors.New("macula-php/cabi: a record type is 1 to 255")) - return nil - } - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - found, dropped, err := p.FindRecordsByType(ctx, record.Type(recordType)) - if err != nil { - setErr(errOut, err) - return nil - } - return recordsJSON(found, dropped, mode) -} - -// macula_pool_put_record puts a signed record's wire bytes in the DHT. -// -//export macula_pool_put_record -func macula_pool_put_record(h C.uintptr_t, wire *C.uchar, wireLen C.size_t, timeoutMs C.int64_t, errOut **C.char) { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - setErr(errOut, p.PutRecord(ctx, goBytes(wire, wireLen))) -} diff --git a/cabi/serve.go b/cabi/serve.go deleted file mode 100644 index d157dca..0000000 --- a/cabi/serve.go +++ /dev/null @@ -1,425 +0,0 @@ -package main - -/* -#include -#include -*/ -import "C" - -import ( - "context" - "encoding/hex" - "encoding/json" - "errors" - "io" - "sync" - "time" - - "github.com/macula-io/macula-go/cbor" - "github.com/macula-io/macula-go/frame" - "github.com/macula-io/macula-go/pool" - "github.com/macula-io/macula-go/stationlink" -) - -// Serving: a served procedure's CALLs wait in the served handle's inbox as a -// pending-call handle and the request as JSON, until macula_served_next takes -// them; PHP answers with macula_pending_reply or macula_pending_error, once. A -// call not answered by its deadline, or never taken, is answered for it with -// an error. A streaming procedure's sessions wait there as a stream handle, -// which PHP drives and ends. - -var errAnswered = errors.New("macula-php/cabi: the call was already answered") - -// pendingCall is a served CALL waiting for its answer. -type pendingCall struct { - once sync.Once - answer chan pendingAnswer -} - -type pendingAnswer struct { - payload cbor.Value - err error -} - -func requestJSON(r stationlink.Request, mode bytesOutput) string { - text, _ := json.Marshal(map[string]any{ - "caller": hex.EncodeToString(r.Caller[:]), "realm": hex.EncodeToString(r.Realm[:]), - "procedure": r.Procedure, "payload": cborToJSON(r.Payload, mode), "deadline_ms": r.Deadline.UnixMilli(), - }) - return string(text) -} - -// served is a served procedure with the inbox its calls or sessions wait in. -type served struct { - served *pool.Served - box *inbox -} - -// macula_pool_serve serves procedure in realm with on_call: each CALL is handed -// over as a pending-call handle and the request's JSON {caller, realm, -// procedure, payload, deadline_ms}. The returned handle is given back to -// macula_served_stop. -// -//export macula_pool_serve -func macula_pool_serve(h C.uintptr_t, realm32 *C.uchar, procedure *C.char, bytesMode C.int, - errOut **C.char) C.uintptr_t { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return 0 - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return 0 - } - realm, _ := id32(realm32) - box := newInbox(inboxCapacity) - handler := func(ctx context.Context, r stationlink.Request) (cbor.Value, error) { - pending := &pendingCall{answer: make(chan pendingAnswer, 1)} - ph := newHandle(pending) - defer release(ph) - if !box.push(ctx, inboxItem{handle: uintptr(ph), json: requestJSON(r, mode)}) { - return cbor.Value{}, errors.New("not taken by its deadline") - } - select { - case answer := <-pending.answer: - return answer.payload, answer.err - case <-ctx.Done(): - return cbor.Value{}, errors.New("not answered by its deadline") - } - } - offered, err := p.Serve(context.Background(), pool.Offer{Realm: realm, Procedure: C.GoString(procedure), Handler: handler}) - if err != nil { - setErr(errOut, err) - return 0 - } - return newHandle(&served{served: offered, box: box}) -} - -// macula_served_next takes the next call (a pending-call handle) or session (a -// stream handle) of a served procedure, waiting at most timeout_ms. It returns -// the request's JSON {caller, realm, procedure, payload, deadline_ms} and sets -// *handle; or NULL with *closed set when the procedure was stopped; or NULL -// with *closed unset when nothing arrived in time. -// -//export macula_served_next -func macula_served_next(h C.uintptr_t, timeoutMs C.int, handle *C.uintptr_t, closed *C.int, - errOut **C.char) *C.char { - s, ok := valueOf[*served](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - item, state := s.box.next(time.Duration(timeoutMs) * time.Millisecond) - return handedOver(item, state, handle, closed) -} - -func answer(h C.uintptr_t, a pendingAnswer, errOut **C.char) { - pending, ok := valueOf[*pendingCall](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - sent := false - pending.once.Do(func() { - pending.answer <- a - sent = true - }) - if !sent { - setErr(errOut, errAnswered) - } -} - -// macula_pending_reply answers a served call with result (JSON). -// -//export macula_pending_reply -func macula_pending_reply(h C.uintptr_t, resultJSON *C.char, errOut **C.char) { - result, err := jsonToCbor(C.GoString(resultJSON)) - if err != nil { - setErr(errOut, err) - return - } - answer(h, pendingAnswer{payload: result}, errOut) -} - -// macula_pending_error answers a served call with a handler_error carrying -// message. -// -//export macula_pending_error -func macula_pending_error(h C.uintptr_t, message *C.char, errOut **C.char) { - answer(h, pendingAnswer{err: errors.New(C.GoString(message))}, errOut) -} - -// macula_served_stop withdraws the procedure everywhere and frees the handle. -// -//export macula_served_stop -func macula_served_stop(h C.uintptr_t, errOut **C.char) { - s, ok := valueOf[*served](h) - if !ok { - setErr(errOut, errInvalidHandle) - return - } - setErr(errOut, s.served.Stop()) - s.box.close() - release(h) -} - -func streamMode(mode C.int) (frame.StreamMode, error) { - switch frame.StreamMode(mode) { - case frame.ServerStream, frame.ClientStream, frame.Bidi: - return frame.StreamMode(mode), nil - } - return 0, errors.New("macula-php/cabi: a stream mode is 0 (server), 1 (client) or 2 (bidi)") -} - -// macula_pool_serve_stream serves procedure in realm as a stream of mode: each -// session is handed to on_stream as a stream handle and the request's JSON. -// The session lives until the PHP side ends it, or its peer does; the -// stream handle is freed with macula_stream_free. -// -//export macula_pool_serve_stream -func macula_pool_serve_stream(h C.uintptr_t, realm32 *C.uchar, procedure *C.char, mode C.int, - bytesMode C.int, errOut **C.char) C.uintptr_t { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return 0 - } - bytes, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return 0 - } - m, err := streamMode(mode) - if err != nil { - setErr(errOut, err) - return 0 - } - realm, _ := id32(realm32) - box := newInbox(inboxCapacity) - handler := func(ctx context.Context, s *stationlink.Stream) error { - sh := newHandle(s) - open := s.Request() - request := requestJSON(stationlink.Request{Caller: open.Caller, Realm: open.Realm, Procedure: open.Procedure, - Payload: open.Payload, Deadline: time.UnixMilli(int64(open.Deadline))}, bytes) - if !box.push(ctx, inboxItem{handle: uintptr(sh), json: request}) { - release(sh) - return errors.New("not taken by its deadline") - } - <-s.Done() - return nil - } - offered, err := p.Serve(context.Background(), pool.Offer{Realm: realm, Procedure: C.GoString(procedure), - Stream: &stationlink.StreamOffer{Mode: m, Handler: handler}}) - if err != nil { - setErr(errOut, err) - return 0 - } - return newHandle(&served{served: offered, box: box}) -} - -// macula_pool_open_stream opens a stream of mode on procedure in realm at a -// provider (any trusted one when provider32 is NULL), with payload as the -// open's, its deadline deadline_ms ahead (30 s when 0). -// -//export macula_pool_open_stream -func macula_pool_open_stream(h C.uintptr_t, realm32 *C.uchar, procedure *C.char, mode C.int, payloadJSON *C.char, - provider32 *C.uchar, deadlineMs C.int64_t, timeoutMs C.int64_t, errOut **C.char) C.uintptr_t { - p, ok := valueOf[*pool.Pool](h) - if !ok { - setErr(errOut, errInvalidHandle) - return 0 - } - m, err := streamMode(mode) - if err != nil { - setErr(errOut, err) - return 0 - } - payload, err := jsonToCbor(C.GoString(payloadJSON)) - if err != nil { - setErr(errOut, err) - return 0 - } - realm, _ := id32(realm32) - provider, _ := id32(provider32) - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - s, err := p.OpenStream(ctx, pool.StreamCall{Realm: realm, Procedure: C.GoString(procedure), Provider: provider, - Mode: m, Payload: payload, Deadline: time.Duration(deadlineMs) * time.Millisecond}) - if err != nil { - setErr(errOut, err) - return 0 - } - return newHandle(s) -} - -func streamOf(h C.uintptr_t, errOut **C.char) *stationlink.Stream { - s, ok := valueOf[*stationlink.Stream](h) - if !ok { - setErr(errOut, errInvalidHandle) - return nil - } - return s -} - -// macula_stream_send_bytes sends a raw chunk. -// -//export macula_stream_send_bytes -func macula_stream_send_bytes(h C.uintptr_t, data *C.uchar, dataLen C.size_t, errOut **C.char) { - if s := streamOf(h, errOut); s != nil { - setErr(errOut, s.Send(goBytes(data, dataLen))) - } -} - -// macula_stream_send_json sends a structured chunk. -// -//export macula_stream_send_json -func macula_stream_send_json(h C.uintptr_t, valueJSON *C.char, errOut **C.char) { - s := streamOf(h, errOut) - if s == nil { - return - } - v, err := jsonToCbor(C.GoString(valueJSON)) - if err != nil { - setErr(errOut, err) - return - } - setErr(errOut, s.SendValue(v)) -} - -// macula_stream_close_send ends this side's sending. -// -//export macula_stream_close_send -func macula_stream_close_send(h C.uintptr_t, errOut **C.char) { - if s := streamOf(h, errOut); s != nil { - setErr(errOut, s.CloseSend()) - } -} - -// macula_stream_close ends the stream on both sides. -// -//export macula_stream_close -func macula_stream_close(h C.uintptr_t, errOut **C.char) { - if s := streamOf(h, errOut); s != nil { - setErr(errOut, s.Close()) - } -} - -// macula_stream_reply sends the provider's terminal value (JSON). -// -//export macula_stream_reply -func macula_stream_reply(h C.uintptr_t, payloadJSON *C.char, errOut **C.char) { - s := streamOf(h, errOut) - if s == nil { - return - } - v, err := jsonToCbor(C.GoString(payloadJSON)) - if err != nil { - setErr(errOut, err) - return - } - setErr(errOut, s.Reply(v)) -} - -// macula_stream_abort ends the stream with a STREAM_ERROR of code and message. -// -//export macula_stream_abort -func macula_stream_abort(h C.uintptr_t, code, message *C.char, errOut **C.char) { - if s := streamOf(h, errOut); s != nil { - setErr(errOut, s.Abort(C.GoString(code), C.GoString(message))) - } -} - -// macula_stream_recv waits up to timeout_ms (forever when 0) for the peer's -// next frame and returns it as JSON: {kind: "data", encoding, body}, {kind: -// "end", role}, {kind: "reply", payload}, {kind: "eof"} after a normal end, -// {kind: "error", code, message, relay} for a stream error, or the error -// "timeout". -// -//export macula_stream_recv -func macula_stream_recv(h C.uintptr_t, timeoutMs C.int64_t, bytesMode C.int, errOut **C.char) *C.char { - s := streamOf(h, errOut) - if s == nil { - return nil - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return nil - } - ctx, cancel := withTimeout(timeoutMs) - defer cancel() - event, err := s.Recv(ctx) - var out map[string]any - var streamErr *stationlink.StreamError - switch { - case err == nil: - out = eventJSON(event, mode) - case errors.Is(err, io.EOF): - out = map[string]any{"kind": "eof"} - case errors.As(err, &streamErr): - out = map[string]any{"kind": "error", "code": streamErr.Code, "message": streamErr.Message, "relay": boolText(streamErr.Relay)} - case errors.Is(err, context.DeadlineExceeded): - setErr(errOut, errors.New("timeout")) - return nil - default: - setErr(errOut, err) - return nil - } - text, _ := json.Marshal(out) - return C.CString(string(text)) -} - -// boolText is a flag as the JSON crossing this boundary carries it: 1 or 0, -// never a boolean. -func boolText(b bool) int { - if b { - return 1 - } - return 0 -} - -func eventJSON(e stationlink.StreamEvent, mode bytesOutput) map[string]any { - switch e.Kind { - case stationlink.StreamEnd: - return map[string]any{"kind": "end", "role": e.Role.Name()} - case stationlink.StreamReply: - return map[string]any{"kind": "reply", "payload": cborToJSON(e.Payload, mode)} - } - return map[string]any{"kind": "data", "encoding": e.Encoding.Name(), "body": cborToJSON(e.Body, mode)} -} - -// macula_stream_request is the stream's open as JSON {caller, realm, -// procedure, payload, deadline_ms}. -// -//export macula_stream_request -func macula_stream_request(h C.uintptr_t, bytesMode C.int, errOut **C.char) *C.char { - s := streamOf(h, errOut) - if s == nil { - return nil - } - mode, err := parseBytesOutput(int(bytesMode)) - if err != nil { - setErr(errOut, err) - return nil - } - open := s.Request() - return C.CString(requestJSON(stationlink.Request{Caller: open.Caller, Realm: open.Realm, Procedure: open.Procedure, - Payload: open.Payload, Deadline: time.UnixMilli(int64(open.Deadline))}, mode)) -} - -// macula_stream_free frees the stream's handle, aborting the stream first when -// it has not ended. -// -//export macula_stream_free -func macula_stream_free(h C.uintptr_t) { - if s, ok := valueOf[*stationlink.Stream](h); ok { - select { - case <-s.Done(): - default: - _ = s.Abort("cancelled", "the stream was released") - } - } - release(h) -} diff --git a/cabi/wirevalue.go b/cabi/wirevalue.go deleted file mode 100644 index 539fd76..0000000 --- a/cabi/wirevalue.go +++ /dev/null @@ -1,176 +0,0 @@ -// wirevalue.go bridges JSON (what a payload is on the wire between PHP and -// this library) and cbor.Value (what -// macula-go's CALL/RESULT frames actually carry). Ported line-for-line -// from macula-io/macula-cli's internal/wirevalue package (FromJSON/ -// ToJSON) rather than reinvented -- that package already solved this -// exact problem, including the two rules that matter most: -// -// - No CBOR bool: cbor.Value's Kind enum is UInt/NegInt/Bytes/Text/ -// List/Map/Null/Float -- there is no KindBool. A JSON `true`/`false` -// is rejected outright with an explicit error instead of being -// silently coerced to 0/1 or dropped. -// - Bytes have no native JSON shape. On the way IN, a JSON object whose -// ONLY key is "$bytes" carries a CBOR byte string as standard padded -// base64 (RFC 4648 section 4): {"$bytes": "aGVsbG8="}. Any other value -// under that sole key is an explicit error, never a silent fallback to -// a map. An object with more keys stays an ordinary map, and a plain -// string is always text: there is no "0x" input form. The sole-key -// "$bytes" object is therefore reserved. -// - On the way OUT, bytes render as a "0x"-prefixed hex string by -// default (bytesHex), or, when the caller asks (bytesTagged), as the -// same {"$bytes": ""} object the input side accepts, so a -// returned value can be sent straight back. Only Go can make that -// choice: once rendered as hex, bytes and a text value that happens -// to look like "0x..." can no longer be told apart. -// -// Plain Go, no cgo -- the *C.char <-> string conversion happens at each -// export site in rpc.go/serve.go, not here. -package main - -import ( - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - - "github.com/macula-io/macula-go/cbor" -) - -// bytesKey is the reserved sole key of a tagged bytes object. -const bytesKey = "$bytes" - -// bytesOutput selects how cborToJSON renders a CBOR byte string. -type bytesOutput int - -const ( - // bytesHex renders bytes as a "0x"-prefixed lowercase hex string: the - // default, and the only form before the tagged object existed. - bytesHex bytesOutput = 0 - // bytesTagged renders bytes as {"$bytes": ""}. - bytesTagged bytesOutput = 1 -) - -// parseBytesOutput validates a mode passed across the FFI boundary. An -// unknown value is an error rather than a silent fallback, so a caller and -// callee that disagree about the modes fail visibly. -func parseBytesOutput(mode int) (bytesOutput, error) { - switch bytesOutput(mode) { - case bytesHex, bytesTagged: - return bytesOutput(mode), nil - } - return bytesHex, fmt.Errorf("macula-php/cabi: unknown bytes output mode %d (0 = hex, 1 = tagged)", mode) -} - -// taggedBytesToCbor decodes the value under a sole "$bytes" key. -func taggedBytesToCbor(raw any) (cbor.Value, error) { - s, ok := raw.(string) - if !ok { - return cbor.Value{}, fmt.Errorf(`macula-php/cabi: a {"$bytes": ...} value must be a standard padded base64 string, got %T`, raw) - } - b, err := base64.StdEncoding.Strict().DecodeString(s) - if err != nil { - return cbor.Value{}, fmt.Errorf(`macula-php/cabi: {"$bytes": ...} is not valid standard padded base64 (RFC 4648 section 4): %w`, err) - } - return cbor.Bytes(b), nil -} - -// jsonToCbor parses a JSON document (a CALL/RESULT payload, JSON- -// encoded on the PHP side with json_encode) into a cbor.Value. -// An empty string is treated the same as literal JSON "null" -- the PHP -// side always sends *something* (see rpc.go/serve.go's callers), but -// this keeps the function total rather than erroring on an edge case -// that would otherwise need its own guard at every call site. -func jsonToCbor(jsonText string) (cbor.Value, error) { - if jsonText == "" { - return cbor.Null(), nil - } - var v any - if err := json.Unmarshal([]byte(jsonText), &v); err != nil { - return cbor.Value{}, fmt.Errorf("macula-php/cabi: invalid payload JSON: %w", err) - } - return jsonValueToCbor(v) -} - -func jsonValueToCbor(v any) (cbor.Value, error) { - switch t := v.(type) { - case nil: - return cbor.Null(), nil - case bool: - return cbor.Value{}, fmt.Errorf("macula-php/cabi: JSON boolean %v has no wire representation (macula's CBOR has no bool type) -- use 0/1 instead", t) - case string: - return cbor.Text(t), nil - case float64: - if t == float64(int64(t)) { - return cbor.Int(int64(t)), nil - } - return cbor.Float(t), nil - case []any: - vals := make([]cbor.Value, len(t)) - for i, item := range t { - cv, err := jsonValueToCbor(item) - if err != nil { - return cbor.Value{}, err - } - vals[i] = cv - } - return cbor.List(vals), nil - case map[string]any: - if raw, isTagged := t[bytesKey]; isTagged && len(t) == 1 { - return taggedBytesToCbor(raw) - } - entries := make([]cbor.MapEntry, 0, len(t)) - for k, item := range t { - cv, err := jsonValueToCbor(item) - if err != nil { - return cbor.Value{}, err - } - entries = append(entries, cbor.MapEntry{Key: cbor.Text(k), Val: cv}) - } - return cbor.Map(entries), nil - default: - return cbor.Value{}, fmt.Errorf("macula-php/cabi: unsupported JSON value of type %T", v) - } -} - -// cborToJSON converts a cbor.Value into a plain Go value that -// encoding/json can marshal directly -- jsonToCbor's inverse. See this -// file's own doc for how mode renders bytes (hex or the tagged object). -func cborToJSON(v cbor.Value, mode bytesOutput) any { - if b, ok := v.AsBytes(); ok { - if mode == bytesTagged { - return map[string]any{bytesKey: base64.StdEncoding.EncodeToString(b)} - } - return "0x" + hex.EncodeToString(b) - } - if s, ok := v.AsText(); ok { - return s - } - if i, ok := v.AsInt64(); ok { - return i - } - if f, ok := v.AsFloat(); ok { - return f - } - if v.IsNull() { - return nil - } - if list, ok := v.AsList(); ok { - out := make([]any, len(list)) - for i, item := range list { - out[i] = cborToJSON(item, mode) - } - return out - } - if entries, ok := v.AsMap(); ok { - out := make(map[string]any, len(entries)) - for _, e := range entries { - key := e.Key.String() - if s, ok := e.Key.AsText(); ok { - key = s - } - out[key] = cborToJSON(e.Val, mode) - } - return out - } - return v.String() -} diff --git a/cabi/wirevalue_test.go b/cabi/wirevalue_test.go deleted file mode 100644 index fe1a43f..0000000 --- a/cabi/wirevalue_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/macula-io/macula-go/cbor" -) - -// Input side: a JSON object whose ONLY key is "$bytes" carries a CBOR byte -// string, as standard padded base64 (RFC 4648 section 4). Any other value -// under that sole key is an explicit error, never a silent fallback to a -// map. An object with more keys stays a map; a plain string is always text. - -func TestJSONBytesTaggedObjectBecomesBytes(t *testing.T) { - v, err := jsonToCbor(`{"$bytes":"aGVsbG8="}`) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - b, ok := v.AsBytes() - if !ok { - t.Fatalf("want a CBOR byte string, got %s", v.String()) - } - if !bytes.Equal(b, []byte("hello")) { - t.Fatalf("want hello, got %q", b) - } -} - -func TestJSONBytesEmptyBase64IsEmptyBytes(t *testing.T) { - v, err := jsonToCbor(`{"$bytes":""}`) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - b, ok := v.AsBytes() - if !ok || len(b) != 0 { - t.Fatalf("want an empty byte string, got %s", v.String()) - } -} - -func TestJSONBytesInvalidValueIsAnError(t *testing.T) { - for _, in := range []string{ - `{"$bytes":"not base64!"}`, - `{"$bytes":"aGVsbG8"}`, // unpadded: only standard padded base64 is accepted - `{"$bytes":"aGVs_G8="}`, // URL-safe alphabet - `{"$bytes":5}`, - `{"$bytes":null}`, - `{"$bytes":{"x":"y"}}`, - } { - _, err := jsonToCbor(in) - if err == nil { - t.Errorf("%s: want an explicit error, got none", in) - continue - } - if !strings.Contains(err.Error(), "$bytes") { - t.Errorf("%s: the error should name $bytes, got %v", in, err) - } - } -} - -func TestJSONBytesWithOtherKeysStaysAMap(t *testing.T) { - v, err := jsonToCbor(`{"$bytes":"aGVsbG8=","other":1}`) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - entries, ok := v.AsMap() - if !ok || len(entries) != 2 { - t.Fatalf("want a 2-entry map, got %s", v.String()) - } - got, _ := v.Get("$bytes") - if s, isText := got.AsText(); !isText || s != "aGVsbG8=" { - t.Fatalf("want \"$bytes\" kept as text inside an ordinary map, got %s", got.String()) - } -} - -func TestJSONPlainStringsStayText(t *testing.T) { - for _, in := range []string{`"aGVsbG8="`, `"0x68656c6c6f"`, `"hello"`} { - v, err := jsonToCbor(in) - if err != nil { - t.Fatalf("%s: unexpected error: %v", in, err) - } - if _, ok := v.AsText(); !ok { - t.Errorf("%s: want text, got %s", in, v.String()) - } - } -} - -func TestJSONBytesNested(t *testing.T) { - v, err := jsonToCbor(`{"id":{"$bytes":"AQID"},"list":[{"$bytes":"BAU="}]}`) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - id, _ := v.Get("id") - if b, ok := id.AsBytes(); !ok || !bytes.Equal(b, []byte{1, 2, 3}) { - t.Fatalf("id: want bytes 010203, got %s", id.String()) - } - lst, _ := v.Get("list") - items, ok := lst.AsList() - if !ok || len(items) != 1 { - t.Fatalf("list: want 1 item, got %s", lst.String()) - } - if b, ok := items[0].AsBytes(); !ok || !bytes.Equal(b, []byte{4, 5}) { - t.Fatalf("list[0]: want bytes 0405, got %s", items[0].String()) - } -} - -// Output side: bytes render as "0x" hex by default, or as the tagged -// {"$bytes": base64} object when asked, and the tagged form round-trips. - -func TestCborToJSONDefaultBytesAreHex(t *testing.T) { - if got := cborToJSON(cbor.Bytes([]byte("hi")), bytesHex); got != "0x6869" { - t.Fatalf("want 0x6869, got %#v", got) - } -} - -func TestCborToJSONTaggedBytes(t *testing.T) { - got, ok := cborToJSON(cbor.Bytes([]byte("hi")), bytesTagged).(map[string]any) - if !ok || len(got) != 1 || got["$bytes"] != "aGk=" { - t.Fatalf(`want {"$bytes":"aGk="}, got %#v`, got) - } -} - -func TestCborToJSONTaggedLeavesTextAlone(t *testing.T) { - if got := cborToJSON(cbor.Text("0x6869"), bytesTagged); got != "0x6869" { - t.Fatalf("text must stay text, got %#v", got) - } -} - -func TestCborToJSONTaggedNestedAndRoundTrip(t *testing.T) { - orig := cbor.Map([]cbor.MapEntry{ - {Key: cbor.Text("id"), Val: cbor.Bytes([]byte{1, 2, 3})}, - {Key: cbor.Text("list"), Val: cbor.List([]cbor.Value{cbor.Bytes([]byte{4, 5}), cbor.Text("t")})}, - }) - rendered, err := json.Marshal(cborToJSON(orig, bytesTagged)) - if err != nil { - t.Fatal(err) - } - back, err := jsonToCbor(string(rendered)) - if err != nil { - t.Fatalf("tagged output must be valid input: %v", err) - } - id, _ := back.Get("id") - if b, ok := id.AsBytes(); !ok || !bytes.Equal(b, []byte{1, 2, 3}) { - t.Fatalf("id did not round-trip as bytes: %s (json %s)", id.String(), rendered) - } - lst, _ := back.Get("list") - items, _ := lst.AsList() - if len(items) != 2 { - t.Fatalf("list: %s", lst.String()) - } - if b, ok := items[0].AsBytes(); !ok || !bytes.Equal(b, []byte{4, 5}) { - t.Fatalf("list[0] did not round-trip as bytes: %s", items[0].String()) - } - if s, ok := items[1].AsText(); !ok || s != "t" { - t.Fatalf("list[1] text changed: %s", items[1].String()) - } -} - -func TestParseBytesOutput(t *testing.T) { - for _, c := range []struct { - in int - want bytesOutput - ok bool - }{{0, bytesHex, true}, {1, bytesTagged, true}, {2, bytesHex, false}, {-1, bytesHex, false}} { - got, err := parseBytesOutput(c.in) - if (err == nil) != c.ok || (c.ok && got != c.want) { - t.Errorf("parseBytesOutput(%d) = %v, %v", c.in, got, err) - } - } -} diff --git a/composer.json b/composer.json index d2cdbed..c5ec352 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "macula-io/macula-php", - "description": "PHP client for the macula 12 mesh: FFI over macula-go's pool (calls and streams by direct dial, serving, pubsub, node-served content, the DHT)", + "description": "PHP client for the macula mesh: FFI over macula-go's released libmacula (calls and streams by direct dial, serving, pubsub, node-served content, the DHT)", "type": "library", "keywords": [ "macula", @@ -42,8 +42,8 @@ }, "scripts": { "build": [ - "cd cabi && go build -buildmode=c-shared -o libmacula.so .", - "cd cabi && go build -o ../build/teststation ./cmd/teststation" + "scripts/fetch_native.sh", + "scripts/build_teststation.sh" ], "test": "phpunit", "test:live": "phpunit --testsuite=live" diff --git a/examples/README.md b/examples/README.md index d70c892..0ccc154 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Runnable scripts against a real macula 12 station. Build the library first +Runnable scripts against a real macula station. Build the library first (`composer install && composer build`), then run an example with PHP. Each reads where to connect from the environment, through [`mesh.php`](mesh.php): diff --git a/scripts/build_teststation.sh b/scripts/build_teststation.sh new file mode 100755 index 0000000..13ae14c --- /dev/null +++ b/scripts/build_teststation.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Builds macula-go's teststation (in-process stations for the offline suite) +# at the tag in abi/MACULA_GO_REF, to build/teststation. Needs go. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +read -r tag _ < "$root/abi/MACULA_GO_REF" +mkdir -p "$root/build" +GOBIN="$root/build" go install "github.com/macula-io/macula-go/teststation/cmd/teststation@$tag" diff --git a/scripts/fetch_native.sh b/scripts/fetch_native.sh new file mode 100755 index 0000000..bf991ad --- /dev/null +++ b/scripts/fetch_native.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Downloads macula-go's released C ABI libraries for the tag in +# abi/MACULA_GO_REF (" ", and the tag must still be that +# commit) into DEST (default build/native), and uses none that +# fails a check: every file must match the release's SHA256SUMS and carry a +# build provenance attestation from macula-io/macula-go, and the release's +# macula.h must be abi/macula.h. Needs gh (GH_TOKEN) and sha256sum. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +read -r tag sha < "$root/abi/MACULA_GO_REF" +dest="${1:-$root/build/native}" +files=(libmacula-linux-x64.so libmacula-linux-arm64.so libmacula-macos-x64.dylib libmacula-macos-arm64.dylib macula-windows-x64.dll macula.h) + +case "$tag" in + v*) ;; + *) echo "fetch_native: abi/MACULA_GO_REF is $tag, not a release tag; a release binds released libraries only" >&2; exit 1 ;; +esac + +tagged="$(git ls-remote https://github.com/macula-io/macula-go "refs/tags/$tag^{}" "refs/tags/$tag" | awk 'NR==1 || /\^\{\}$/ {c=$1} END {print c}')" +if [ "$tagged" != "$sha" ]; then + echo "fetch_native: macula-go $tag is $tagged, abi/MACULA_GO_REF records $sha" >&2 + exit 1 +fi + +rm -rf "$dest"; mkdir -p "$dest" +patterns=(-p SHA256SUMS); for f in "${files[@]}"; do patterns+=(-p "$f"); done +gh release download "$tag" --repo macula-io/macula-go --dir "$dest" "${patterns[@]}" + +(cd "$dest" + for f in "${files[@]}"; do + grep -E "^[0-9a-f]{64} $f\$" SHA256SUMS > /dev/null || { echo "fetch_native: $f is not in SHA256SUMS" >&2; exit 1; } + done + sha256sum --check --strict --ignore-missing SHA256SUMS + for f in "${files[@]}"; do + gh attestation verify "$f" --repo macula-io/macula-go > /dev/null + echo "attested: $f" + done) + +cmp "$dest/macula.h" "$root/abi/macula.h" || { echo "fetch_native: $tag's macula.h is not abi/macula.h" >&2; exit 1; } diff --git a/src/Binding.php b/src/Binding.php index d5c481c..b46c07e 100644 --- a/src/Binding.php +++ b/src/Binding.php @@ -5,120 +5,175 @@ namespace Macula; /** - * Loads libmacula.so (cabi/, macula-go's pool behind a C ABI) with PHP's FFI, - * and turns its conventions into PHP ones: a function that fails sets a C - * string in its err_out, which is thrown here as a MaculaException (or the - * subclass its text names) and freed. Internal to the package: the public API - * is NodeKey, Pool, Subscription, Served, ServedStream and Stream. + * Loads libmacula, macula-go's released C ABI (abi/macula.h, the tag in + * abi/MACULA_GO_REF), with PHP's FFI, and turns its conventions into PHP ones: + * a function that fails sets a JSON error in its err_out, {"kind", "message", + * ...}, which is thrown here as the MaculaException its kind names, and + * freed. Internal to the package: the public API is NodeKey, Pool, + * Subscription, Served, ServedStream and Stream. * - * The declarations are written out rather than read from the generated - * header: FFI::cdef() has no preprocessor, so cgo's boilerplate cannot be fed - * to it. They are the functions cabi/ exports, in plain C types. + * The declarations are abi/macula.h's, in plain C types: FFI::cdef() has no + * preprocessor. Every call that blocks takes a cancel token, always 0 here. * * @internal */ final class Binding { + /** The ABI version this binding is written against. */ + public const ABI_VERSION = 1; + private const CDEF = <<<'CDEF' + int32_t macula_abi_version(void); void macula_free_string(char *s); - void macula_free_bytes(unsigned char *b); - - uintptr_t macula_key_generate(char *profile, char **err_out); - uintptr_t macula_key_load(char *path, char *profile, char **err_out); - void macula_key_save(uintptr_t h, char *path, char **err_out); - void macula_key_node_id(uintptr_t h, unsigned char *out32, char **err_out); - unsigned char *macula_key_public_key(uintptr_t h, size_t *out_len, char **err_out); - char *macula_key_profile(uintptr_t h, char **err_out); - unsigned char *macula_key_sign(uintptr_t h, unsigned char *data, size_t data_len, size_t *out_len, char **err_out); - void macula_key_free(uintptr_t h); - int macula_verify(unsigned char *data, size_t data_len, unsigned char *signature, size_t signature_len, - unsigned char *public_key, size_t public_key_len, char *profile, char **err_out); - - uintptr_t macula_pool_connect(uintptr_t key, char *seeds_json, char *opts_json, char **err_out); - void macula_pool_close(uintptr_t h); - void macula_pool_node_id(uintptr_t h, unsigned char *out32, char **err_out); - char *macula_pool_status(uintptr_t h, char **err_out); - char *macula_pool_call(uintptr_t h, unsigned char *realm32, char *procedure, char *payload_json, - unsigned char *provider32, int64_t timeout_ms, int bytes_mode, char **err_out); - char *macula_pool_providers(uintptr_t h, unsigned char *realm32, char *procedure, int64_t timeout_ms, char **err_out); - void macula_pool_publish(uintptr_t h, unsigned char *realm32, char *topic, char *payload_json, int64_t ttl_ms, + void macula_free_bytes(uint8_t *b); + + uintptr_t macula_key_generate(const char *profile, uintptr_t cancel, char **err_out); + uintptr_t macula_key_load(const char *path, const char *profile, char **err_out); + uintptr_t macula_key_load_or_create(const char *path, const char *profile, uintptr_t cancel, char **err_out); + void macula_key_save(uintptr_t key, const char *path, char **err_out); + void macula_key_node_id(uintptr_t key, uint8_t *out_node_id, char **err_out); + uint8_t *macula_key_public_key(uintptr_t key, size_t *out_len, char **err_out); + char *macula_key_profile(uintptr_t key, char **err_out); + uint8_t *macula_key_sign(uintptr_t key, const uint8_t *data, size_t data_len, size_t *out_len, char **err_out); + int32_t macula_verify(const uint8_t *data, size_t data_len, const uint8_t *signature, size_t signature_len, + const uint8_t *public_key, size_t public_key_len, const char *profile, char **err_out); + void macula_key_free(uintptr_t key); + + uintptr_t macula_pool_connect(uintptr_t key, const char *seeds_json, const char *options_json, uintptr_t cancel, char **err_out); - uintptr_t macula_pool_subscribe(uintptr_t h, unsigned char *realm32, char *topic, int bytes_mode, char **err_out); - char *macula_subscription_next(uintptr_t h, int timeout_ms, int *closed, char **err_out); - void macula_subscription_stop(uintptr_t h); - char *macula_pool_find_record(uintptr_t h, unsigned char *key32, int64_t timeout_ms, int bytes_mode, char **err_out); - char *macula_pool_find_records(uintptr_t h, unsigned char *key32, int64_t timeout_ms, int bytes_mode, char **err_out); - char *macula_pool_find_records_by_type(uintptr_t h, int record_type, int64_t timeout_ms, int bytes_mode, + void macula_pool_close(uintptr_t pool); + void macula_pool_node_id(uintptr_t pool, uint8_t *out_node_id, char **err_out); + char *macula_pool_status(uintptr_t pool, char **err_out); + + char *macula_pool_call_opts(uintptr_t pool, const uint8_t *realm, const char *procedure, const char *payload_json, + const char *options_json, int64_t timeout_ms, uintptr_t cancel, char **err_out); + char *macula_pool_providers(uintptr_t pool, const uint8_t *realm, const char *procedure, int64_t timeout_ms, + uintptr_t cancel, char **err_out); + + void macula_pool_publish(uintptr_t pool, const uint8_t *realm, const char *topic, const char *payload_json, + int64_t ttl_ms, char **err_out); + uintptr_t macula_pool_subscribe(uintptr_t pool, const uint8_t *realm, const char *topic, char **err_out); + char *macula_subscription_next(uintptr_t subscription, int64_t timeout_ms, uintptr_t cancel, int32_t *closed, char **err_out); - void macula_pool_put_record(uintptr_t h, unsigned char *wire, size_t wire_len, int64_t timeout_ms, char **err_out); - - uintptr_t macula_pool_serve(uintptr_t h, unsigned char *realm32, char *procedure, int bytes_mode, char **err_out); - char *macula_served_next(uintptr_t h, int timeout_ms, uintptr_t *handle, int *closed, char **err_out); - void macula_pending_reply(uintptr_t h, char *result_json, char **err_out); - void macula_pending_error(uintptr_t h, char *message, char **err_out); - void macula_served_stop(uintptr_t h, char **err_out); - uintptr_t macula_pool_serve_stream(uintptr_t h, unsigned char *realm32, char *procedure, int mode, int bytes_mode, + void macula_subscription_stop(uintptr_t subscription); + + uintptr_t macula_pool_serve_opts(uintptr_t pool, const uint8_t *realm, const char *procedure, + const char *options_json, char **err_out); + uintptr_t macula_pool_serve_stream_opts(uintptr_t pool, const uint8_t *realm, const char *procedure, int32_t mode, + const char *options_json, char **err_out); + char *macula_served_next(uintptr_t served, int64_t timeout_ms, uintptr_t cancel, uintptr_t *out_item, + int32_t *closed, char **err_out); + void macula_pending_reply(uintptr_t pending, const char *result_json, char **err_out); + void macula_pending_error(uintptr_t pending, const char *message, char **err_out); + void macula_served_stop(uintptr_t served, char **err_out); + + uintptr_t macula_pool_open_stream_opts(uintptr_t pool, const uint8_t *realm, const char *procedure, int32_t mode, + const char *payload_json, const char *options_json, int64_t deadline_ms, int64_t timeout_ms, + uintptr_t cancel, char **err_out); + char *macula_stream_request(uintptr_t stream, char **err_out); + void macula_stream_send_bytes(uintptr_t stream, const uint8_t *data, size_t data_len, char **err_out); + void macula_stream_send_json(uintptr_t stream, const char *value_json, char **err_out); + void macula_stream_close_send(uintptr_t stream, char **err_out); + void macula_stream_reply(uintptr_t stream, const char *payload_json, char **err_out); + void macula_stream_abort(uintptr_t stream, const char *code, const char *message, char **err_out); + void macula_stream_close(uintptr_t stream, char **err_out); + char *macula_stream_recv(uintptr_t stream, int64_t timeout_ms, uintptr_t cancel, char **err_out); + void macula_stream_free(uintptr_t stream); + + void macula_pool_share_content(uintptr_t pool, const uint8_t *realm, const uint8_t *data, size_t data_len, + const char *name, int64_t timeout_ms, uintptr_t cancel, uint8_t *out_mcid, char **err_out); + void macula_pool_unshare_content(uintptr_t pool, const uint8_t *realm, const uint8_t *mcid, int64_t timeout_ms, + uintptr_t cancel, char **err_out); + uint8_t *macula_pool_get_content(uintptr_t pool, const uint8_t *realm, const uint8_t *mcid, + const char *options_json, int64_t timeout_ms, uintptr_t cancel, size_t *out_len, char **err_out); + + char *macula_pool_find_record(uintptr_t pool, const uint8_t *key, int64_t timeout_ms, uintptr_t cancel, char **err_out); - - uintptr_t macula_pool_open_stream(uintptr_t h, unsigned char *realm32, char *procedure, int mode, char *payload_json, - unsigned char *provider32, int64_t deadline_ms, int64_t timeout_ms, char **err_out); - void macula_stream_send_bytes(uintptr_t h, unsigned char *data, size_t data_len, char **err_out); - void macula_stream_send_json(uintptr_t h, char *value_json, char **err_out); - void macula_stream_close_send(uintptr_t h, char **err_out); - void macula_stream_close(uintptr_t h, char **err_out); - void macula_stream_reply(uintptr_t h, char *payload_json, char **err_out); - void macula_stream_abort(uintptr_t h, char *code, char *message, char **err_out); - char *macula_stream_recv(uintptr_t h, int64_t timeout_ms, int bytes_mode, char **err_out); - char *macula_stream_request(uintptr_t h, int bytes_mode, char **err_out); - void macula_stream_free(uintptr_t h); - - unsigned char *macula_pool_share_content(uintptr_t h, unsigned char *realm32, unsigned char *data, size_t data_len, - char *name, int64_t timeout_ms, size_t *out_len, char **err_out); - void macula_pool_unshare_content(uintptr_t h, unsigned char *realm32, unsigned char *mcid, size_t mcid_len, - int64_t timeout_ms, char **err_out); - unsigned char *macula_pool_get_content(uintptr_t h, unsigned char *realm32, unsigned char *mcid, size_t mcid_len, - uint64_t max_bytes, int max_chunks, int parallel, int64_t chunk_timeout_ms, int64_t timeout_ms, - size_t *out_len, char **err_out); + char *macula_pool_find_records(uintptr_t pool, const uint8_t *key, int64_t timeout_ms, uintptr_t cancel, + char **err_out); + char *macula_pool_find_records_by_type(uintptr_t pool, int32_t record_type, int64_t timeout_ms, uintptr_t cancel, + char **err_out); + void macula_pool_put_record(uintptr_t pool, const uint8_t *wire, size_t wire_len, int64_t timeout_ms, + uintptr_t cancel, char **err_out); CDEF; private static ?\FFI $ffi = null; - /** The loaded library: MACULA_LIBRARY_PATH, or cabi/libmacula.so. */ + /** + * The loaded library: MACULA_LIBRARY_PATH, or this platform's library in + * build/native, which `composer build` fetches and checks. A library built + * for another ABI version is refused. + */ public static function ffi(): \FFI { if (self::$ffi === null) { - $path = getenv('MACULA_LIBRARY_PATH') ?: __DIR__ . '/../cabi/libmacula.so'; + $path = getenv('MACULA_LIBRARY_PATH') ?: __DIR__ . '/../build/native/' . self::libraryName(); if (!is_file($path)) { - throw new MaculaException("libmacula.so not found at {$path}: build it with `composer build`" + throw new MaculaException("libmacula not found at {$path}: fetch it with `composer build`" . ' (or set MACULA_LIBRARY_PATH)'); } - self::$ffi = \FFI::cdef(self::CDEF, $path); + $ffi = \FFI::cdef(self::CDEF, $path); + $version = $ffi->macula_abi_version(); + if ($version !== self::ABI_VERSION) { + throw new MaculaException("libmacula at {$path} is ABI {$version}; macula-php needs ABI " + . self::ABI_VERSION); + } + self::$ffi = $ffi; } return self::$ffi; } + /** The released library's file name for this platform. */ + private static function libraryName(): string + { + $arm = in_array(php_uname('m'), ['aarch64', 'arm64'], true); + return match (PHP_OS_FAMILY) { + 'Darwin' => $arm ? 'libmacula-macos-arm64.dylib' : 'libmacula-macos-x64.dylib', + 'Windows' => 'macula-windows-x64.dll', + default => $arm ? 'libmacula-linux-arm64.so' : 'libmacula-linux-x64.so', + }; + } + /** - * Calls fn with a fresh err_out, and throws what it set, as the error - * class errorFor picks, after freeing it. + * Calls fn with a fresh err_out, and throws what it set, as the + * MaculaException its kind names, after freeing it. * * @template T * @param callable(\FFI\CData): T $fn - * @param callable(string): \Throwable $errorFor * @return T */ - public static function call(callable $fn, ?callable $errorFor = null): mixed + public static function call(callable $fn): mixed { $ffi = self::ffi(); $err = $ffi->new('char*'); $result = $fn(\FFI::addr($err)); if (!\FFI::isNull($err)) { - $message = \FFI::string($err); + $text = \FFI::string($err); $ffi->macula_free_string($err); - throw ($errorFor ?? static fn (string $m) => new MaculaException($m))($message); + throw self::error($text); } return $result; } + /** The library's JSON error as the class its kind names. */ + private static function error(string $text): MaculaException + { + $e = json_decode($text, true); + if (!is_array($e) || !is_string($e['kind'] ?? null)) { + return new MaculaException($text); + } + $message = (string) ($e['message'] ?? ''); + return match ($e['kind']) { + 'provider_error' => new ProviderError((string) ($e['code'] ?? ''), (string) ($e['detail'] ?? '')), + 'relay_error' => new RelayError((string) ($e['code'] ?? '')), + 'not_shared' => new NotSharedError(), + 'unavailable' => new ContentUnavailableError(implode('; ', $e['failures'] ?? [])), + 'confidentiality' => new ConfidentialityError((string) ($e['reason'] ?? ''), $e['named'] ?? null, + $e['found'] ?? null, $message), + default => new MaculaException($message, $e['kind']), + }; + } + /** A C string the library returned, as PHP text, freed. */ public static function takeString(?\FFI\CData $s): ?string { @@ -145,7 +200,7 @@ public static function takeBytes(?\FFI\CData $b, int $length): string public static function buffer(string $bytes): \FFI\CData { $n = max(1, strlen($bytes)); - $buf = self::ffi()->new("unsigned char[{$n}]"); + $buf = self::ffi()->new("uint8_t[{$n}]"); \FFI::memcpy($buf, $bytes, strlen($bytes)); return $buf; } @@ -156,10 +211,10 @@ public static function size(): \FFI\CData return self::ffi()->new('size_t'); } - /** A fresh int out-parameter. */ + /** A fresh int32_t out-parameter. */ public static function int(): \FFI\CData { - return self::ffi()->new('int'); + return self::ffi()->new('int32_t'); } /** A fresh uintptr_t out-parameter. */ @@ -171,7 +226,7 @@ public static function handle(): \FFI\CData /** The 32 bytes a node_id out-parameter was filled with, as hex. */ public static function id32Out(callable $fill): string { - $out = self::ffi()->new('unsigned char[32]'); + $out = self::ffi()->new('uint8_t[32]'); self::call(static fn ($err) => $fill($out, $err)); return bin2hex(\FFI::string($out, 32)); } diff --git a/src/Confidentiality.php b/src/Confidentiality.php new file mode 100644 index 0000000..5a063b5 --- /dev/null +++ b/src/Confidentiality.php @@ -0,0 +1,19 @@ + Binding::ffi()->macula_key_generate($profile->value, $err))); + return new self(Binding::call(fn ($err) => Binding::ffi()->macula_key_generate($profile->value, 0, $err))); } /** The key in the key file at path. A file its group or others can read, @@ -32,12 +32,8 @@ public static function load(string $path, Profile $profile = Profile::PqHybrid): /** The key at path, or a new one saved there when the file does not exist. */ public static function loadOrCreate(string $path, Profile $profile = Profile::PqHybrid): self { - if (file_exists($path)) { - return self::load($path, $profile); - } - $key = self::generate($profile); - $key->save($path); - return $key; + return new self(Binding::call(fn ($err) => Binding::ffi()->macula_key_load_or_create($path, $profile->value, 0, + $err))); } /** Writes the key to path, readable by its owner only. */ diff --git a/src/NotSharedError.php b/src/NotSharedError.php index 8a07d5c..a3a4be8 100644 --- a/src/NotSharedError.php +++ b/src/NotSharedError.php @@ -9,6 +9,6 @@ final class NotSharedError extends MaculaException { public function __construct() { - parent::__construct('macula-php: no node shares that content in that realm'); + parent::__construct('macula-php: no node shares that content in that realm', 'not_shared'); } } diff --git a/src/Pool.php b/src/Pool.php index 16705c6..78dad50 100644 --- a/src/Pool.php +++ b/src/Pool.php @@ -5,7 +5,7 @@ namespace Macula; /** - * A node's pool of station links on the macula 12 mesh, as macula-go's pool + * A node's pool of station links on the macula mesh, as macula-go's pool * keeps it: every seed pinned by its node_id, the realms whose keys the node * trusts, and one identity for every link. Calls and streams reach a provider * by direct dial: its advertisements from the DHT, trusted only when the @@ -33,6 +33,9 @@ private function __construct(private readonly int $handle) * its authorization verifies against it, and a procedure is served only * in a realm it names * @param int $timeoutMs how long to wait for a first link, 30 s when 0 + * @param bool $kemAdvertise name this node's KEM key in its confidential + * procedures' advertisements, so callers seal to it: only once every + * station runs macula 12.11 or later */ public static function connect( NodeKey $key, @@ -42,6 +45,7 @@ public static function connect( int $maxDirectLinks = 0, int $respawnDelayMs = 0, int $timeoutMs = 0, + bool $kemAdvertise = false, ): self { $seedJson = array_map(fn (Seed $s) => [ 'host' => $s->host, @@ -58,10 +62,11 @@ public static function connect( 'max_direct_links' => $maxDirectLinks, 'respawn_delay_ms' => $respawnDelayMs, 'timeout_ms' => $timeoutMs, + 'kem_advertise' => $kemAdvertise ? 1 : 0, ]; $k = $key->live(); return new self(Binding::call(fn ($err) => Binding::ffi()->macula_pool_connect($k, - json_encode($seedJson, JSON_THROW_ON_ERROR), json_encode($opts, JSON_THROW_ON_ERROR), $err))); + json_encode($seedJson, JSON_THROW_ON_ERROR), json_encode($opts, JSON_THROW_ON_ERROR), 0, $err))); } /** The node_id the pool links as, as hex. */ @@ -91,7 +96,10 @@ public function status(): array /** * Calls procedure in realm at a provider (any trusted one unless provider * names one) by direct dial, and returns its result. A provider's ERROR is - * thrown as a ProviderError, a station's relay error as a RelayError. + * thrown as a ProviderError, a station's relay error as a RelayError. It + * is sealed whenever the provider's advertisement names a KEM key; under + * Confidentiality::Required one that cannot be sealed is a + * ConfidentialityError, never a call in the clear. */ public function call( string $realm, @@ -100,14 +108,25 @@ public function call( ?string $provider = null, int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS, BytesOutput $bytes = BytesOutput::Hex, + Confidentiality $confidential = Confidentiality::Preferred, ): mixed { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); - $provider32 = $provider === null ? null : Binding::buffer(Wire::id32($provider, 'provider')); $json = Wire::encode($payload); - $result = Binding::call(fn ($err) => Binding::ffi()->macula_pool_call($h, $realm32, $procedure, $json, $provider32, - $timeoutMs, $bytes->value, $err), Wire::callError(...)); - return Wire::decode(Binding::takeString($result)); + $opts = self::callOptions($provider, $confidential); + $result = Binding::call(fn ($err) => Binding::ffi()->macula_pool_call_opts($h, $realm32, $procedure, $json, $opts, + $timeoutMs, 0, $err)); + return Wire::decodeOutput(Binding::takeString($result), $bytes); + } + + /** A call's or an open's options as the library takes them. */ + private static function callOptions(?string $provider, Confidentiality $confidential): string + { + $opts = ['confidential' => $confidential->value]; + if ($provider !== null) { + $opts['provider'] = bin2hex(Wire::id32($provider, 'provider')); + } + return json_encode($opts, JSON_THROW_ON_ERROR); } /** The procedure's trusted providers, freshest first. @return list */ @@ -116,7 +135,7 @@ public function providers(string $realm, string $procedure, int $timeoutMs = Wir $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); $found = Wire::decode(Binding::takeString(Binding::call(fn ($err) => Binding::ffi()->macula_pool_providers($h, - $realm32, $procedure, $timeoutMs, $err)))); + $realm32, $procedure, $timeoutMs, 0, $err)))); return array_map(fn (array $p) => new Provider($p['node'], $p['station']), $found ?? []); } @@ -137,7 +156,7 @@ public function subscribe(string $realm, string $topic, BytesOutput $bytes = Byt $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); return new Subscription(Binding::call(fn ($err) => Binding::ffi()->macula_pool_subscribe($h, $realm32, $topic, - $bytes->value, $err))); + $err)), $bytes); } /** @@ -146,14 +165,20 @@ public function subscribe(string $realm, string $topic, BytesOutput $bytes = Byt * an error. An org procedure needs the realm's key pinned and the org's * delegation to this node in the DHT; a procedure in this node's own * namespace (ownProcedure) needs neither, and another node's namespace is - * refused. + * refused. Under Confidentiality::Required a call in the clear is refused; + * a pool connected with kemAdvertise names its KEM key so callers seal. */ - public function serve(string $realm, string $procedure, BytesOutput $bytes = BytesOutput::Hex): Served - { + public function serve( + string $realm, + string $procedure, + BytesOutput $bytes = BytesOutput::Hex, + Confidentiality $confidential = Confidentiality::Preferred, + ): Served { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); - return new Served(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve($h, $realm32, $procedure, - $bytes->value, $err))); + $opts = json_encode(['confidential' => $confidential->value], JSON_THROW_ON_ERROR); + return new Served(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_opts($h, $realm32, $procedure, + $opts, $err)), $bytes); } /** Serves procedure in realm as a stream of mode. Its sessions wait until @@ -163,11 +188,13 @@ public function serveStream( string $procedure, StreamMode $mode, BytesOutput $bytes = BytesOutput::Hex, + Confidentiality $confidential = Confidentiality::Preferred, ): ServedStream { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); - return new ServedStream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_stream($h, $realm32, - $procedure, $mode->value, $bytes->value, $err)), $bytes); + $opts = json_encode(['confidential' => $confidential->value], JSON_THROW_ON_ERROR); + return new ServedStream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_stream_opts($h, $realm32, + $procedure, $mode->value, $opts, $err)), $bytes); } /** Opens a stream of mode on procedure in realm at a provider, by direct @@ -181,13 +208,14 @@ public function openStream( int $deadlineMs = 0, int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS, BytesOutput $bytes = BytesOutput::Hex, + Confidentiality $confidential = Confidentiality::Preferred, ): Stream { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); - $provider32 = $provider === null ? null : Binding::buffer(Wire::id32($provider, 'provider')); $json = Wire::encode($payload); - return new Stream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_open_stream($h, $realm32, $procedure, - $mode->value, $json, $provider32, $deadlineMs, $timeoutMs, $err), Wire::callError(...)), $bytes); + $opts = self::callOptions($provider, $confidential); + return new Stream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_open_stream_opts($h, $realm32, + $procedure, $mode->value, $json, $opts, $deadlineMs, $timeoutMs, 0, $err)), $bytes); } /** The verified record under key, or null when there is none. */ @@ -199,15 +227,14 @@ public function findRecord( $h = $this->live(); $key32 = Binding::buffer(Wire::id32($key, 'key')); try { - $found = Binding::call(fn ($err) => Binding::ffi()->macula_pool_find_record($h, $key32, $timeoutMs, - $bytes->value, $err)); + $found = Binding::call(fn ($err) => Binding::ffi()->macula_pool_find_record($h, $key32, $timeoutMs, 0, $err)); } catch (MaculaException $e) { - if ($e->getMessage() === 'not_found') { + if ($e->kind === 'not_found') { return null; } throw $e; } - return DhtRecord::fromArray(Wire::decode(Binding::takeString($found))); + return DhtRecord::fromArray(Wire::decodeOutput(Binding::takeString($found), $bytes)); } /** Every verified record under key, and how many did not verify. */ @@ -219,7 +246,7 @@ public function findRecords( $h = $this->live(); $key32 = Binding::buffer(Wire::id32($key, 'key')); return FoundRecords::fromJson(Binding::takeString(Binding::call(fn ($err) => - Binding::ffi()->macula_pool_find_records($h, $key32, $timeoutMs, $bytes->value, $err)))); + Binding::ffi()->macula_pool_find_records($h, $key32, $timeoutMs, 0, $err))), $bytes); } /** Every verified record of type the station holds, and how many did not @@ -232,7 +259,7 @@ public function findRecordsByType( $h = $this->live(); $t = $type instanceof RecordType ? $type->value : $type; return FoundRecords::fromJson(Binding::takeString(Binding::call(fn ($err) => - Binding::ffi()->macula_pool_find_records_by_type($h, $t, $timeoutMs, $bytes->value, $err)))); + Binding::ffi()->macula_pool_find_records_by_type($h, $t, $timeoutMs, 0, $err))), $bytes); } /** Puts a signed record's wire bytes in the DHT. */ @@ -240,7 +267,7 @@ public function putRecord(string $wire, int $timeoutMs = Wire::DEFAULT_CALL_TIME { $h = $this->live(); $buf = Binding::buffer($wire); - Binding::call(fn ($err) => Binding::ffi()->macula_pool_put_record($h, $buf, strlen($wire), $timeoutMs, $err)); + Binding::call(fn ($err) => Binding::ffi()->macula_pool_put_record($h, $buf, strlen($wire), $timeoutMs, 0, $err)); } /** @@ -260,10 +287,10 @@ public function shareContent( $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); $buf = Binding::buffer($data); - $n = Binding::size(); - $mcid = Binding::call(fn ($err) => Binding::ffi()->macula_pool_share_content($h, $realm32, $buf, strlen($data), - $name, $timeoutMs, \FFI::addr($n), $err), Wire::contentError(...)); - return bin2hex(Binding::takeBytes($mcid, $n->cdata)); + $mcid = Binding::ffi()->new('uint8_t[50]'); + Binding::call(fn ($err) => Binding::ffi()->macula_pool_share_content($h, $realm32, $buf, strlen($data), $name, + $timeoutMs, 0, $mcid, $err)); + return bin2hex(\FFI::string($mcid, 50)); } /** Stops sharing mcid in realm and withdraws its announcement. */ @@ -273,8 +300,8 @@ public function unshareContent(string $realm, string $mcid, int $timeoutMs = Wir $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); $id = Wire::mcid50($mcid); $idBuf = Binding::buffer($id); - Binding::call(fn ($err) => Binding::ffi()->macula_pool_unshare_content($h, $realm32, $idBuf, strlen($id), - $timeoutMs, $err), Wire::contentError(...)); + Binding::call(fn ($err) => Binding::ffi()->macula_pool_unshare_content($h, $realm32, $idBuf, $timeoutMs, 0, + $err)); } /** @@ -298,8 +325,10 @@ public function getContent( $id = Wire::mcid50($mcid); $idBuf = Binding::buffer($id); $n = Binding::size(); - $data = Binding::call(fn ($err) => Binding::ffi()->macula_pool_get_content($h, $realm32, $idBuf, strlen($id), - $maxBytes, $maxChunks, $parallel, $chunkTimeoutMs, $timeoutMs, \FFI::addr($n), $err), Wire::contentError(...)); + $opts = json_encode(['max_bytes' => $maxBytes, 'max_chunks' => $maxChunks, 'parallel' => $parallel, + 'chunk_timeout_ms' => $chunkTimeoutMs], JSON_THROW_ON_ERROR); + $data = Binding::call(fn ($err) => Binding::ffi()->macula_pool_get_content($h, $realm32, $idBuf, $opts, + $timeoutMs, 0, \FFI::addr($n), $err)); return Binding::takeBytes($data, $n->cdata); } diff --git a/src/ProviderError.php b/src/ProviderError.php index d474478..421a91c 100644 --- a/src/ProviderError.php +++ b/src/ProviderError.php @@ -14,6 +14,6 @@ final class ProviderError extends MaculaException { public function __construct(public readonly string $errorCode, public readonly string $detail) { - parent::__construct("macula-php: the provider answered {$errorCode}" . ($detail === '' ? '' : ": {$detail}")); + parent::__construct("macula-php: the provider answered {$errorCode}" . ($detail === '' ? '' : ": {$detail}"), 'provider_error'); } } diff --git a/src/RelayError.php b/src/RelayError.php index 55c3ff1..c139f8a 100644 --- a/src/RelayError.php +++ b/src/RelayError.php @@ -10,6 +10,6 @@ final class RelayError extends MaculaException { public function __construct(public readonly string $errorCode) { - parent::__construct("macula-php: the station could not relay the call: {$errorCode}"); + parent::__construct("macula-php: the station could not relay the call: {$errorCode}", 'relay_error'); } } diff --git a/src/Request.php b/src/Request.php index 33fb38b..8799bb3 100644 --- a/src/Request.php +++ b/src/Request.php @@ -14,13 +14,15 @@ public function __construct( public readonly string $procedure, public readonly mixed $payload, public readonly int $deadlineMs, + public readonly bool $sealed = false, ) { } /** @internal */ - public static function fromJson(string $json): self + public static function fromJson(string $json, BytesOutput $bytes = BytesOutput::Hex): self { $r = Wire::decode($json); - return new self($r['caller'], $r['realm'], $r['procedure'], $r['payload'], $r['deadline_ms']); + return new self($r['caller'], $r['realm'], $r['procedure'], Wire::output($r['payload'], $bytes), + $r['deadline_ms'], ($r['sealed'] ?? 0) === 1); } } diff --git a/src/Served.php b/src/Served.php index 1d25b6a..103f7f7 100644 --- a/src/Served.php +++ b/src/Served.php @@ -15,7 +15,7 @@ final class Served private bool $ended = false; /** @internal */ - public function __construct(private readonly int $handle) + public function __construct(private readonly int $handle, private readonly BytesOutput $bytes = BytesOutput::Hex) { } @@ -27,9 +27,9 @@ public function next(int $timeoutMs = 1_000): ?PendingCall return null; } [$json, $call, $ended] = Inbox::take(fn ($wait, $handle, $closed, $err) => - Binding::ffi()->macula_served_next($this->handle, $wait, $handle, $closed, $err), $timeoutMs); + Binding::ffi()->macula_served_next($this->handle, $wait, 0, $handle, $closed, $err), $timeoutMs); $this->ended = $ended; - return $json === null ? null : new PendingCall($call, Request::fromJson($json)); + return $json === null ? null : new PendingCall($call, Request::fromJson($json, $this->bytes)); } /** diff --git a/src/ServedStream.php b/src/ServedStream.php index 3f2dbbc..31023b2 100644 --- a/src/ServedStream.php +++ b/src/ServedStream.php @@ -27,7 +27,7 @@ public function next(int $timeoutMs = 1_000): ?Stream return null; } [$json, $stream, $ended] = Inbox::take(fn ($wait, $handle, $closed, $err) => - Binding::ffi()->macula_served_next($this->handle, $wait, $handle, $closed, $err), $timeoutMs); + Binding::ffi()->macula_served_next($this->handle, $wait, 0, $handle, $closed, $err), $timeoutMs); $this->ended = $ended; return $json === null ? null : new Stream($stream, $this->bytes); } diff --git a/src/Stream.php b/src/Stream.php index ed0c51d..3eee797 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -26,7 +26,7 @@ public function request(): Request { $h = $this->live(); return Request::fromJson(Binding::takeString(Binding::call(fn ($err) => - Binding::ffi()->macula_stream_request($h, $this->bytes->value, $err)))); + Binding::ffi()->macula_stream_request($h, $err))), $this->bytes); } /** Sends a raw chunk. */ @@ -82,8 +82,8 @@ public function abort(string $code, string $message = ''): void public function recv(int $timeoutMs = 0): ?StreamEvent { $h = $this->live(); - $e = Wire::decode(Binding::takeString(Binding::call(fn ($err) => - Binding::ffi()->macula_stream_recv($h, $timeoutMs, $this->bytes->value, $err)))); + $e = Wire::decodeOutput(Binding::takeString(Binding::call(fn ($err) => + Binding::ffi()->macula_stream_recv($h, $timeoutMs, 0, $err))), $this->bytes); return match ($e['kind']) { 'eof' => null, 'error' => throw new StreamError($e['code'], $e['message'] ?? '', ($e['relay'] ?? 0) === 1), diff --git a/src/StreamError.php b/src/StreamError.php index 464def8..d9af7c8 100644 --- a/src/StreamError.php +++ b/src/StreamError.php @@ -13,6 +13,6 @@ public function __construct( public readonly string $detail, public readonly bool $relay, ) { - parent::__construct("macula-php: stream error {$errorCode}" . ($detail === '' ? '' : ": {$detail}")); + parent::__construct("macula-php: stream error {$errorCode}" . ($detail === '' ? '' : ": {$detail}"), 'stream_error'); } } diff --git a/src/Subscription.php b/src/Subscription.php index f78549a..52ec53d 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -14,7 +14,7 @@ final class Subscription private bool $ended = false; /** @internal */ - public function __construct(private readonly int $handle) + public function __construct(private readonly int $handle, private readonly BytesOutput $bytes = BytesOutput::Hex) { } @@ -26,9 +26,9 @@ public function next(int $timeoutMs = 1_000): ?Event return null; } [$json, , $ended] = Inbox::take(fn ($wait, $_, $closed, $err) => - Binding::ffi()->macula_subscription_next($this->handle, $wait, $closed, $err), $timeoutMs); + Binding::ffi()->macula_subscription_next($this->handle, $wait, 0, $closed, $err), $timeoutMs); $this->ended = $ended; - return $json === null ? null : Event::fromJson($json); + return $json === null ? null : Event::fromJson($json, $this->bytes); } /** Every event as it arrives, until the subscription ends or none arrives diff --git a/src/Wire.php b/src/Wire.php index 71204c7..ab83af6 100644 --- a/src/Wire.php +++ b/src/Wire.php @@ -16,7 +16,8 @@ * Bytes have no JSON shape. Going in, give them as Wire::bytes($raw), the * tagged form `['$bytes' => base64]`; a plain string is always text. Coming * out, bytes are a "0x"-prefixed lowercase hex string by default, or the - * tagged form when BytesOutput::Tagged is asked for. + * tagged form when BytesOutput::Tagged is asked for. Only the tagged form + * tells bytes from text that happens to start with "0x". */ final class Wire { @@ -70,31 +71,25 @@ public static function mcid50(string $mcid): string } /** - * @internal The library's call error as the class it names: - * "provider_error::" and "relay_error:", any other - * text a MaculaException. + * @internal A value from the library as PHP gets it under bytes: the + * library gives every byte string tagged, `['$bytes' => base64]`; Hex + * turns each into "0x" and its lowercase hex. */ - public static function callError(string $message): MaculaException + public static function output(mixed $value, BytesOutput $bytes): mixed { - if (preg_match('/^provider_error:([^:]*):(.*)$/s', $message, $m) === 1) { - return new ProviderError($m[1], $m[2]); + if ($bytes === BytesOutput::Tagged || !is_array($value)) { + return $value; } - if (preg_match('/^relay_error:(.*)$/s', $message, $m) === 1) { - return new RelayError($m[1]); + if (count($value) === 1 && is_string($value['$bytes'] ?? null)) { + return '0x' . bin2hex(base64_decode($value['$bytes'], true)); } - return new MaculaException($message); + return array_map(static fn (mixed $v) => self::output($v, $bytes), $value); } - /** @internal The library's content errors as the classes they name. */ - public static function contentError(string $message): MaculaException + /** @internal Library JSON as PHP values under bytes. */ + public static function decodeOutput(string $json, BytesOutput $bytes): mixed { - if ($message === 'not_shared') { - return new NotSharedError(); - } - if (str_starts_with($message, 'unavailable:')) { - return new ContentUnavailableError(substr($message, strlen('unavailable:'))); - } - return new MaculaException($message); + return self::output(self::decode($json), $bytes); } private static function refuseBooleans(mixed $value, string $path): void diff --git a/tests/PoolTest.php b/tests/PoolTest.php index 546a051..73d1c23 100644 --- a/tests/PoolTest.php +++ b/tests/PoolTest.php @@ -5,6 +5,8 @@ namespace Macula\Tests; use Macula\BytesOutput; +use Macula\Confidentiality; +use Macula\ConfidentialityError; use Macula\ContentUnavailableError; use Macula\NodeKey; use Macula\NotSharedError; @@ -101,6 +103,48 @@ public function testACallReachesAProviderOnAnotherStationAndBringsItsErrorBack() } } + public function testARequiredCallIsSealedToTheKeyTheProviderAdvertises(): void + { + $provider = ProviderProcess::start(self::$env, 'sealed', 0, admitted: true); + $pool = $this->node(1); + try { + $result = null; + $this->eventually('the sealed provider answers', function () use ($pool, $provider, &$result): bool { + try { + $result = $pool->call(self::$env->realmId, $provider->procedure, 'secret', + confidential: Confidentiality::Required); + return true; + } catch (\Macula\MaculaException $e) { + return false; + } + }); + self::assertEquals(['echo' => 'secret', 'sealed' => 1], $result); + } finally { + $pool->close(); + $provider->stop(); + } + } + + public function testARequiredCallToAProviderThatNamesNoKeyIsAConfidentialityError(): void + { + $provider = ProviderProcess::start(self::$env, 'echo', 0, admitted: true); + $pool = $this->node(1); + try { + $this->eventually('the provider is advertised', fn (): bool => + $pool->providers(self::$env->realmId, $provider->procedure) !== []); + try { + $pool->call(self::$env->realmId, $provider->procedure, 'secret', confidential: Confidentiality::Required); + self::fail('a required call went in the clear'); + } catch (ConfidentialityError $e) { + self::assertSame('confidentiality', $e->kind); + self::assertNotSame('', $e->reason); + } + } finally { + $pool->close(); + $provider->stop(); + } + } + public function testACallIsRefusedForAnUnpinnedRealmAndFindsNoProviderForWhatNobodyServes(): void { $caller = $this->node(0); diff --git a/tests/TestStations.php b/tests/TestStations.php index e993bf7..0bc3bee 100644 --- a/tests/TestStations.php +++ b/tests/TestStations.php @@ -5,8 +5,8 @@ namespace Macula\Tests; /** - * Runs cabi/cmd/teststation (built to build/teststation by `composer - * build`) for a test class: two in-process macula 12 stations sharing a DHT + * Runs macula-go's teststation at the tag in abi/MACULA_GO_REF (built to + * build/teststation by `composer build`) for a test class: two in-process macula 12 stations sharing a DHT * and a test realm, driven over the helper's stdin. */ final class TestStations diff --git a/tests/fixtures/provider.php b/tests/fixtures/provider.php index 46ac9d1..4261d01 100644 --- a/tests/fixtures/provider.php +++ b/tests/fixtures/provider.php @@ -12,6 +12,7 @@ require __DIR__ . '/../../vendor/autoload.php'; +use Macula\Confidentiality; use Macula\NodeKey; use Macula\Pool; use Macula\Profile; @@ -30,7 +31,8 @@ } $seed = new Seed($config['seed']['host'], $config['seed']['port'], $config['seed']['node_id']); $realm = $config['realm_id']; -$pool = Pool::connect($key, [$seed], realmTrust: $config['realm_key'] === null ? [] : [$realm => $config['realm_key']]); +$pool = Pool::connect($key, [$seed], realmTrust: $config['realm_key'] === null ? [] : [$realm => $config['realm_key']], + kemAdvertise: $config['kind'] === 'sealed'); $org = $config['org']; [$procedure, $served, $handler] = match ($config['kind']) { @@ -44,6 +46,11 @@ function (Request $r): mixed { return ['echo' => $r->payload, 'caller' => $r->caller]; }, ], + 'sealed' => [ + "{$org}/sealed", + $pool->serve($realm, "{$org}/sealed", confidential: Confidentiality::Required), + fn (Request $r): mixed => ['sealed' => $r->sealed ? 1 : 0, 'echo' => $r->payload], + ], 'ring' => [ $pool->ownProcedure('ring'), $pool->serve($realm, $pool->ownProcedure('ring')),