Skip to content
Open
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
156 changes: 156 additions & 0 deletions .github/workflows/c-bindings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Marmot C Bindings

on:
push:
tags:
- marmotc-v*

permissions:
contents: read

concurrency:
group: marmot-c-bindings-${{ github.ref }}
cancel-in-progress: false

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_RELEASE_DEBUG: "0"

jobs:
linux-bindings:
name: Build Linux C bindings
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Check out repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false

- name: Validate release tag
shell: bash
run: |
set -euo pipefail
if [[ ! "$GITHUB_REF_NAME" =~ ^marmotc-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "error: C binding releases must use a version tag like marmotc-v0.1.0" >&2
exit 1
fi

- name: Install Rust toolchain
shell: bash
run: |
set -euo pipefail
rustup show active-toolchain || rustup toolchain install

- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
# Release artifacts are published to a public GitHub Release, so
# never write (or reuse a poisoned) cache on this tag-triggered path.
save-if: false

- name: Build C bindings
shell: bash
run: ./crates/marmot-c/c-bindings.sh

- name: Smoke-test against gcc and clang
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y valgrind
./crates/marmot-c/c-smoke.sh gcc clang

- name: Package bundle
shell: bash
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
version="${tag#marmotc-v}"
workspace_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)"
lock_sha="$(sha256sum Cargo.lock | awk '{print $1}')"

stage_parent="build/marmot-c-stage"
stage="$stage_parent/marmot-c-$version"
asset="marmot-c-linux-x86_64-$version.zip"
rm -rf "$stage_parent" "$asset" "$asset.sha256"
mkdir -p "$stage" dist

cp -R crates/marmot-c/output/. "$stage/"
cp crates/marmot-c/README.md "$stage/"
cat > "$stage/manifest.json" <<EOF
{
"artifact": "marmot-c",
"version": "$version",
"workspace_version": "$workspace_version",
"cargo_lock_sha256": "$lock_sha",
"commit": "$GITHUB_SHA",
"target": "x86_64-unknown-linux-gnu"
}
EOF

(cd "$stage_parent" && zip -r "../../$asset" "marmot-c-$version")
sha256sum "$asset" | awk -v name="$asset" '{print $1 " " name}' > "$asset.sha256"
cp "$asset" "$asset.sha256" dist/

- name: Upload bundle
uses: actions/upload-artifact@v4
with:
name: marmot-c-linux
path: dist/
if-no-files-found: error

release:
name: Create GitHub release
runs-on: ubuntu-latest
needs:
- linux-bindings
timeout-minutes: 15
permissions:
actions: read
contents: write

steps:
- name: Download binding artifacts
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p dist
gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name marmot-c-linux --dir dist

- name: Create or update release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
version="${tag#marmotc-v}"
notes="$RUNNER_TEMP/release-notes.md"
cat > "$notes" <<EOF
Marmot C bindings generated from MDK commit \`$GITHUB_SHA\`.

Assets:
- \`marmot-c-linux-x86_64-$version.zip\`: \`libmarmot_c.so\` + \`libmarmot_c.a\`, \`marmot.h\`, pkg-config file, README.

Verify downloads with the accompanying \`.sha256\` files.
EOF

assets=(
"dist/marmot-c-linux-x86_64-$version.zip"
"dist/marmot-c-linux-x86_64-$version.zip.sha256"
)

if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$tag" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --title "v$version - Marmot C" --notes-file "$notes"
else
gh release create "$tag" "${assets[@]}" \
--repo "$GITHUB_REPOSITORY" \
--title "v$version - Marmot C" \
--notes-file "$notes"
fi
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,49 @@ jobs:
- name: Run clippy with all features
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings

c-smoke:
name: C bindings smoke
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Check out repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false

- name: Install Rust toolchain
run: |
set -euo pipefail
toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml)"
rustup toolchain install "$toolchain" --profile minimal
rustup default "$toolchain"

- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

- name: Header diff gate
run: |
set -euo pipefail
# Pin the exact version so a newer cbindgen can't reformat the
# checked-in header and fail the diff gate on unrelated PRs.
cargo install cbindgen --version 0.29.4 --locked
cbindgen --config crates/marmot-c/cbindgen.toml --crate marmot-c \
--output crates/marmot-c/include/marmot.h crates/marmot-c
git diff --exit-code crates/marmot-c/include/marmot.h

- name: Alloc-audit tests
run: cargo test -p marmot-c --features alloc-audit --locked

- name: Compile and run C smoke test (gcc + clang, valgrind)
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y valgrind clang
# Also compiles and runs the Zig and Odin consumers when those
# toolchains are present; the script skips them otherwise.
./crates/marmot-c/c-smoke.sh gcc clang

rust-test:
name: Rust tests (${{ matrix.partition }}/4)
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This repo owns the Rust implementation workspace for Marmot:
- app message Markdown display parser,
- shared JSONL forensic audit schema,
- UniFFI bindings for the app runtime,
- C ABI bindings for the app runtime,
- CLI surface, daemon, and TUI,
- conformance simulator and vector fixtures,
- Tamarin models for distributed convergence,
Expand Down Expand Up @@ -49,6 +50,7 @@ The canonical protocol specification lives in
| Host integrations / connector coexistence | `integrations/AGENTS.md` |
| Forensic audit schema | `crates/marmot-forensics/AGENTS.md` |
| App runtime UniFFI bindings | `crates/marmot-uniffi/AGENTS.md` |
| App runtime C ABI bindings | `crates/marmot-c/AGENTS.md` |
| CLI / daemon / TUI surface | `crates/cli/AGENTS.md` |
| Multi-client harness / vectors | `crates/cgka-conformance-simulator/AGENTS.md` |
| Architecture docs | `docs/AGENTS.md` and `docs/marmot-architecture/AGENTS.md` |
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ members = [
"crates/marmot-account",
"crates/marmot-app",
"crates/marmot-uniffi",
"crates/marmot-c",
"crates/cli",
"integrations/opencode/marmot",
]
Expand Down Expand Up @@ -113,4 +114,5 @@ marmot-markdown = { path = "crates/marmot-markdown" }
marmot-account = { path = "crates/marmot-account" }
marmot-app = { path = "crates/marmot-app" }
marmot-uniffi = { path = "crates/marmot-uniffi" }
marmot-c = { path = "crates/marmot-c" }
wn-cli = { path = "crates/cli" }
8 changes: 8 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ dead-code-audit:
naming-gate:
./scripts/check_legacy_naming.sh

# Regenerate the checked-in C header from crates/marmot-c (CI diff-gates it).
c-header:
cbindgen --config crates/marmot-c/cbindgen.toml --crate marmot-c --output crates/marmot-c/include/marmot.h crates/marmot-c

# Build marmot-c and compile+run the C smoke test (valgrind when available).
c-smoke:
./crates/marmot-c/c-smoke.sh

# Fast local pre-push gate: mechanical/static checks only. GitHub CI runs the
# full `just ci` suite (including the workspace test matrix).
fast-ci: fmt-check naming-gate check clippy
Expand Down
5 changes: 5 additions & 0 deletions crates/marmot-c/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Generated/derived build artifacts. c-bindings.sh writes into output/ and
# c-smoke.sh writes into build/. The checked-in include/marmot.h header is the
# source of truth for the C surface (CI diff-gates it); nothing else here is.
/build/
/output/
51 changes: 51 additions & 0 deletions crates/marmot-c/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# AGENTS.md - marmot-c

C ABI bindings for the Marmot app runtime. Read `README.md` first for build, linking, and packaging.

## Scope

- Own the stable C ABI over `marmot-uniffi` for C/C++, Zig, Odin, and raw-FFI consumers (Go, Lua, …).
- Own `include/marmot.h` (generated by cbindgen, checked in) and `cbindgen.toml`.
- Own `c-bindings.sh` (cdylib + staticlib + header + pkg-config staging into `output/`) and the `examples/` consumers
(`smoke.{c,zig,nim,go,odin,lua,php}`) driven by `c-smoke.sh`.

## Known gaps

- External-signer accounts (`register_external_signer` / `login_external_signer`) are not yet exposed. They take a
host-implemented `ExternalAccountSignerFfi` trait (public key, sign event, NIP-04 encrypt/decrypt). A C mapping needs a
callback vtable (function pointers + `user_data`) invoked from runtime worker threads, with owned-string return and
error signalling across the boundary. That deserves its own focused design + tests rather than a rushed vtable.

## Architecture

- `marmot-uniffi` is the single source of truth for FFI DTO shapes and the runtime object; this crate never talks to
`marmot-app` directly. Every `…Ffi` record/enum has a `#[repr(C)]` mirror in `src/types/` (one module per
`marmot-uniffi` conversions module) built by `From<…Ffi>` and released by a `CFree` deep-free.
- `src/commands/` holds the `extern "C"` wrappers (one module per `marmot-uniffi` commands module): `ffi_guard`
(catch_unwind), borrowed-input readers, `block_on` onto the embedded tokio runtime, mirror conversion, out-pointer.
- `src/subscriptions.rs` wraps the 8 subscription objects as opaque handles with blocking `*_next` (timeout →
`MARMOT_STATUS_TIMEOUT`, shutdown → `MARMOT_STATUS_CLOSED`) and optional callback registration.
- `src/memory.rs` is the only allocation chokepoint; the `alloc-audit` test feature counts live allocations so tests
can prove deep-free completeness.

## Invariants

- No struct crossing the ABI is freed anywhere except its own `marmot_*_free`; frees are deep and NULL-tolerant;
input structs and strings are borrowed and never freed or retained.
- Every `extern "C"` body is wrapped in `ffi_guard` — panics must not unwind into C.
- `MarmotStatus` values are stable ABI: append, never renumber. Keep the variant set in lockstep with
`marmot_uniffi::MarmotKitError`.
- `include/marmot.h` is generated, never hand-edited; regenerate with `just c-header`. CI diff-gates it.
- Host-supplied `group_id_hex` values are variable-length MLS `GroupId` bytes — do not validate them with the
32-byte route-id/pubkey/message-id rule.
- No `tracing`/logging in this crate; error detail strings carry only what `MarmotKitError` Display already exposes.
- Keep this crate in lockstep with `marmot-uniffi` surface changes; bump the workspace version when mirrors,
commands, status codes, or callback signatures change.

## Verification

```sh
cargo test -p marmot-c --features alloc-audit
just c-header && git diff --exit-code crates/marmot-c/include/marmot.h
just c-smoke # compiles and runs examples/smoke.c (valgrind when available)
```
31 changes: 31 additions & 0 deletions crates/marmot-c/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Changelog

All notable changes to `marmot-c` are tracked here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This crate uses semantic
versioning through the workspace version in the root `Cargo.toml`.

## [0.9.3] - 2026-07-08

### Added

- Added `marmot-c`, a stable C ABI over the Marmot app runtime for consumers that cannot pull in a UniFFI runtime
(C/C++, and raw-FFI callers such as Zig, Nim, Go, Odin, Lua, and PHP). The crate builds a `cdylib` and a `staticlib`,
ships a cbindgen-generated, checked-in `include/marmot.h`, and mirrors the full `marmot-uniffi` surface: account and
session lifecycle, group operations, messaging, media, timeline, notifications, push, audit, relay health, agent text
streams, and Markdown parsing.
- Every runtime record/enum has a `#[repr(C)]` mirror built from the `marmot-uniffi` `…Ffi` types, so the C shape can
never drift from the Swift/Kotlin surface. Complex values cross the ABI as owned pointers freed only by their matching
`marmot_*_free`; inputs are borrowed and never retained. Async runtime work runs on an embedded tokio runtime via
blocking calls, and subscriptions expose both blocking `*_next` reads and callback registration.
- Added memory-safety scaffolding: an `alloc-audit` test feature that proves deep-free completeness, `catch_unwind` at
every ABI boundary so panics never unwind into C, and a typed `MarmotStatus` code per `MarmotKitError` variant with a
thread-local last-error detail channel.
- Added `marmot_download_group_blossom_image` (fetch + verify + decrypt the group's encrypted Blossom image to an owned
byte buffer freed with `marmot_bytes_free`), completing the group-image surface alongside `image_hash_hex`. External
signer accounts remain a documented gap pending a C callback-vtable design.
- Added packaging and cross-language verification: `c-bindings.sh` (staged libraries + header + pkg-config),
`c-smoke.sh`, and seven worked example consumers (`examples/smoke.{c,zig,nim,go,odin,lua,php}`) exercised in CI, plus a
`marmotc-v*` release track titled `v<version> - Marmot C`.

Closes [#328](https://github.com/marmot-protocol/mdk/issues/328)
1 change: 1 addition & 0 deletions crates/marmot-c/CLAUDE.md
25 changes: 25 additions & 0 deletions crates/marmot-c/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "marmot-c"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true

[lib]
name = "marmot_c"
crate-type = ["cdylib", "staticlib", "lib"]

[features]
default = []
# Test-only accounting of every FFI allocation/free pair; used by the
# roundtrip tests to prove deep-free completeness. Never enable in release
# artifacts.
alloc-audit = []

[dependencies]
marmot-uniffi.workspace = true
tokio = { workspace = true, features = ["io-util", "net", "time"] }
libc.workspace = true

[dev-dependencies]
tempfile.workspace = true
Loading
Loading