Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release-ffi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
# Dry-run needs no registry auth -- it only verifies metadata,
# compresses the package, and checks the result, never uploads.
# This is also the step that proves macula-rust-ffi's `macula-rust
# = { path = "..", version = "0.4" }` dependency actually resolves
# = { path = "..", version = "0.5" }` dependency actually resolves
# against the REAL published macula-rust on crates.io, not just the
# local workspace path -- `cargo publish` downloads and rebuilds
# against the registry version during verification, confirmed
Expand Down
38 changes: 36 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,28 @@ usually touches both, but their version numbers don't move in lockstep.

## macula-rust

### [0.4.0] - Unreleased
### [0.5.0] - Unreleased

#### Added

- Node-served content (macula 12's D27), ported from macula-go v0.12.0's
`pool/content.go`: `Pool::share_content` keeps the content, serves it on the
node's own `~<node_id>/content_v1` server stream and announces it in the DHT,
renewed at half its hour; `unshare_content` withdraws it with a tombstone;
`get_content` finds the announcements bound to their announcer's own content
procedure in the realm, dials each sharer's station, and checks the block,
the manifest and every chunk against the content id, within
`ContentOptions` (256 MiB, 16,384 chunks, 4 streams at a time, 15 s each by
default). No realm key on either side. `content_procedure_bound` is public.
New `PoolError`s: `NotShared`, `ContentUnavailable`, `ContentMismatch`,
`ContentTooLarge`, `ContentReply`.
- `manifest`: macula 12's content manifests, byte for byte with macula's own
(`tests/vectors/manifest/erlang_manifests.json`): 256 KiB chunks, SHA-384,
50-byte content ids, the odd-leaf Merkle fold, and the wire form, read with
macula_manifest's checks (sha384 only, whole chunks, fields in range).
- Example `content`.

### [0.4.0] - 2026-09-26

The macula 12 wire. **Breaking throughout**: a 0.3 node cannot reach a
macula 12 station, and nothing of the 0.3 API carries over. See the README's
Expand Down Expand Up @@ -340,7 +361,20 @@ same day, not a separate feature set. Independently versioned from the core
crate since day one (this crate started at 0.1.0 the same day the core crate
did, but the two have moved at different paces ever since).

### [ffi-0.4.0] - Unreleased
### [ffi-0.5.0] - Unreleased

#### Added

- `FfiPool::share_content`, `unshare_content` and `get_content`, with
`FfiContentOptions` (zero for macula's defaults). New `FfiError`s:
`NotShared` and `ContentUnavailable`, which names why each sharer failed; a
content id of another length than 50 bytes is `WrongByteLength`.

#### Changed

- Builds on `macula-rust` 0.5.

### [ffi-0.4.0] - 2026-09-26

**Breaking throughout**: rewritten on `macula-rust` 0.4's pool, the macula 12
wire.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["macula-rust-ffi"]

[package]
name = "macula-rust"
version = "0.4.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.89"
authors = ["Macula <raf.lefever@erlef.org>"]
Expand Down
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
> ML-DSA-87 identities (in pq_hybrid, the fleet's profile, the ML-DSA-87 +
> RSA-PSS-4096 composite), ML-KEM hybrid key exchange, and signed requests.
> Calls and streams by direct dial, serving (under an org or in a node's own
> namespace), publish/subscribe and the DHT are tested against in-process
> macula 12 stations on every `cargo test`, and live against the fleet. Not
> here yet: UCAN-gated calls and node-served content; see [Not yet
> implemented](#not-yet-implemented). Releases before 0.4.0 speak the retired
> 10.x wire and cannot reach the current fleet.
> namespace), publish/subscribe, node-served content and the DHT are tested
> against in-process macula 12 stations on every `cargo test`, and calls,
> pubsub and the DHT live against the fleet. Not here yet: UCAN-gated calls;
> see [Not yet implemented](#not-yet-implemented). Releases before 0.4.0 speak
> the retired 10.x wire and cannot reach the current fleet.

## What is this?

Expand All @@ -48,7 +48,7 @@ Swift. The core crate has no FFI dependency and no FFI-shaped types.

```toml
[dependencies]
macula-rust = "0.4"
macula-rust = "0.5"
tokio = { version = "1", features = ["full"] }
```

Expand Down Expand Up @@ -109,8 +109,8 @@ let served = pool
.await?;
```

Runnable versions are in [`examples/`](examples): `quickstart`, `serve` and
`publish_subscribe`, each reading the environment described at the top of
Runnable versions are in [`examples/`](examples): `quickstart`, `serve`,
`publish_subscribe` and `content`, each reading the environment described at the top of
[`examples/common/mod.rs`](examples/common/mod.rs).

### Coming from 0.3 and earlier
Expand All @@ -129,8 +129,10 @@ compatibility layer.
`direct_dial::call` is simply `Pool::call`; `resolve` is `Pool::providers`;
`serve_one_call` is `Pool::serve` with a handler; `Trust::WebPki` is gone:
every station is pinned by its node_id.
- `ucan`, `cert_chain` and the content-transfer modules are gone until
macula 12's own arrive (see [Not yet implemented](#not-yet-implemented)).
- The 10.x content transfer (`content`, `manifest`, `put_direct`/`get_direct`)
is replaced by node-served content: `Pool::share_content`/`get_content`, with
macula 12's SHA-384 `manifest`. `ucan` and `cert_chain` are gone until macula
12's own arrive (see [Not yet implemented](#not-yet-implemented)).
- Serving an org procedure needs the realm's org directory and the org's
delegation to your node in the DHT: a realm admits orgs through a human.

Expand All @@ -145,6 +147,7 @@ compatibility layer.
| A node's own namespace (`record::own_procedure`) | ✅ | ✅ | `~<node_id>/<name>`: served and called with no org and no realm key |
| Streams (`open_stream`, `Offer::stream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path |
| Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links |
| Node-served content (`share_content`, `unshare_content`, `get_content`) | ✅ | ✅ | Shared on the node's own `~<node_id>/content_v1` and announced; a fetch checks the block, the manifest and every chunk against the content id, bounded (`ContentOptions`), with no realm key; manifests match macula's byte for byte (`manifest`) |
| DHT (`find_record`, `find_records`, `find_records_by_type`, `put_record`) | ✅ | — | Records verified before they are handed on |
| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | `macula-rust-ffi`, below |

Expand All @@ -165,8 +168,10 @@ integers within ±2^63, text or integer map keys, no duplicates).
`macula-rust-ffi` wraps the pool with [UniFFI](https://mozilla.github.io/uniffi-rs/)
proc macros: `FfiNodeKey`, `FfiPool`, `FfiSubscription`, `FfiStream`, and two
handlers the app implements, `FfiCallHandler` and `FfiStreamHandler`
(`suspend fun` in Kotlin, `async throws` in Swift). Every 32-byte id crosses as
bytes and is checked.
(`suspend fun` in Kotlin, `async throws` in Swift). `FfiPool` also shares and
fetches content (`shareContent`, `getContent`, `FfiContentOptions`). Every id
crosses as bytes and is checked for its length (32 bytes, or 50 for a content
id).

```bash
cargo build -p macula-rust-ffi --release
Expand Down Expand Up @@ -203,7 +208,6 @@ crate 1.89.

- **UCAN-gated calls and serving.** macula 12 uses post-quantum UCANs; calls
carry no token yet, and a gated procedure cannot be served.
- **Node-served content** (macula 12's D27): planned for 0.5.0.
- **Station discovery beyond the seeds.** macula's discovery call is not
served by the fleet today (macula-io/macula#31); give the pool its seeds.

Expand Down
32 changes: 32 additions & 0 deletions examples/content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! Shares content from one node and fetches it from another by its content
//! id. The sharer keeps the content and serves it from its own namespace;
//! the fetcher checks everything it receives against the content id, so
//! neither needs a realm key.
//!
//! Run: `cargo run --example content`, with the environment
//! examples/common/mod.rs reads. The fetcher's key is `fetcher.key`.

mod common;

use macula_rust::pool::ContentOptions;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sharer = common::connect(None).await;
let data: Vec<u8> = (0..600_000usize).map(|i| (i % 251) as u8).collect();
let mcid = sharer
.share_content(&common::realm(), &data, "example.bin")
.await?;
println!("shared {} bytes as {}", data.len(), common::hex(&mcid));

let fetcher = common::connect(Some("fetcher.key")).await;
let got = fetcher
.get_content(&common::realm(), &mcid, ContentOptions::default())
.await?;
println!("fetched {} bytes, the same: {}", got.len(), got == data);

sharer.unshare_content(&common::realm(), &mcid).await?;
fetcher.close().await;
sharer.close().await;
Ok(())
}
4 changes: 2 additions & 2 deletions macula-rust-ffi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "macula-rust-ffi"
version = "0.4.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.91"
authors = ["Macula <raf.lefever@erlef.org>"]
Expand All @@ -27,7 +27,7 @@ name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"

[dependencies]
macula-rust = { path = "..", version = "0.4" }
macula-rust = { path = "..", version = "0.5" }
uniffi = { version = "0.32", features = ["cli", "tokio"] }
tokio = { version = "1", features = ["full"] }
thiserror = "2"
Expand Down
95 changes: 95 additions & 0 deletions macula-rust-ffi/src/content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Node-served content (D27): a node shares content it keeps, served from
//! its own `~<node_id>/content_v1` and announced in the DHT; another node
//! fetches it by its 50-byte content id, checking everything it receives
//! against that id. No realm key is needed on either side.

use macula_rust::manifest::Mcid;
use macula_rust::pool::ContentOptions;

use crate::pool::FfiPool;
use crate::{millis, to_32, FfiError};

/// A fetch's bounds. Zero is macula's default: 256 MiB, 16,384 chunks, 4
/// chunk streams at a time, 15 seconds per stream.
#[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FfiContentOptions {
#[uniffi(default = 0)]
pub max_bytes: u64,
#[uniffi(default = 0)]
pub max_chunks: u64,
#[uniffi(default = 0)]
pub parallel: u32,
#[uniffi(default = 0)]
pub chunk_timeout_ms: u64,
}

impl From<FfiContentOptions> for ContentOptions {
fn from(o: FfiContentOptions) -> Self {
let d = ContentOptions::default();
let or = |v: u64, fallback: u64| if v == 0 { fallback } else { v };
ContentOptions {
max_bytes: or(o.max_bytes, d.max_bytes),
max_chunks: or(o.max_chunks, d.max_chunks),
parallel: or(u64::from(o.parallel), d.parallel as u64) as usize,
chunk_timeout: if o.chunk_timeout_ms == 0 {
d.chunk_timeout
} else {
millis(o.chunk_timeout_ms)
},
}
}
}

/// A content id: 50 bytes.
fn to_mcid(bytes: Vec<u8>) -> Result<Mcid, FfiError> {
let actual = bytes.len() as u32;
bytes.try_into().map_err(|_| FfiError::WrongByteLength {
expected: 50,
actual,
})
}

#[uniffi::export(async_runtime = "tokio")]
impl FfiPool {
/// Keeps `data`, serves it and announces it in `realm`, until
/// [`unshare_content`](Self::unshare_content). Returns its 50-byte
/// content id: a raw block's for up to 256 KiB, a manifest's, named
/// `name`, above that.
pub async fn share_content(
&self,
realm: Vec<u8>,
data: Vec<u8>,
name: String,
) -> Result<Vec<u8>, FfiError> {
Ok(self
.0
.share_content(&to_32(realm)?, &data, &name)
.await?
.to_vec())
}

/// Stops sharing `mcid` in `realm` and withdraws its announcement.
pub async fn unshare_content(&self, realm: Vec<u8>, mcid: Vec<u8>) -> Result<(), FfiError> {
Ok(self
.0
.unshare_content(&to_32(realm)?, &to_mcid(mcid)?)
.await?)
}

/// Fetches the content `mcid` names in `realm` from a node that shares
/// it, checked against `mcid` throughout. [`FfiError::NotShared`] when
/// nobody announces it; [`FfiError::ContentUnavailable`] when every
/// sharer failed, naming why.
pub async fn get_content(
&self,
realm: Vec<u8>,
mcid: Vec<u8>,
options: FfiContentOptions,
) -> Result<Vec<u8>, FfiError> {
let mcid = to_mcid(mcid)?;
Ok(self
.0
.get_content(&to_32(realm)?, &mcid, options.into())
.await?)
}
}
17 changes: 16 additions & 1 deletion macula-rust-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
//! calls to a provider at its own station, serving a procedure with a
//! handler the foreign side implements ([`FfiCallHandler`]), pubsub
//! ([`FfiSubscription`]), streaming sessions on either side ([`FfiStream`],
//! [`FfiStreamHandler`]), and DHT records.
//! [`FfiStreamHandler`]), node-served content (`share_content`,
//! `get_content`, [`FfiContentOptions`]), and DHT records.
//!
//! [`FfiValue`] mirrors every variant [`macula_rust::cbor::Value`] has,
//! narrowed only where the FFI boundary forces it: `Int` is `i64`, and an
Expand All @@ -27,12 +28,14 @@
//! --language kotlin --out-dir bindings/kotlin
//! ```

mod content;
mod node_key;
mod pool;
mod pubsub;
mod serve;
mod stream;

pub use content::FfiContentOptions;
pub use node_key::{FfiNodeKey, FfiProfile};
pub use pool::{
own_procedure, FfiLinkStatus, FfiPool, FfiPoolOptions, FfiProvider, FfiRealmKey, FfiRecord,
Expand Down Expand Up @@ -105,6 +108,14 @@ pub enum FfiError {
/// An operation on a closed pool, subscription, stream or serving.
#[error("closed")]
Closed,
/// Content no node announces in the realm.
#[error("the content is not shared")]
NotShared,
/// Content every announcing sharer failed to give, and why each failed:
/// content that does not match its id, is over the fetch's bounds, or a
/// sharer that could not be reached.
#[error("{message}")]
ContentUnavailable { message: String },
/// Anything else the core crate reports, as its text.
#[error("{message}")]
Other { message: String },
Expand Down Expand Up @@ -148,6 +159,10 @@ impl From<PoolError> for FfiError {
PoolError::Link(link) => link.into(),
PoolError::NoRealmKey => FfiError::NoRealmKey,
PoolError::Closed => FfiError::Closed,
PoolError::NotShared => FfiError::NotShared,
e @ PoolError::ContentUnavailable(_) => FfiError::ContentUnavailable {
message: e.to_string(),
},
e @ PoolError::NoProvider(_) => FfiError::NoProvider {
message: e.to_string(),
},
Expand Down
Loading
Loading